diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 00000000..2ac8d7b1
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,21 @@
+# actionlint configuration.
+#
+# actionlint validates `runs-on:` against GitHub's list of hosted-runner labels
+# and reports anything else as unknown, because a typo'd label is a job that
+# queues forever rather than failing. Self-hosted labels therefore have to be
+# declared here — that is the mechanism actionlint's own diagnostic points at.
+#
+# Run it over the whole tree with:
+#
+# actionlint
+#
+# (no arguments — it discovers `.github/workflows/*.yml` and reads this file).
+
+self-hosted-runner:
+ labels:
+ # The maintainer's self-hosted runner for the Antigravity PR reviewer
+ # (`.github/workflows/antigravity-review.yml`, `runs-on: [self-hosted, agy]`).
+ # It is a personal machine holding the `agy` CLI's Google AI Ultra OAuth
+ # session, which is why that workflow cannot run on a hosted runner. See
+ # `Local_Only-Projects/antigravity-pr-review/README.md` in the workspace.
+ - agy
diff --git a/.github/release-notes/v2.2.3.md b/.github/release-notes/v2.2.3.md
new file mode 100644
index 00000000..d67eb7ed
--- /dev/null
+++ b/.github/release-notes/v2.2.3.md
@@ -0,0 +1,141 @@
+# RustyNES v2.2.3 — "Datum" (fast dot path promoted + PGO shipped + the last two mapper residuals closed)
+
+A **performance and accuracy-closure patch**, produced by a measure-first appraisal: profile the emulator, then act on what the profile actually shows. Two of the resulting candidates were **measured and rejected**, and are published here with their numbers alongside the ones that landed — this project records what did not clear the bar as well as what did.
+
+A *datum* is the fixed reference a measurement is taken from. That is what this release is about: promoting a win that was already proven, pinning the references that keep it honest, and closing the two accuracy residuals that had a real oracle waiting for them.
+
+**AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, `pal_apu_tests` 10/10, `visual_regression` unmoved. Full suite **2238 passed / 0 failed**.
+
+---
+
+## Performance
+
+### The specialized PPU fast dot path is now the default — and reachable for the first time
+
+`Ppu::tick` is the emulator's hottest function at **32.8%** of frame self-time. v2.1.8 added `tick_visible_render_fast`, a straight-line handler for the common undisturbed visible background dot, and shipped it **off** as that roadmap's highest-risk item, pending "maintainer review and a clean-host Criterion confirmation".
+
+Both conditions are now met, so it defaults on. Clean-host `full_frame`: `nes_run_frame_nestest` **4.4343 ms → 3.9331 ms (−11.3%)**; the rendering-disabled `flowing_palette` workload is unchanged (−0.07%, noise — its guard bails at `rendering_enabled()`). That independently reproduces v2.1.8's +12.3% measurement by a different method.
+
+The more important half: **the win was previously unreachable by any user.** `Nes::set_fast_dotloop` had **zero callers** outside the core and its own tests — nothing in `rustynes-frontend`, `rustynes-libretro`, or `rustynes-mobile`. It is now surfaced as an `[emulation]` config key carrying `#[serde(default = "default_fast_dotloop")]`, which defaults it **on** for configs written before this release, so an existing `config.toml` inherits the speedup rather than silently opting out of it. The named function is load-bearing: a plain `#[serde(default)]` would fall back to `bool`'s `Default` — `false` — and quietly opt every pre-v2.2.3 config *out* of the change this release is about.
+
+**Byte-identical, and not newly so.** `fast_dotloop_diff.rs` has compared both paths' framebuffer, palette-index framebuffer, audio, CPU-cycle count and full core snapshot **every frame** since v2.1.8. Re-verified with the new default across the whole `--features test-roms` suite.
+
+### Release builds ship PGO-optimized Linux binaries
+
+`.github/workflows/pgo.yml` already computed a promotion verdict and uploaded a gated binary; `release.yml` had no reference to it, so a *passing* >3%-and-byte-identical gate still shipped a plain release. The two are now wired together: the release workflow consumes the PGO artifact when `outputs.promotable == 'true'` and falls back to the plain build otherwise.
+
+Scoped honestly to **linux-x86_64**, which is where the gate already runs. PGO for macOS-aarch64 and Windows needs per-target native training runs; that is a follow-up, not an assumed freebie.
+
+### A same-runner relative frame-time regression gate
+
+`ci.yml`'s bench job enforced a deliberately loose **absolute** ceiling (~10 ms against the 16.67 ms NTSC deadline), chosen to be non-flaky on shared runners. The looseness was a real hole: on the ~4 ms/frame the core actually runs at, a change could get **2.5x slower** and still pass.
+
+`scripts/bench_relative_check.sh` closes it by building and benching merge-base and HEAD **back to back on the same runner**, so runner-to-runner variance is common-mode and cancels — the identical technique `pgo.yml` already relies on for its promotion bar. The absolute ceiling is kept alongside it.
+
+### Two optimizations measured and REJECTED
+
+Both are written up in `docs/performance.md` with their measurements, per that file's standing convention.
+
+**P3 — `emit_pixel` bounds-check elision.** `perf annotate` showed the cost is not the pixel math but the *stores*: `framebuffer: Box<[u8]>` carries a runtime length, so the optimiser emits a bounds check and panic path per pixel, twice, 61,440 times a frame. The candidate (fixed-size boxed arrays plus a branchless clamp so both checks fold away) made the **shipped default slower** — `nestest_fast` **+4.32%** (p = 0.00) and `flowing_palette_fast` **+3.35%** (p = 0.02), and since P1 promoted the fast path to the default, those are the configuration that ships. The one favourable number, −3.10% on the now-non-default exact path, is not significant (p = 0.09, CI spans zero). Reverted.
+
+Two lessons recorded with it: perf's self-time *percentage* is not a verdict (`emit_pixel` measured **higher** after the change, 10.26% vs 9.38% — a share, not a duration), and removing a well-predicted bounds check is not free.
+
+**P4 — `cpu_clock` (22.4% of frame self-time).** The plan expected DMA/PPU hook overhead; `perf annotate` showed floating point — the APU inlined through `apu_advance_one`. Both textbook optimizations turned out to be **already implemented**: the per-channel gain short-circuits at unity, and the FIR scatter is already guarded by `delta != 0.0`.
+
+A first probe stubbing the mixed sample to a constant showed a 6.9–7.9% "win" that was an artifact — a constant lets LLVM prove `delta == 0.0` and delete the entire band-limited scatter, so it measured synthesis, not the mixer. The clean measurement adds a **second, discarded `black_box`ed mixer call**, leaving the value into `add_sample` untouched: **+1.9%**. That is the hard ceiling on caching the mix across unchanged cycles, before paying for change detection. Below the 3% bar; nothing adopted.
+
+---
+
+## Accuracy
+
+### The last two Holy Mapperel residuals are closed — 17/17 now `detail=0000`
+
+Both were single missing register states, not bank-reachability defects.
+
+**MMC1 software WRAM write-protect.** MMC1 carries **two** PRG-RAM write-protect layers and RustyNES modelled neither, reading and writing `$6000-$7FFF` unconditionally: the `$E000` bit-4 disable common to every board, and SNROM's second layer, where on a CHR-**RAM** board the CHR bank register's bit 4 is wired to the RAM's other enable. That distinction is exactly what the oracle measured — `1000` on SJROM (one layer) versus `5000` on SNROM (both). The SNROM layer is gated on `chr_is_ram`, since on a CHR-ROM board those same bits are real CHR banking; getting that gate wrong would break every SJROM/SUROM title, so it carries its own negative-control test.
+
+Holy Mapperel's own README calls this a **game-compatibility hazard** and notes FCEUX and PowerPak decline to model it. It was therefore validated before landing rather than assumed: the commercial-ROM oracle passes **60/60** — including seven battery-backed MMC1 saves (Zelda, Metroid, Final Fantasy, Mega Man 2, Castlevania II, Ninja Gaiden, Kid Icarus), precisely the titles that corrupt if the RAM enable is wrong — and the extended corpus **138/138**. No regression, so it ships on by default rather than behind the default-off knob held in reserve.
+
+**FME-7 open bus on the RAM-selected-but-disabled window.** Command `$8` bit 6 = 1 with bit 7 = 0 drives neither the RAM nor the ROM chip, so the databus floats; RustyNES fell through to the PRG-ROM bank and returned its tag byte. Both fixes route through `Mapper::cpu_read_unmapped`, the trait's existing "not wired to mapper-resident memory" contract, so the bus preserves the open-bus latch instead of clobbering it.
+
+Each was **negative-controlled** by decoding the ROM's on-screen result to ASCII and confirming that reverting flips the digit back — the harness's `detail=` string is a hand-maintained classification, not a measurement, so it would have kept reporting the old value after the behaviour changed.
+
+### Sunsoft 5B absolute level calibrated — and `Mapper::mix_audio` widened to `i32`
+
+The 5B's logarithmic DAC *shape* has been hardware-exact since v2.1.6, but its *absolute* level was a documented gap. The blocker was never the value — it was the **type**. `Mapper::mix_audio` returned `i16`, and the correct full-scale 5B tone is `1882 * 18.471 = 34,761`, past `i16::MAX` for a **single** channel; three simultaneous full-volume tones (Gimmick!, Hebereke) reach ~104k.
+
+The target is derived from Mesen2 source rather than from prior in-repo numbers: a full-volume 2A03 square is `(95.88 * 5000) / (8128/15 + 100) = 746.9` units, and the 5B sums at weight `* 15` over `1.1885^(2i)`, giving **1.265x** at volume 12 and 3.554x at full scale. Shape and level are now separate constants with separate oracles.
+
+The NSF path had to follow: `NsfExpansion::mix` summed into an `i16` **with a clamp**, so a calibrated 5B would have *clipped* on an NSF tune where the identical cartridge path does not.
+
+### A save-state schema gap — `PPU_SNAPSHOT_VERSION` 8 and an APU v4 tail
+
+AccuracyCoin scored 141/141 headless but **138/141** through the app. Root cause: run-ahead (`snapshot` → N frames → `restore`) round-trips PPU state that the v7 snapshot did not carry — the sprite-evaluation FSM and the OAM data-bus model — so three tests that depend on it failed only under run-ahead.
+
+The v8 tail carries that state, and four cached scanline-classification flags are invalidated on restore at **every** version. A new `accuracycoin_runahead` test now runs the whole battery through run-ahead at depths 1 and 2, so the discrepancy cannot recur silently.
+
+**This bumps a save-state schema version:** a pre-v8 `.rns` slot now fails to load with a clear `VersionMismatch` instead of silently misinterpreting stale bytes (ADR 0028, ADR 0034). Accepted deliberately — the alternative is restoring a broken sprite-evaluation FSM.
+
+To be precise about what that does and does not affect, since "netplay is unaffected" would be too glib: the incompatibility is confined to **`.rns` files on disk**, which are the only pre-v8 bytes that exist. Movie playback (`.rnm`) is unaffected — a movie replays inputs from a fresh power-on and stores no PPU snapshot. Netplay rollback and TAS seeking *do* go through snapshot/restore, but only against in-memory snapshots taken by the running build, so they never see a v7 payload; they are not merely unaffected but are the direct beneficiaries of the v8 state, which is what makes rollback and run-ahead reproduce the sprite-evaluation FSM correctly.
+
+### A standing field-vs-schema audit, which immediately found two more gaps
+
+The mechanical diff that caught the above is now a test. `snapshot_schema_audit.rs` compares each chip's live fields against what its snapshot writer actually serializes, and its `known_gaps` list is **empty**. It found the new v8 field and, unprompted, **two pre-existing APU gaps** — fixed by an APU **v4** tail.
+
+### Zapper beam-relative light model (opt-in, default off)
+
+The photodiode charges as the CRT beam passes the aim point and drains over ~19–26 scanlines. RustyNES sampled the *completed* framebuffer once per frame, so the light bit was constant for the whole frame — a category error, not a resolution shortfall.
+
+`Nes::set_zapper_temporal_light` makes light a function of where the beam is at the moment of the `$4016`/`$4017` read. It holds **no extra state** (derived on demand from framebuffer + aim + scanline), so it adds nothing to serialize and cannot desync a save state or a netplay rollback, and both models share one aperture test so they differ only in *when* they sample.
+
+It stays **opt-in** because no redistributable pass/fail light-gun ROM exists to adjudicate it: the supported titles re-poll every frame and are satisfied by either model, so promoting it would change output with no oracle able to confirm the change is an improvement.
+
+---
+
+## Changed
+
+### Mapper modules are named for the board they emulate, not the sprint that added them
+
+Eleven `sprintN.rs` files — **27,631 lines, ~110 boards** — were named after a point in the development calendar. `sprint3.rs` held VRC2, VRC4, VRC6, VRC7, Sunsoft FME-7 and Namco 163; nothing in the name said so. They are replaced by board-named modules, and every single-mapper file now carries its iNES mapper number as an `mNNN_` prefix so the directory sorts by mapper: `m000_nrom.rs`, `m004_mmc3.rs`, `m009_mmc2.rs`, `m069_sunsoft_fme7.rs`.
+
+The convention was not invented here — 42 of the 54 existing files were already board-named; the sprint files were the outliers. Files implementing **one** shared core across many mapper IDs keep plain descriptive names, because no single number describes them (`mmc3_clones.rs` = 11 IDs, `multicart_discrete.rs` = 27, `kaiser.rs` = 6).
+
+**This moves code; it does not change it** — verified mechanically rather than asserted. A checker parses every top-level item out of the originals and compares the code byte for byte: **930 items byte-identical, 0 missing, 0 altered**, and the `parse()` dispatch table resolves the same **172** mapper IDs to the same constructors, an identical set. Test count moves only because five tests that each exercised two-to-four different boards were split per board, so a failure now names the board.
+
+Also renamed for the same reason: `tests/roms/sprint-2/` → `tests/roms/assorted/`, and nineteen ad-hoc scripts in `scripts/diag/` (`an2.py` → `ppu2002_isolated_exact_timed_reads.py`, and so on).
+
+### Seven commercial-oracle audio rows re-blessed — and the structural hole plugged
+
+Seven `external_real_games` rows failed on `audio_fnv1a64` **alone**; frames, cycles, sample counts and every framebuffer checkpoint were byte-identical. The MMC5 (x3) and VRC6 (x3) rows had been stale since **v2.1.6**, when `VRC6_MIX_SCALE` and all three MMC5 level constants were recalibrated — those snapshots were last blessed 28 days earlier. The FME-7 row moved with this release's own 5B calibration.
+
+Root cause was structural: the suite needs `--features commercial-roms` **and** local gitignored ROM dumps, so neither CI nor the default gate can run it, and a golden vector nothing executes only accumulates drift. A new `expansion_level_tripwire` unit test — which CI **does** run — pins every expansion-audio level constant and fails with instructions naming both suites that must be re-blessed in the same change.
+
+---
+
+## Upgrade notes
+
+- **Save states:** `.rns` slots written by v2.2.2 or earlier will **not** load; they fail with a clear version error rather than misinterpreting stale bytes. Movies (`.rnm`) and netplay replays are unaffected.
+- **Config:** the new `[emulation] fast_dotloop` key defaults **on**, including for configs written before this release. Set it to `false` to restore the exact per-dot path; both produce identical frames.
+- **API:** `Mapper::mix_audio` now returns `i32` (was `i16`). Out-of-tree mapper implementations need the signature updated; every in-tree board returns the values it always did.
+- **Mapper module paths:** `crate::sprintN::*` paths no longer exist. Public re-exports from `rustynes_mappers` are unchanged, so downstream code that depends on `rustynes-core`'s re-exports needs no change.
+
+## Verification
+
+```text
+cargo test --workspace --features test-roms 2238 passed, 0 failed, 20 ignored
+ AccuracyCoin 141/141 (100.00%)
+ AccuracyCoin through run-ahead (depths 1, 2) 141/141
+ nestest 0-diff
+ holy_mapperel 17/17, all detail=0000
+ pal_apu_tests 10/10
+ visual_regression 9/9
+external_real_games (--features commercial-roms) 60/60
+external_extended (--features commercial-roms) 138/138
+cargo fmt --all --check clean
+cargo clippy --workspace --all-targets -D warnings clean
+cargo clippy -p rustynes-mappers --no-default-features --all-targets -D warnings clean
+RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clean
+cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features clean
+```
+
+Version: workspace `2.2.2 → 2.2.3`. No mobile app version change — `versionCode` / `versionName` on Android and `MARKETING_VERSION` on iOS stay frozen at their v2.0.x host-only release points; the joint mobile store launch remains **v2.3.0**.
diff --git a/.github/workflows/antigravity-review.yml b/.github/workflows/antigravity-review.yml
index 22157c87..6da5f7e6 100644
--- a/.github/workflows/antigravity-review.yml
+++ b/.github/workflows/antigravity-review.yml
@@ -51,16 +51,24 @@ jobs:
# Check out the DEFAULT BRANCH, never the PR head. This job runs the checked-out
# `scripts/agy-review.sh` on a self-hosted runner with a token in the environment,
# so taking those scripts from the PR would let the reviewed change rewrite its own
- # reviewer. The review content is unaffected: the diff is fetched from the API by
- # `gh pr diff`, not from the working tree. Consequence worth knowing: a PR that
- # edits the reviewer or the style guide is reviewed by the version already on main.
+ # reviewer. The review content is unaffected: the diff comes from `gh pr diff` (or,
+ # for a diff over GitHub's 20,000-line API limit, from a local `git diff` of fetched
+ # objects) — never from the checked-out working tree, which stays on the default
+ # branch throughout. Consequence worth knowing: a PR that edits the reviewer or the
+ # style guide is reviewed by the version already on main.
- name: Check out repo (for the style guide + scripts)
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
+ # Not `fetch-depth: 0`: the large-diff fallback fetches exactly the two refs it
+ # needs on demand, so a full history clone would be paid on every run for a path
+ # most runs never take.
fetch-depth: 1
- # Repo-wide rule (PR #319): no checkout persists credentials. Nothing here
- # performs an authenticated git network operation — `gh` uses GH_TOKEN.
+ # Repo-wide rule (PR #319): no checkout persists credentials. The large-diff
+ # fallback does perform an authenticated fetch, but supplies the token per
+ # command via `http.extraheader` rather than persisting it into .git/config —
+ # which is the point of the rule, since agy executes with the repo as its
+ # workspace.
persist-credentials: false
- name: Run Antigravity review
@@ -69,10 +77,12 @@ jobs:
# --- optional overrides (uncomment to change) ---
# AGY_MODEL: gemini-3-pro # default: agy's configured model
AGY_EFFORT: high # low|medium|high
- # Diff budget. The prompt is passed to agy as a SINGLE argv string, and Linux
- # caps one argument at MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB (this is a
- # separate, much lower ceiling than ARG_MAX). 90 KB leaves room for the
- # instruction boilerplate + style guide; the script enforces the total cap.
+ # INLINE diff budget — not a cap on what gets reviewed. The prompt reaches agy
+ # as a SINGLE argv string, and Linux caps one argument at
+ # MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB (a separate, much lower ceiling than
+ # ARG_MAX). 90 KB leaves room for the instruction boilerplate + style guide.
+ # A diff over this is NOT truncated: it is written into agy's workspace in parts
+ # and read with agy's file tools, so PR size no longer bounds review coverage.
MAX_DIFF_BYTES: "90000"
# STYLE_GUIDE: .github/agy-review.md # style guide, loaded if present
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87d099a1..b6b5da2e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -209,6 +209,20 @@ jobs:
# cfg-gated-code coverage gap (#11a) without a per-feature matrix.
- name: clippy (full — every native feature)
run: cargo clippy -p rustynes-frontend --all-targets --features full -- -D warnings
+ # `rustynes-mappers` with `mapper-audio` OFF. Every combo above turns
+ # features ON; this is the only gate that compiles a feature OUT, and the
+ # subtraction is where the rot happens: this configuration was outright
+ # BROKEN — a hard `E0599`, because `Namco163Audio` was missing the
+ # feature-off `clock()` shim that the NSF expansion router calls
+ # unconditionally — and nothing noticed, because nothing built it. The
+ # `no_std` job below is `-p rustynes-core`, which keeps `mapper-audio` on.
+ #
+ # The feature's contract (see the crate manifest) is that with audio off
+ # the register decoders stay live — so save states from an audio-enabled
+ # build still load — while the oscillators freeze. That asymmetry is
+ # exactly what a compile-only gate protects.
+ - name: clippy (mappers — mapper-audio compiled OUT)
+ run: cargo clippy -p rustynes-mappers --no-default-features --all-targets -- -D warnings
# Cross-platform behaviour: full `cargo test` on stable across the three
# shipped OSes. The previous ubuntu / 1.96 MSRV *test* entry is dropped — the
@@ -418,18 +432,44 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
+ # fetch-depth: 0 — the RELATIVE gate below benches the base commit in
+ # a worktree, which needs real history. The default depth-1 checkout
+ # cannot resolve it and the gate would skip on every run.
+ fetch-depth: 0
- uses: ./.github/actions/rust-setup
with:
apt: "false"
cache-key: bench
# The `rustynes-core` full_frame bench is chip-stack-only (no wgpu/winit),
- # so no apt deps are needed. The gate asserts headless frame production
- # stays well under the 16.67 ms NTSC real-time deadline — a deliberately
- # non-flaky absolute ceiling (a tight %-regression gate would flake on
- # shared runners; that's a local criterion-baseline workflow instead).
- # See scripts/bench_regression_check.sh + docs/performance.md.
+ # so no apt deps are needed.
+ #
+ # TWO gates, deliberately different in kind:
+ #
+ # 1. ABSOLUTE ceiling — headless frame production stays well under the
+ # 16.67 ms NTSC deadline. Never flakes, but on the ~4 ms/frame the
+ # core actually runs at, a change could get 2.5x slower and still
+ # pass. It answers "is the emulator still real-time?", not "did this
+ # PR make it worse".
+ # 2. RELATIVE A/B — builds and benches the base commit and HEAD back to
+ # back on THIS runner and compares them to each other. Runner-to-
+ # runner variance is common-mode and cancels, which is why a
+ # percentage gate is sound here where a cross-run one would flake
+ # (same technique as pgo.yml's >3% promotion bar). It answers the
+ # question the ceiling cannot.
+ #
+ # See scripts/bench_{regression,relative}_check.sh + docs/performance.md.
- run: ./scripts/bench_regression_check.sh
+ # Base ref: the PR's base-branch tip, else the push's previous head.
+ # Neither is present on a workflow_dispatch, and `github.event.before` is
+ # all-zeros for a brand-new branch — in both cases the script resolves
+ # nothing and SKIPs with exit 0 rather than inventing a verdict.
+ - name: Relative frame-time A/B (base vs HEAD, same runner)
+ env:
+ BENCH_BASE_REF: >-
+ ${{ github.event.pull_request.base.sha || github.event.before }}
+ run: ./scripts/bench_relative_check.sh
+
# Single summary status — the ONE check to require in branch protection / the
# merge queue. It runs always (even when a needed job fails or is skipped) and
# fails iff any job it depends on failed or was cancelled. A *skipped* job
diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index d5a68e54..dabf6d71 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -60,7 +60,21 @@ jobs:
# toolchain with a POSIX-safe pipeline: prefer the canonical `Xcode_26.app`,
# else the lexically-highest `Xcode_26.x.app` (26.0–26.9 order correctly under
# plain `sort`). Using `sort -V` here would wrongly fall back on a real runner.
- xc=$(ls -d /Applications/Xcode_26.app /Applications/Xcode_26.*.app 2>/dev/null | sort | tail -1 || true)
+ #
+ # Shell globbing rather than `ls -d` (shellcheck SC2012). The ordering is
+ # load-bearing and unchanged: both patterns are emitted together, filtered
+ # to real directories, then `sort | tail -1` picks the last. That still
+ # prefers `Xcode_26.app` over `Xcode_26..app`, because after the common
+ # `Xcode_26.` prefix `a` (0x61) sorts above any digit — the canonical app
+ # is the lexically-highest name, not the first. A non-matching glob expands
+ # to the literal pattern, which the `-d` test discards.
+ xc=$(
+ printf '%s\n' /Applications/Xcode_26.app /Applications/Xcode_26.*.app \
+ | while IFS= read -r d; do
+ [ -d "$d" ] && printf '%s\n' "$d"
+ done \
+ | sort | tail -1
+ )
if [ -n "$xc" ]; then
sudo xcode-select -s "$xc/Contents/Developer"
echo "Selected $xc"
diff --git a/.github/workflows/pgo.yml b/.github/workflows/pgo.yml
index 807c9ad8..d9972fbd 100644
--- a/.github/workflows/pgo.yml
+++ b/.github/workflows/pgo.yml
@@ -8,8 +8,18 @@ name: PGO
# the workspace twice plus a multi-ROM training sweep — far too slow for the PR
# gate (that's the fast `bench` job in ci.yml). It runs ONLY on:
# * workflow_dispatch — manual, from the Actions tab; and
-# * push of a release tag (v*) — alongside release.yml, so a release can
-# consider shipping the PGO binary.
+# * workflow_call — invoked BY `release.yml`, which ships the promoted
+# binary as the Linux release asset (see below).
+#
+# WHY NOT `push: tags` ANY MORE (v2.2.3). It used to also trigger on a `v*` tag
+# push, "alongside release.yml, so a release can consider shipping the PGO
+# binary" — except nothing ever consumed the result, so the gate computed a
+# promotable artifact that was then thrown away. `release.yml` now calls this
+# workflow directly and uses its output. Keeping the tag trigger as well would
+# start a SECOND, redundant 90-minute run for every manually-pushed tag (the
+# auto-release path pushes tags with GITHUB_TOKEN, which does not re-trigger
+# `on: push`, so that path would have been fine — but a hand-pushed tag would
+# double up). One entry point per tag is the whole point.
#
# PROMOTION GATE (both conditions, AND):
# 1. FASTER — the PGO `full_frame` criterion mean must beat the plain-release
@@ -42,9 +52,26 @@ on:
required: false
default: false
type: boolean
- push:
- tags:
- - "v*"
+ # Invoked by `release.yml` on a version tag. The caller consumes
+ # `outputs.promotable` + `outputs.artifact` to ship the PGO binary as the
+ # Linux release asset when the gate passes.
+ workflow_call:
+ inputs:
+ frames:
+ description: "Training frames per ROM (default 3600 = 60 s NTSC each)."
+ required: false
+ default: "3600"
+ type: string
+ outputs:
+ promotable:
+ description: "'true' when the PGO build cleared BOTH the >3% speedup bar and the byte-identical determinism oracle."
+ value: ${{ jobs.pgo.outputs.promotable }}
+ speedup_pct:
+ description: "Measured full_frame speedup of PGO vs plain release, in percent."
+ value: ${{ jobs.pgo.outputs.speedup_pct }}
+ artifact:
+ description: "Name of the uploaded PGO-binary artifact (empty unless promotable)."
+ value: ${{ jobs.pgo.outputs.artifact }}
# Never cancel an in-flight PGO run; let each finish so its A/B numbers + the
# determinism proof land in the log.
@@ -72,6 +99,10 @@ jobs:
outputs:
promotable: ${{ steps.gate.outputs.promotable }}
speedup_pct: ${{ steps.gate.outputs.speedup_pct }}
+ # Empty unless promotable — the upload step below is gated on the same
+ # condition, so a caller that downloads this name only ever does so when
+ # the artifact actually exists.
+ artifact: ${{ steps.triple.outputs.artifact }}
steps:
- uses: actions/checkout@v7
with:
@@ -110,8 +141,15 @@ jobs:
# runs `pgo_trainer` over the committed corpus, then
# `cargo pgo optimize build -- -p rustynes-frontend`. The optimized
# `rustynes` binary lands at target//release/rustynes.
+ # `inputs.frames` (NOT `github.event.inputs.frames`): the latter is
+ # populated only for `workflow_dispatch` and is empty on `workflow_call`,
+ # which would silently drop the caller's value. The `inputs.` context
+ # covers both triggers. Bound through `env:` rather than interpolated
+ # into the command string.
- name: PGO instrument + train + optimized rebuild
- run: scripts/pgo/run.sh "${{ github.event.inputs.frames || '3600' }}"
+ env:
+ PGO_FRAMES: ${{ inputs.frames || '3600' }}
+ run: scripts/pgo/run.sh "$PGO_FRAMES"
# ---- Stage 3: PGO full_frame bench (A/B vs the `plain` baseline) ---
- name: PGO full_frame bench
@@ -125,8 +163,15 @@ jobs:
# the golden-framebuffer visual_regression suite + the APU mixer/volume
# audio suites all assert byte-exact values — if PGO codegen perturbed any
# framebuffer/audio/cycle hash, this fails and blocks promotion.
+ # `continue-on-error` so a determinism DIVERGENCE is a gate verdict, not a
+ # job crash: the gate step below already reads `oracle_ok` and treats an
+ # unset value as FAIL, which is what the workflow header has always
+ # claimed ("a failure of either is informational"). Without this the job
+ # died here and the gate never ran — and now that `release.yml` calls this
+ # workflow, that would redden a release whose assets are perfectly fine.
- name: Determinism oracle (test-roms, PGO-optimized codegen)
id: oracle
+ continue-on-error: true
run: |
set -o pipefail
cargo pgo optimize test -- --workspace --release --features test-roms \
@@ -167,8 +212,19 @@ jobs:
# ---- Stage 6: upload the PGO binary ONLY when promotable ----------
- name: Resolve target triple
id: triple
+ env:
+ PROMOTABLE: ${{ steps.gate.outputs.promotable }}
run: |
- echo "triple=$(rustc -vV | sed -n 's/host: //p')" >> "$GITHUB_OUTPUT"
+ triple="$(rustc -vV | sed -n 's/host: //p')"
+ echo "triple=${triple}" >> "$GITHUB_OUTPUT"
+ # Publish the artifact NAME only when the gate promoted, so the
+ # caller's `if:` and its `download-artifact` name agree by
+ # construction rather than by two places staying in sync.
+ if [ "$PROMOTABLE" = "true" ]; then
+ echo "artifact=rustynes-pgo-${triple}" >> "$GITHUB_OUTPUT"
+ else
+ echo "artifact=" >> "$GITHUB_OUTPUT"
+ fi
- name: Upload promoted PGO binary
if: steps.gate.outputs.promotable == 'true'
@@ -185,9 +241,16 @@ jobs:
bolt:
name: BOLT post-link (linux, best-effort)
needs: pgo
+ # Explicit opt-in ONLY. Previously this ran whenever the PGO stage promoted
+ # and the event was not a workflow_dispatch-without-run_bolt — which, now
+ # that `release.yml` calls this workflow, would fire BOLT on every release
+ # and add ~90 minutes for an artifact nothing consumes (the release ships
+ # the PGO binary, not the BOLT one). BOLT stays the opt-in experiment it
+ # has always been in practice.
if: >-
needs.pgo.outputs.promotable == 'true' &&
- (github.event_name != 'workflow_dispatch' || github.event.inputs.run_bolt == 'true')
+ github.event_name == 'workflow_dispatch' &&
+ inputs.run_bolt == true
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
@@ -219,7 +282,9 @@ jobs:
# pgo job are intentionally not BOLT-instrumentable post-strip).
- name: PGO instrument + train + optimized rebuild
if: steps.bolt_probe.outputs.have_bolt == 'true'
- run: scripts/pgo/run.sh "${{ github.event.inputs.frames || '3600' }}"
+ env:
+ PGO_FRAMES: ${{ inputs.frames || '3600' }}
+ run: scripts/pgo/run.sh "$PGO_FRAMES"
- name: Plain baseline bench
if: steps.bolt_probe.outputs.have_bolt == 'true'
@@ -231,9 +296,11 @@ jobs:
- name: BOLT instrument + optimize
if: steps.bolt_probe.outputs.have_bolt == 'true'
+ env:
+ PGO_FRAMES: ${{ inputs.frames || '3600' }}
run: |
cargo pgo bolt build -- -p rustynes-frontend
- scripts/pgo/run.sh "${{ github.event.inputs.frames || '3600' }}"
+ scripts/pgo/run.sh "$PGO_FRAMES"
cargo pgo bolt optimize -- -p rustynes-frontend
- name: BOLT full_frame bench + gate
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 809301dc..f81a8e0f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -156,3 +156,118 @@ jobs:
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # --------------------------------------------------------------------------
+ # PGO build + promotion gate (linux x86_64 only).
+ #
+ # Runs the full instrument -> train -> optimized-rebuild cycle and its two
+ # gates (>3% faster on `full_frame` AND byte-identical across the whole
+ # `--features test-roms` oracle). Before v2.2.3 this ran on the same tag push
+ # as the release but nothing consumed the result — a promoted binary was
+ # computed and discarded. Now `pgo-linux-asset` below ships it.
+ #
+ # FAILURE SEMANTICS (note: `continue-on-error` is NOT permitted on a
+ # reusable-workflow `uses:` job — actionlint rejects it — so the tolerance
+ # lives inside pgo.yml instead):
+ #
+ # * A GATE verdict (slower than the 3% bar, or an oracle divergence) is not
+ # a failure at all. pgo.yml's determinism step carries a step-level
+ # `continue-on-error`, so the gate simply reports promotable=false, this
+ # job stays green, and `pgo-linux-asset` below is skipped.
+ # * An INFRASTRUCTURE failure (runner died, cargo-pgo unavailable) does mark
+ # this job — and therefore the run — red. That is deliberate: a broken PGO
+ # pipeline should be visible, not swallowed. The RELEASE is still correct
+ # either way: `build` has already attached the plain assets for all three
+ # targets, and the replacement job below is skipped.
+ #
+ # Scope is deliberately linux-x86_64 ONLY — PGO training has to RUN the
+ # instrumented binary, so each additional target needs its own native runner
+ # doing a full train cycle. macOS-aarch64 and Windows keep shipping plain
+ # release builds; extending PGO to them is a separate decision with a real
+ # cost (~90 min of runner time each), not a freebie.
+ pgo:
+ name: PGO build + gate (linux x86_64)
+ uses: ./.github/workflows/pgo.yml
+ permissions:
+ contents: read
+
+ # Replace the Linux release asset with the PGO binary when — and only when —
+ # the gate promoted it.
+ #
+ # Sequencing note: `build` attaches the plain Linux archive first (it finishes
+ # in ~10 min; the PGO job takes up to 90), so for the intervening window the
+ # release carries the plain binary and is then upgraded in place. That is the
+ # deliberate trade: the release is complete and downloadable immediately
+ # rather than gated behind an hour and a half of PGO, and the swap is
+ # idempotent. The archive keeps the SAME name, so download links and
+ # checksums-by-name do not change shape.
+ pgo-linux-asset:
+ name: Ship PGO binary as the linux asset
+ needs: [build, pgo]
+ if: needs.pgo.outputs.promotable == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Resolve release tag
+ id: tag
+ env:
+ INPUT_TAG: ${{ inputs.tag }}
+ shell: bash
+ run: |
+ # Same resolution as the `build` job: `inputs.tag` on
+ # workflow_dispatch / workflow_call, else the pushed ref.
+ if [ -n "$INPUT_TAG" ]; then
+ tag="$INPUT_TAG"
+ else
+ tag="${GITHUB_REF#refs/tags/}"
+ fi
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+
+ - name: Download the promoted PGO binary
+ uses: actions/download-artifact@v7
+ with:
+ name: ${{ needs.pgo.outputs.artifact }}
+ path: pgo-bin
+
+ - name: Stage + archive (identical layout to the plain Linux asset)
+ env:
+ TAG: ${{ steps.tag.outputs.tag }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ target="x86_64-unknown-linux-gnu"
+ stage="rustynes-${TAG}-${target}"
+ mkdir -p "$stage"
+ cp pgo-bin/rustynes "$stage/rustynes"
+ chmod +x "$stage/rustynes"
+ strip "$stage/rustynes"
+ cp README.md LICENSE-MIT LICENSE-APACHE NOTICE CHANGELOG.md "$stage/"
+ tar -czf "${stage}.tar.gz" "$stage"
+ echo "asset=${stage}.tar.gz" >> "$GITHUB_ENV"
+
+ # `gh release upload --clobber` rather than softprops/action-gh-release:
+ # this is a REPLACEMENT of an asset the `build` job already attached under
+ # the same name, and the API rejects a duplicate asset name without an
+ # explicit overwrite. `--clobber` is that overwrite.
+ - name: Replace the Linux asset with the PGO build
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ steps.tag.outputs.tag }}
+ SPEEDUP: ${{ needs.pgo.outputs.speedup_pct }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh release upload "$TAG" "$asset" --clobber --repo "$GITHUB_REPOSITORY"
+ {
+ echo "### PGO binary shipped"
+ echo
+ echo "The \`x86_64-unknown-linux-gnu\` release asset is the"
+ echo "profile-guided-optimized build (**${SPEEDUP}%** faster on"
+ echo "\`full_frame\`, byte-identical across the full test-roms oracle)."
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 7d86d824..dd292533 100644
--- a/.gitignore
+++ b/.gitignore
@@ -281,3 +281,9 @@ WARP.md
# Game Genie header-robust re-key: NES 2.0 DB is a build-time input, never committed
scripts/gg/nes20db.xml
scripts/gg/__pycache__/
+
+# Antigravity PR reviewer: scratch workspace for the on-disk diff handoff. When a PR's
+# diff is too large to inline in the prompt, scripts/agy-review.sh writes it here so agy
+# can READ it with its file tools instead of the review being truncated to the argv
+# ceiling. Created and removed within a single run; never committed.
+/.agy-review-work/
diff --git a/AGENTS.md b/AGENTS.md
index dfbbcb58..9d8392a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
-
+
# AGENTS.md — RustyNES
@/home/parobek/.claude/master-core/AGENTS.base.md
@@ -14,6 +14,7 @@
@/home/parobek/.claude/master-core/modules/70-release-ceremony.md
@/home/parobek/.claude/master-core/modules/80-phase-sprint-workflow.md
@/home/parobek/.claude/master-core/modules/90-multi-language-integration.md
+@/home/parobek/.claude/master-core/modules/91-agent-system-architecture.md
@/home/parobek/.claude/master-core/modules/95-named-pattern-library.md
<<< MC-PROJECT-START >>>
@@ -26,11 +27,13 @@
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`).
-**Current release: v2.2.2 "Conduit"** (2026-07-21) — a **build, distribution, and CI-integrity patch**. It takes the **libretro buildbot recipe from 1 of 10 jobs green to all ten building** (three rounds of diagnosis against a third-party pipeline we cannot push to or re-run — the last step before RustyNES lands in RetroArch's built-in core downloader), **hardens the GitHub Actions supply chain** (`persist-credentials: false` on all 19 checkouts, a fail-closed release-tag check via `git/matching-refs`, `dtolnay/rust-toolchain` SHA-pinned off `@master`), and **collapses the toolchain to one pinned source of truth** — `.github/actions/rust-setup` resolves `[toolchain].channel` from `rust-toolchain.toml`, so there is **no toolchain version literal anywhere under `.github/`** and **no `nightly` on any build path** (it survives only in `cargo fuzz` and the dormant `rustynes-monetization` bindgen, neither a gate). **Zero emulation-core changes** — nothing under `crates/rustynes-{cpu,ppu,apu,mappers,core}` is touched, so the deterministic `#![no_std]` chip stack, save-state / TAS / netplay-replay formats, and every golden vector are untouched by construction: **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff, `pal_apu_tests` 10/10, all unchanged since v2.2.0. One behavioural improvement reaches a shipped artifact: the libretro **tvOS** core is now built with `panic = "abort"` like every other platform, rather than the `panic = "unwind"` its previous `-Zbuild-std` path forced (`aarch64-apple-tvos` is no longer tier 3 — rustup ships a prebuilt std including `panic_abort`, making the upstream template's `+nightly -Zbuild-std` override obsolete).
+**Current release: v2.2.3 "Datum"** (2026-07-23) — a **performance and accuracy-closure patch**, the product of a measure-first appraisal that profiled the emulator and acted on what the profile showed rather than on intuition. **Performance:** the specialized PPU fast dot path is promoted to the **default** and exposed to users for the first time — `Nes::set_fast_dotloop` had **no caller outside the core**, so a **−11.3%** frame-time win (fresh clean-host Criterion, reproducing v2.1.8's +12.3% by a different method; differential-tested bit-identical every frame since v2.1.8) shipped switched off and unreachable; release builds now ship **PGO-optimized** Linux binaries when the existing >3%-and-byte-identical gate passes; and CI gained a same-runner **relative** frame-time regression gate, closing a hole where a 2.5x slowdown passed the deliberately-loose absolute ceiling. **Two optimizations were measured and REJECTED** and are documented with their numbers per `docs/performance.md`'s convention — P3 (`emit_pixel` bounds-check elision) made the shipped default *slower* (+4.32% / +3.35% on the `_fast` workloads, p ≤ 0.02), and P4 (`cpu_clock`) found both textbook optimizations already implemented with the one remaining lever capped at **≤1.9%**. **Accuracy:** the **last two Holy Mapperel residuals are closed**, so all 17 ROMs report `detail=0000` (was 15/17) — MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's CHR-register layer, gated on `chr_is_ram`) and FME-7's open bus on the RAM-selected-but-disabled window, both routed through the trait's existing `cpu_read_unmapped` contract. MMC1 is the change Holy Mapperel's README calls a game-compatibility hazard (FCEUX / PowerPak omit it), so it was validated before landing: **60/60** commercial ROMs including seven battery-backed MMC1 saves, plus **138/138** extended. The **Sunsoft 5B absolute level** is calibrated against Mesen2, which required widening `Mapper::mix_audio` to `i32` (the correct full-scale 5B tone `1882 * 18.471 = 34,761` does not fit `i16`). A **save-state schema gap** is fixed — `PPU_SNAPSHOT_VERSION` **8** carrying the sprite-eval FSM + OAM data-bus state, plus an APU **v4** tail — which is what made AccuracyCoin report **141/141 through run-ahead** as well as without it; a new standing field-vs-schema audit found it and the two APU gaps mechanically. A **Zapper beam-relative light model** lands opt-in / default-off (no pass-fail light-gun ROM exists to adjudicate it). **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff. Also: the eleven `sprintN.rs` mapper modules (27,631 lines, ~110 boards) are renamed for the boards they emulate with `mNNN_` mapper-number prefixes, proven content-preserving by a byte-for-byte item comparison (930 items, 0 altered) and an identical 172-ID dispatch table.
+
+The prior release, **v2.2.2 "Conduit"** (2026-07-21), was a **build, distribution, and CI-integrity patch**: the **libretro buildbot recipe from 1 of 10 jobs green to all ten building** (the last step before RustyNES lands in RetroArch's built-in core downloader), a **GitHub Actions supply-chain hardening** pass (`persist-credentials: false` on all 19 checkouts, a fail-closed release-tag check via `git/matching-refs`, `dtolnay/rust-toolchain` SHA-pinned off `@master`), and the toolchain **collapsed to one pinned source of truth** — no toolchain version literal anywhere under `.github/` and **no `nightly` on any build path**. **Zero emulation-core changes**, so AccuracyCoin held 141/141 by construction. Its one behavioural improvement in a shipped artifact: the libretro **tvOS** core built with `panic = "abort"` like every other platform.
The prior release, **v2.2.1** (2026-07-15), was a **housekeeping patch** on top of v2.2.0 "Capstone" (next paragraph): archives two batches of dev/research tooling (the Game Genie header-robust re-key's six research/verification scripts in `scripts/gg/`, and the 2A03-revision DMA-divergence probe in `scripts/probes/`), consolidates six open Dependabot PRs with **zero source changes** (`pollster` 0.4→1.0, `wide` 0.7→1.5, `tungstenite`/`tokio-tungstenite` 0.29→0.30, `bytemuck`/`cc` patch, `actions/setup-python` v5→v6), and wires four gitignored, `RUSTYNES_FDS_BIOS`-gated smoke tests against `TakuikaNinja`'s FDS `$4023` / mirroring / audio-register / DRAM-watchdog hardware-verification probes (regression insurance for behavior RustyNES already models correctly, not a fix — the `$4030.D1` DRAM-refresh-watchdog probe tracks a known, honest residual RustyNES does not model, per `docs/accuracy-ledger.md`). **Zero accuracy, feature, or core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay-replay formats, and every golden vector are untouched; **AccuracyCoin holds 141/141 (100.00%)**, unchanged from v2.2.0.
-The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut** that **closes the v2.1.5 → v2.2.0 "deepen the existing project"** run, landing its two remaining marquees — the **netplay matchmaking / lobby** stack and the **FDS medium model** — atop a peripherals + quality/security pass, all sitting on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. Every v2.2.0 change is **additive or default-off**, so the deterministic core is untouched on the shipped default — **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and `blargg_apu_2005` / `pal_apu_tests` (10/10) / `visual_regression` / the 60-ROM commercial oracle / the `#![no_std]` chip stack are all unchanged, and save-state / TAS / netplay replay stay bit-identical. It lands (PRs #290, #291; cut #297): a **netplay lobby + matchmaking** stack over the existing room-code / TURN transport (`ListRooms`/`RoomList` browse-and-join room directory + a server-side `QuickMatch`/`Matched` quick-play path, both **signaling only** with the `room-list` frame parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` = 256); **delayed-stream spectators** (`SpectatorConfig.delay_frames`, clamped to `MAX_DELAY_FRAMES` = 512 — a pure presentation hold that cannot perturb the match); a **hardened desync + peer-liveness surface** (a graded `DesyncStatus` with a hysteresis threshold of 3 ≈ 1.5 s + a graded `PeerLink` / terminal `DisconnectReason::PeerTimeout` on multi-second 2 s / 5 s RTT timeouts — telemetry only, the rollback/determinism contract untouched); the **FDS medium model completion** (F4.3, a marquee — a per-block **CRC-16/KERMIT** re-emitted on every BIOS write via `resynth_block_crc` over a synthesized gap/mark wire image, an opt-in default-OFF continuous analog head-seek / velocity model `Fds::set_analog_head_seek` replacing the flat `HEAD_RESEEK_CYCLES`, and a BIOS-free synthetic write-verify oracle `Fds::medium_write_verify` as the CI-verifiable half — the real-BIOS write-CRC path stays gitignored/local; an additive **v4** FDS save-state tail, byte-identical with the model off); **peripherals** (a Famicom `$4016`-bit-2 **microphone** `Nes::set_microphone` — `$4016`-only, never `$4017` — and a 3×3-aperture Zapper light-timing model `ZAPPER_APERTURE_*`, both additive / default-off); and a **quality / security** pass (cargo-fuzz targets grown **3 → 8** — `ppu_reg_io` / `apu_reg_io` / `netplay_message` / `save_state` / `movie` — the `movie` target finding and fixing **two real OOM-DoS paths** in `Movie::deserialize`, byte-identical for valid input; a read-only **Tools → ROM Info** browser over `&Nes` that never mutates; and four new MkDocs handbook pages). The prior step — **v2.1.10 "Fathom" ("Loom")** (2026-07-12) — was the **creator-tools & web-parity** step (TAStudio greenzone depth + Lua API breadth + a browser RetroAchievements auth-proxy deploy stack (ADR 0015) + Vs. `DualSystem` presentation in the libretro core; the wasm desktop-style dual present remains deferred). The intervening steps: **v2.1.6 "Timbre"** (2026-07-11) expansion-audio fidelity (a decibel oracle asserting measured level ratios vs Mesen2 / hardware targets, hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical) → **v2.1.7 "Stepping"** (2026-07-12) hardware revisions & DMA frontier (opt-in `PpuRevision` / `Cpu2A03Revision` die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier proven a **documented no-op on every oracle**, ADR 0033 — honest, not faked) → **v2.1.8 "Tempo"** (2026-07-12) performance (a default-OFF specialized fast PPU dot path, `Nes::set_fast_dotloop` — ~+12.3% rendering-heavy, differential-tested bit-identical; a SIMD-validated software blitter where scalar stays default; a wasm 3.99 MiB-gzip size pass) → **v2.1.9 "Aperture"** (2026-07-12) presentation & signal (a raw NTSC composite `rustynes-ppu::raw_signal` core; a marquee CRT shader stack — CRT-Royale / CRT Guest Advanced / Sony Megatron — all naga-validated WGSL; GIF/WAV capture; a live generated-palette preview). Now on the shipped default the Fathom line keeps the deterministic core byte-identical and layers accuracy + display + quality-of-life work atop the v2.0.0 core: **v2.1.0** accuracy remediation (PPU palette backdrop-override, the mapper-tier completion to **51 Core / 95 Curated / 26 BestEffort of 172**, the MMC3 R1/R2 residual closed by-design-permanent) → **v2.1.1** the Wizards & Warriors game-DB mirroring freeze fix → **v2.1.2 "Prism"** display fidelity (Vs. `DualSystem` desktop second-screen, the NTSC composite-shader ladder, an in-core generated NTSC palette, NSF non-60 Hz + NSFe) → **v2.1.3 "Codex"** quality-of-life (an APU audio filter-model selector, Game Genie code nomination + a full / header-robust code database, a Material-for-MkDocs handbook served at `/docs/` on GitHub Pages) → **v2.1.4 "Caliper"** accuracy hardening (opt-in default-OFF OAM decay modeled on Mesen2, a CI boot-smoke sweep of all 26 BestEffort mapper families, a shared MMC3-clone A12/IRQ timing oracle) → **v2.1.5 "Vernier"** the regression-net & residual step → **v2.1.6 "Timbre"** expansion-audio fidelity → **v2.1.7 "Stepping"** hardware revisions & DMA frontier → **v2.1.8 "Tempo"** performance → **v2.1.9 "Aperture"** presentation & signal → **v2.1.10 "Loom"** creator tools & web parity → **v2.2.0 "Capstone"** the milestone cut closing the run → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** this build/distribution/CI-integrity patch (this release). v2.0.0 "Timebase" remains RustyNES's designated MAJOR-boundary release — see "Timebase (v2.0.0)" below. RustyNES is now a multi-platform emulation suite, all on the one byte-identical cycle-accurate core — **`docs/STATUS.md` is the authoritative current-state record.** What ships beyond the desktop app:
+The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut** that **closes the v2.1.5 → v2.2.0 "deepen the existing project"** run, landing its two remaining marquees — the **netplay matchmaking / lobby** stack and the **FDS medium model** — atop a peripherals + quality/security pass, all sitting on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. Every v2.2.0 change is **additive or default-off**, so the deterministic core is untouched on the shipped default — **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and `blargg_apu_2005` / `pal_apu_tests` (10/10) / `visual_regression` / the 60-ROM commercial oracle / the `#![no_std]` chip stack are all unchanged, and save-state / TAS / netplay replay stay bit-identical. It lands (PRs #290, #291; cut #297): a **netplay lobby + matchmaking** stack over the existing room-code / TURN transport (`ListRooms`/`RoomList` browse-and-join room directory + a server-side `QuickMatch`/`Matched` quick-play path, both **signaling only** with the `room-list` frame parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` = 256); **delayed-stream spectators** (`SpectatorConfig.delay_frames`, clamped to `MAX_DELAY_FRAMES` = 512 — a pure presentation hold that cannot perturb the match); a **hardened desync + peer-liveness surface** (a graded `DesyncStatus` with a hysteresis threshold of 3 ≈ 1.5 s + a graded `PeerLink` / terminal `DisconnectReason::PeerTimeout` on multi-second 2 s / 5 s RTT timeouts — telemetry only, the rollback/determinism contract untouched); the **FDS medium model completion** (F4.3, a marquee — a per-block **CRC-16/KERMIT** re-emitted on every BIOS write via `resynth_block_crc` over a synthesized gap/mark wire image, an opt-in default-OFF continuous analog head-seek / velocity model `Fds::set_analog_head_seek` replacing the flat `HEAD_RESEEK_CYCLES`, and a BIOS-free synthetic write-verify oracle `Fds::medium_write_verify` as the CI-verifiable half — the real-BIOS write-CRC path stays gitignored/local; an additive **v4** FDS save-state tail, byte-identical with the model off); **peripherals** (a Famicom `$4016`-bit-2 **microphone** `Nes::set_microphone` — `$4016`-only, never `$4017` — and a 3×3-aperture Zapper light-timing model `ZAPPER_APERTURE_*`, both additive / default-off); and a **quality / security** pass (cargo-fuzz targets grown **3 → 8** — `ppu_reg_io` / `apu_reg_io` / `netplay_message` / `save_state` / `movie` — the `movie` target finding and fixing **two real OOM-DoS paths** in `Movie::deserialize`, byte-identical for valid input; a read-only **Tools → ROM Info** browser over `&Nes` that never mutates; and four new MkDocs handbook pages). The prior step — **v2.1.10 "Fathom" ("Loom")** (2026-07-12) — was the **creator-tools & web-parity** step (TAStudio greenzone depth + Lua API breadth + a browser RetroAchievements auth-proxy deploy stack (ADR 0015) + Vs. `DualSystem` presentation in the libretro core; the wasm desktop-style dual present remains deferred). The intervening steps: **v2.1.6 "Timbre"** (2026-07-11) expansion-audio fidelity (a decibel oracle asserting measured level ratios vs Mesen2 / hardware targets, hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical) → **v2.1.7 "Stepping"** (2026-07-12) hardware revisions & DMA frontier (opt-in `PpuRevision` / `Cpu2A03Revision` die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier proven a **documented no-op on every oracle**, ADR 0033 — honest, not faked) → **v2.1.8 "Tempo"** (2026-07-12) performance (a default-OFF specialized fast PPU dot path, `Nes::set_fast_dotloop` — ~+12.3% rendering-heavy, differential-tested bit-identical; a SIMD-validated software blitter where scalar stays default; a wasm 3.99 MiB-gzip size pass) → **v2.1.9 "Aperture"** (2026-07-12) presentation & signal (a raw NTSC composite `rustynes-ppu::raw_signal` core; a marquee CRT shader stack — CRT-Royale / CRT Guest Advanced / Sony Megatron — all naga-validated WGSL; GIF/WAV capture; a live generated-palette preview). Now on the shipped default the Fathom line keeps the deterministic core byte-identical and layers accuracy + display + quality-of-life work atop the v2.0.0 core: **v2.1.0** accuracy remediation (PPU palette backdrop-override, the mapper-tier completion to **51 Core / 95 Curated / 26 BestEffort of 172**, the MMC3 R1/R2 residual closed by-design-permanent) → **v2.1.1** the Wizards & Warriors game-DB mirroring freeze fix → **v2.1.2 "Prism"** display fidelity (Vs. `DualSystem` desktop second-screen, the NTSC composite-shader ladder, an in-core generated NTSC palette, NSF non-60 Hz + NSFe) → **v2.1.3 "Codex"** quality-of-life (an APU audio filter-model selector, Game Genie code nomination + a full / header-robust code database, a Material-for-MkDocs handbook served at `/docs/` on GitHub Pages) → **v2.1.4 "Caliper"** accuracy hardening (opt-in default-OFF OAM decay modeled on Mesen2, a CI boot-smoke sweep of all 26 BestEffort mapper families, a shared MMC3-clone A12/IRQ timing oracle) → **v2.1.5 "Vernier"** the regression-net & residual step → **v2.1.6 "Timbre"** expansion-audio fidelity → **v2.1.7 "Stepping"** hardware revisions & DMA frontier → **v2.1.8 "Tempo"** performance → **v2.1.9 "Aperture"** presentation & signal → **v2.1.10 "Loom"** creator tools & web parity → **v2.2.0 "Capstone"** the milestone cut closing the run → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch (the prior release). v2.0.0 "Timebase" remains RustyNES's designated MAJOR-boundary release — see "Timebase (v2.0.0)" below. RustyNES is now a multi-platform emulation suite, all on the one byte-identical cycle-accurate core — **`docs/STATUS.md` is the authoritative current-state record.** What ships beyond the desktop app:
- **Timebase (v2.0.0)** — the scheduler substrate is rewritten from a five-counter dot-lockstep model to a single canonical cycle counter, every CPU cycle a real bus access, and a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up (ADR 0002 / ADR 0029), now the *only* scheduler path. This is a MAJOR-boundary breaking change (ADR 0003): `.rns` save-state and `.rnm` movie format epochs bump (ADR 0028) — a pre-v2.0.0 `.rns` slot now fails to load with a clear error instead of silently misinterpreting stale bytes. Landed across five betas + rc.1 (PRs #217–223). Also new: core-level **Vs. `DualSystem`** dual-console support (`Emu::Dual`, `crates/rustynes-core`) for the four Vs. arcade cabinet boards — core-and-test-harness-only, frontend wiring deferred. The R1/R2 MMC3 IRQ-timing residual is by-design-deferred beyond this release with a mechanism-level finding recorded in ADR 0002 (not closed, not silently dropped). **AccuracyCoin now measures 141/141 (100.00%)**: the v2.0.1 upstream AccuracyCoin re-sync grew the catalog to 146 rows / 141 assigned tests and briefly opened two new PPU gaps ("ALE + Read" $0491, "Hybrid Addresses" $0492), which **v2.0.3** closed by promoting the 2-cycle-ALE PPU fetch model to the unconditional default (both experimental flags retired; additive `PPU_SNAPSHOT_VERSION` v5 tail). AccuracyCoin held 100% (139/139) throughout the v2.0.0 betas and final cut, dipped to 139/141 under the v2.0.1 re-sync, and is back to a full 141/141 from v2.0.3 onward.
@@ -40,7 +43,7 @@ The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut**
- **Mapper breadth → 172 families** (up from 168 at the v1.7.x tag), Core / Curated / BestEffort behind the CI accuracy-honesty gate.
- **Release automation** — `.github/workflows/release-auto.yml`: when a new version goes final-green on `main`, it auto-tags + publishes the GitHub Release (body from a maintainer-authored `.github/release-notes/vX.Y.Z.md` override, else the CHANGELOG `[X.Y.Z]` section; title codename parsed from the CHANGELOG header) and builds + attaches the desktop binaries by invoking `release.yml` via `workflow_call` (a tag pushed by `GITHUB_TOKEN` can't trigger `on: push: tags`, hence the direct call). The v1.8.0–v1.9.9 GitHub Releases are all published with comprehensive notes + Linux / macOS-aarch64 / Windows binaries.
-Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch and **v2.2.2 "Conduit"**, the current release; see the "Current release" paragraph above. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — with it the `rustynes-monetization` activation — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets).
+Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch, **v2.2.2 "Conduit"**, and **v2.2.3 "Datum"**, the current release; see the "Current release" paragraph above. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — with it the `rustynes-monetization` activation — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets).
---
@@ -182,7 +185,7 @@ These cross-cutting decisions span multiple files. Reading individual chip docs
- `ref-docs/` is immutable. Research updates go in dated supplemental files.
- ADRs go in `docs/adr/` (Michael Nygard format).
- `rustynes-core` re-exports the public types from the chip crates; downstream consumers (`rustynes-frontend`, `rustynes-test-harness`) should depend on `rustynes-core` rather than the chip crates directly.
-- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.2.2 "Conduit"** (2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes, AccuracyCoin 141/141 — on top of **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** this build/distribution/CI-integrity patch (this release) — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.2 is released** — in particular the joint mobile app-store launch (Google Play + Apple App Store + AltStore PAL + F-Droid) is the future **v2.3.0** (NOT v2.1.0 or v2.2.0 — the entire v2.1.x line and the v2.2.0 "Capstone" milestone have all already shipped, closing the "deepen the existing project" run; the store launch moved out to v2.3.0 — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
+- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch (the prior release) — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.3 is released** — in particular the joint mobile app-store launch (Google Play + Apple App Store + AltStore PAL + F-Droid) is the future **v2.3.0** (NOT v2.1.0 or v2.2.0 — the entire v2.1.x line and the v2.2.0 "Capstone" milestone have all already shipped, closing the "deepen the existing project" run; the store launch moved out to v2.3.0 — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
- **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive.
- The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`.
- **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.39.0`). The local `markdownlint` binary is a newer version that reports rules v0.39.0 lacks (e.g. MD060) — those are NOT gated; verify with `pre-commit run markdownlint --all-files`, not the bare binary. `.markdownlint.json` keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/`
`, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/`, the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 180a9a76..b4b258a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,388 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [2.2.3] - 2026-07-23 - "Datum" (fast dot path promoted + PGO shipped + the last two mapper residuals closed)
+
+### Performance
+
+- **The specialized PPU fast dot path is now the default (`~11%` faster on
+ rendering-heavy content).** `Ppu::tick` is the emulator's hottest function
+ (32.8% of frame self-time in a fresh profile of the 7-ROM training corpus);
+ v2.1.8 added `tick_visible_render_fast`, a straight-line handler for the
+ common undisturbed visible background dot, and shipped it **off** as that
+ roadmap's highest-risk item, pending "maintainer review and a clean-host
+ Criterion confirmation". Both conditions are now met, so it defaults on.
+
+ Clean-host `full_frame`: `nes_run_frame_nestest` **4.4343 ms → 3.9331 ms
+ (−11.3%)**; the rendering-disabled `flowing_palette` workload is unchanged
+ (−0.07%, noise — its guard bails at `rendering_enabled()`). This reproduces
+ v2.1.8's interleaved +12.3% measurement by a different method.
+
+ **Byte-identical, and not newly so:** `fast_dotloop_diff.rs` has compared both
+ paths' framebuffer + palette-index framebuffer + audio + CPU-cycle count +
+ full core snapshot *every frame* since v2.1.8. Re-verified with the new
+ default across the whole `--features test-roms` suite. *At the promotion
+ commit* that read **2219 passed / 0 failed** — the pre-promotion 2218 plus
+ exactly the one config test added alongside, with no test changing its
+ verdict, which is the number that matters for this item. The suite total then
+ grew with the later work in this release; the figure for the release as a
+ whole is **2238 passed / 0 failed / 20 ignored** (see the Verification block
+ and `.github/release-notes/v2.2.3.md`, which quote that same total).
+ AccuracyCoin 141/141, nestest 0-diff, `visual_regression` and the APU oracles
+ unmoved.
+
+ Until now the win was **unreachable in practice**: `Nes::set_fast_dotloop` had
+ no callers outside the core and its tests, so no shipped frontend
+ configuration could turn it on.
+
+### Fixed
+
+- **The last two Holy Mapperel mapper residuals are closed — all 17 ROMs now
+ report `detail=0000`** (was 15/17). Both were single missing register states,
+ not bank-reachability defects.
+
+ **MMC1 software WRAM write-protect.** MMC1 has *two* PRG-RAM write-protect
+ layers and RustyNES modelled neither, reading and writing `$6000-$7FFF`
+ unconditionally: the `$E000` bit-4 disable common to every board, and SNROM's
+ second layer, where on a CHR-**RAM** board the CHR bank register's bit 4 is
+ wired to the RAM's other enable. That is exactly what Holy Mapperel measured —
+ `1000` on SJROM (one layer) versus `5000` on SNROM (both). The SNROM layer is
+ gated on `chr_is_ram`, since on a CHR-ROM board those bits are real CHR
+ banking; getting that wrong would break every SJROM/SUROM title, so it carries
+ its own negative-control test.
+
+ Holy Mapperel's README calls this a game-compatibility hazard and notes FCEUX
+ and PowerPak decline to model it, so it was validated before landing rather
+ than assumed: the commercial-ROM oracle passes **60/60** — including seven
+ battery-backed MMC1 saves (Zelda, Metroid, Final Fantasy, Mega Man 2,
+ Castlevania II, Ninja Gaiden, Kid Icarus), precisely the titles that corrupt if
+ the RAM enable is wrong — and the extended corpus **138/138**. No regression,
+ so it ships on by default.
+
+ **FME-7 open bus on the RAM-selected-but-disabled window.** Command `$8`
+ bit 6 = 1 with bit 7 = 0 drives neither the RAM nor the ROM chip, so the
+ databus floats; RustyNES fell through to the PRG-ROM bank and returned its tag
+ byte. Both fixes route through `Mapper::cpu_read_unmapped`, the trait's
+ existing "not wired to mapper-resident memory" contract, so the bus preserves
+ the open-bus latch instead of clobbering it.
+
+ Each was negative-controlled by decoding the ROM's on-screen result to ASCII
+ and confirming that reverting flips the digit back — the harness's `detail=`
+ string is a hand-maintained classification, not a measurement.
+
+- **Seven commercial-oracle audio rows had gone silently stale; re-blessed with
+ their provenance recorded.** They failed on `audio_fnv1a64` alone — frames,
+ cycles, sample counts and every framebuffer checkpoint byte-identical. The
+ MMC5 (×3) and VRC6 (×3) rows had been stale since **v2.1.6**, when
+ `VRC6_MIX_SCALE` and all three MMC5 level constants were recalibrated: those
+ snapshots were last blessed 2026-06-13, 28 days earlier. The FME-7 row moved
+ with this line's own 5B level calibration.
+
+ Root cause was structural — the suite needs `--features commercial-roms` **and**
+ local gitignored ROM dumps, so neither CI nor the default gate can run it, and
+ a golden vector nothing executes only accumulates drift. A new
+ `expansion_level_tripwire` unit test (which CI *does* run) pins every
+ expansion-audio level constant and fails with instructions naming both suites
+ that must be re-blessed in the same change.
+
+- Sunsoft 5B audio register file (`$07` mixer, `$08-$0A` volumes, envelope
+ period/shape/output, live mix value) is now surfaced in the FME-7 mapper
+ debug window (`Nes::mapper_info()`). Added while diagnosing the snapshot-window
+ gap below — the 5B was the only part of that board with no debug view, and its
+ mixer/volume bytes are exactly what answer "why is this cart silent?".
+
+- **Sunsoft 5B expansion audio was ~23 dB too quiet; now calibrated.** The 5B's
+ logarithmic DAC *shape* has been hardware-exact since v2.1.6, but its
+ *absolute level* was a documented gap — for one reason only: `Mapper::mix_audio`
+ returned `i16`, and the correct full-scale tone is `1882 × 18.471 = 34,761`,
+ past `i16::MAX` for a **single** channel (three simultaneous tones, as in
+ Gimmick! / Hebereke, reach ~104 k — 3.2× over). The blocker was the type, not
+ the value.
+
+ The trait return is widened to **`i32`** and the level calibrated by
+ `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`. `db_5b` measured
+ **0.0685×** before and **1.2651×** after, against a target derived from Mesen2
+ rather than from our own prior numbers: `LUT[12]=63 × mixer weight 15 / 746.9
+ = 1.265` (full scale `3.554`), independently reproducing the figures the
+ accuracy ledger recorded when the work was deferred. Now asserted by a new
+ `level_db_5b` oracle, so shape and level are each pinned by their own test.
+
+ Every other board returns exactly the values it always did — the widening is
+ representational. AccuracyCoin 141/141, the other 24 audio-expansion tests,
+ and the APU oracles are unchanged.
+
+ One consequence had to be chased down rather than left: `NsfExpansion::mix`
+ summed the chips into an `i16` **with a clamp**, which was harmless while
+ every chip fitted — but a calibrated 5B reaches ~104 k at full scale, so an
+ NSF 5B tune would have *clipped* where the identical cartridge 5B path does
+ not. Since the entire point of `nsf_expansion` is that an NSF tune sounds
+ bit-for-bit like the cartridge, that function is now `i32` and unclamped.
+
+- **The expansion-audio snapshot layer was blind to expansion audio.** Snapshots
+ captured 120 frames, but these ROMs hold a 2A03 reference tone first and do
+ not switch the expansion chip in until ~frame 560 — so the "load-bearing audio
+ sentinel" hashed boot and the reference section and never observed the chip
+ under test. Found by accident: the 5B level change above is **18.5×** and all
+ six 5B snapshots stayed byte-identical.
+
+ The capture window now spans the expansion segment (660 frames, reusing
+ `DB_FRAMES`), and all 19 snapshots are re-blessed accordingly — the hash
+ changes are the window extension plus, for the 5B ROMs, the level fix.
+ Verified by perturbation rather than assumed: a **one-unit** scale change
+ (0.04%) now fails **all six** 5B snapshots, where an 18.5× change previously
+ failed none.
+
+ Reaching all six needed per-ROM windows, not just a bigger shared one.
+ Instrumenting the 5B register file (new `5b_*` rows in the FME-7 debug
+ window, `Nes::mapper_info()`) measured each ROM's first non-zero 5B output:
+ `db_5b` ~540, `envelope_5b` ~420, but **`noise_5b` ~900** and **`sweep_5b`
+ ~4740** (~79 s). Neither late ROM is broken and neither awaits input —
+ `noise_5b` enables noise on channel A about 15 s in, and `sweep_5b` runs a
+ slow volume sweep holding mixer `$3F` (the "both bits set ⇒ constant output
+ at volume" case). They simply outlast the shared window, so they now get
+ `NOISE_5B_FRAMES` / `SWEEP_5B_FRAMES` of their own.
+
+- **Run-ahead cost three AccuracyCoin tests.** The PPU save-state carried
+ `secondary_oam` but not the sprite-evaluation FSM that fills it — the
+ `sprite_eval_*` pointers and phase flags, the parallel OAM-data-bus model
+ (`oam_bus_*`), and the clear-window write pointer `oam2_addr`. The frontend's
+ run-ahead (`[input] run_ahead`, **default 1**) snapshots and restores the core
+ once per visible frame, so every frame restored a full secondary-OAM buffer
+ next to a reset walker. The battery measured 141/141 headless but **138/141**
+ through the desktop app, failing `Sprite Evaluation :: Arbitrary Sprite zero`
+ (error 2), `Sprite Evaluation :: Misaligned OAM behavior` (error 1), and
+ `PPU Behavior :: Rendering Flag Behavior` (error 2). Serializing that state in
+ a new `PPU_SNAPSHOT_VERSION` **v8** tail (50 bytes) restores **141/141 with
+ run-ahead on**, at depth 1 and 2. Same bug class as the v6 tail (Wizards &
+ Warriors), a different uncovered field set; Mesen2 serializes the equivalent
+ fields. Netplay rollback and TAS seeking take the same round trip and get the
+ same fix. New regression net:
+ `crates/rustynes-test-harness/tests/accuracycoin_runahead.rs` reruns the whole
+ battery through the run-ahead cycle and names any test it costs.
+
+### Changed
+
+- **Mapper modules are named for the board they emulate, not the sprint that
+ added them.** Eleven `sprintN.rs` files (27,631 lines, ~110 boards) named
+ after a point in the development calendar are replaced by board-named
+ modules, and every single-mapper file now carries its iNES mapper number as
+ an `mNNN_` prefix so the directory sorts by mapper: `m000_nrom.rs`,
+ `m004_mmc3.rs`, `m009_mmc2.rs`, `m069_sunsoft_fme7.rs`, `m085_vrc7.rs`, and
+ so on. Files that implement **one** shared core spanning many mapper IDs keep
+ a plain descriptive name, because no single number describes them —
+ `mmc3_clones.rs` (11 IDs), `multicart_discrete.rs` (27), `bmc_simple.rs` (7),
+ `kaiser.rs` (6), `sachen_8259.rs`, `ntdec.rs`, `waixing.rs`,
+ `sachen_discrete.rs`, `homebrew_boards.rs`, `jaleco_discrete.rs`.
+
+ Boards that were merely *adjacent* are now separate files even where a doc
+ argument could be made for pairing them: MMC2 and MMC4 share the tile-fetch
+ CHR-latch concept but not a line of code, so they are `m009_mmc2.rs` and
+ `m010_mmc4.rs`, consistent with the pre-existing `m001_mmc1.rs` /
+ `m004_mmc3.rs` / `m005_mmc5.rs`. Likewise VRC2 and VRC4, which share only the
+ small `vrc_a_bits` pin-rewiring helper — now duplicated per file, exactly as
+ the crate already duplicates `nametable_offset` across ~40 modules.
+
+ Every new module gains a hand-written `//!` preamble explaining what the
+ board *is* and why it is shaped that way — MMC2's mid-scanline CHR swap and
+ why Punch-Out!! needs it; why three mapper numbers describe one VRC4;
+ Bandai Oeka Kids latching CHR bits off the *PPU* address bus; why the FDS
+ conversion boards carry a free-running IRQ counter.
+
+ **This moves code; it does not change it.** Verified mechanically rather than
+ asserted: all **499** top-level items from the eleven sprint files and all
+ **431** from the pre-existing mapper files compare **byte-identical** in code
+ (comments excluded, since module docs were deliberately rewritten), with zero
+ missing and zero altered; the `parse()` dispatch table still resolves the
+ same **172** mapper IDs to the same constructors, an identical set. Test
+ count moves 696 → 701 only because five tests that each exercised two-to-four
+ different boards were split into per-board tests, so a failure now names the
+ board.
+
+ Also renamed for the same reason: `tests/roms/sprint-2/` →
+ `tests/roms/assorted/` (a mixed blargg/kevtris corpus, not a sprint), and
+ `m78.rs` → `m078_irem_jaleco78.rs` (every peer uses a vendor name).
+
+- **`PPU_SNAPSHOT_VERSION` 7 → 8 — this breaks existing `.rns` save states.**
+ The `.rns` container is version-exact per section, so a pre-v8 save now fails
+ to load with a clear `VersionMismatch` instead of silently misreading (ADR
+ 0028). Accepted deliberately: the alternative is loading states that restore
+ a broken sprite-evaluation FSM. Movies (`.rnm`) and netplay are unaffected —
+ both re-derive state from a fresh power-on. `Ppu::restore` still upconverts
+ v1..=7 blobs for direct callers.
+- The scanline-classification cache (`cached_visible` / `cached_pre_render` /
+ `cached_render_line`, keyed by `flags_cached_scanline`) is now invalidated on
+ every restore rather than left warm. It is derived from `scanline` + `region`,
+ both serialized, so this adds no bytes; it stops a cache filled under one
+ timeline from satisfying the fast dot path's staleness guard under another.
+
+- **Release builds now ship the PGO binary on `x86_64-unknown-linux-gnu`.** The
+ `PGO` workflow has computed a profile-guided-optimized build behind a
+ >3%-faster **and** byte-identical gate since v1.2.0 — but nothing consumed the
+ result: it ran on the release tag, promoted an artifact, and `release.yml`
+ attached the plain build regardless, so the measured win never reached a
+ single user. `release.yml` now *calls* the PGO workflow and replaces the Linux
+ asset with the promoted binary under the same asset name.
+
+ A PGO gate verdict — slower than the 3% bar, or an oracle divergence — never
+ blocks or reddens a release: the determinism step is now step-level
+ `continue-on-error`, so the gate reports `promotable=false`, and the
+ replacement job is skipped, leaving the plain asset the build matrix already
+ attached. (A PGO *infrastructure* failure does still mark the run red, which
+ is intended — a broken PGO pipeline should be visible. The release assets are
+ correct regardless.) `continue-on-error` cannot be used on the caller job
+ itself: GitHub disallows it on a reusable-workflow `uses:` job, which
+ `actionlint` catches. Because the plain archive lands in ~10 minutes and PGO
+ takes up to 90, the release is complete and downloadable immediately and is
+ then upgraded in place — deliberately preferred over withholding the whole
+ release for an hour and a half.
+
+ Scope is **linux-x86_64 only**: PGO training has to *run* the instrumented
+ binary, so each further target needs its own native runner doing a full
+ ~90-minute train cycle. macOS-aarch64 and Windows keep shipping plain
+ release builds.
+
+ Two latent bugs were fixed in passing, both of which this wiring would have
+ tripped over: the PGO workflow read `github.event.inputs.frames`, which is
+ empty on `workflow_call` and would have silently dropped the caller's value
+ (now `inputs.frames`); and the BOLT job's condition admitted any non-dispatch
+ event, so it would have fired on every release, adding ~90 minutes for an
+ artifact nothing consumes (now explicit `workflow_dispatch` + `run_bolt`
+ only). The workflow's own `push: tags` trigger was removed so a hand-pushed
+ tag no longer starts two 90-minute PGO runs.
+
+- **CI gained a relative frame-time regression gate**
+ (`scripts/bench_relative_check.sh`), alongside — not replacing — the existing
+ absolute ceiling. It builds and benches the base commit and HEAD **back to
+ back on the same runner** and fails if HEAD is more than 10% slower
+ (`BENCH_MAX_REGRESSION_PCT`).
+
+ The ceiling answers "is the emulator still real-time?", not "did this change
+ make it worse": at the ~4 ms/frame the core actually runs at, a change could
+ get **2.5x slower and still pass**. v1.6.0 judged a percentage gate too flaky
+ for shared runners, and that was correct for *cross-run* comparison — but a
+ same-runner back-to-back A/B makes runner variance common-mode, which is the
+ technique `pgo.yml` has used for its >3% bar since v1.2.0 and which measured a
+ ±0.7% noise floor during this pass. The 10% default sits far above that on
+ purpose: this gate is for gross regressions, not 2% micro-optimizations.
+
+ The base commit is benched in a throwaway **git worktree**, never via
+ `git checkout`, so the gate cannot disturb the tree it runs in; and it **skips
+ with exit 0** rather than inventing a verdict when no base is resolvable
+ (shallow clone, root commit, new branch, `workflow_dispatch`). The `bench` job
+ now checks out with `fetch-depth: 0` so the normal case does not skip.
+
+- **`rustynes-mappers` with `mapper-audio` compiled OUT was broken, and is now
+ gated in CI.** `Namco163Audio` was missing the feature-off `clock()` shim that
+ the NSF expansion router calls unconditionally, so the whole configuration
+ failed to build with a hard `E0599`. Nothing noticed because nothing built it:
+ every other feature gate turns features *on*, and the `no_std` job is
+ `-p rustynes-core`, which keeps `mapper-audio` on.
+
+ The shim is added (matching the pattern FDS and Sunsoft 5B already had), and
+ `cargo clippy -p rustynes-mappers --no-default-features --all-targets -D
+ warnings` joins the `lint` job so the subtraction case cannot rot again.
+
+ Two follow-ons the newly-compiling config exposed, neither of which is
+ unfinished work — the default build is dead-code-warning clean and every item
+ has real call sites (`effective_period_p/_s` drive VRC6 period computation,
+ `half_period` reloads the 5B tone/noise counters). They are audio-support
+ items that are simply unreachable once the subsystem is compiled out, so they
+ carry `#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]` rather
+ than `#[cfg]` — they still compile, so any future non-audio caller keeps
+ working. The feature-off shims themselves also needed narrow
+ `clippy::unused_self` / `needless_pass_by_ref_mut` allows: a shim must keep
+ the gated signature so the unconditional caller compiles, which is exactly
+ what those lints object to.
+
+- **`actionlint` is now clean across every workflow** (it was not before, which
+ is how the invalid `continue-on-error` above was caught). Two pre-existing
+ findings fixed:
+ - `ios.yml` used `ls -d … | sort | tail -1` to pick an Xcode 26 toolchain
+ (shellcheck SC2012). Replaced with shell globbing, preserving the ordering
+ exactly — including the non-obvious part, that `Xcode_26.app` outranks
+ `Xcode_26..app` because `a` sorts above any digit after the shared
+ prefix. Verified equivalent against six synthetic runner layouts (canonical
+ only, canonical + point releases, point releases only, none, unrelated
+ versions, and the documented 26.9/26.10 lexical bound).
+ - The `agy` self-hosted runner label in `antigravity-review.yml` was reported
+ as unknown. Declared in a new `.github/actionlint.yaml`, which is the
+ mechanism actionlint's own diagnostic points at; the alternative (a hosted
+ label) is not available, since that runner holds the `agy` CLI's OAuth
+ session.
+
### Added
+- **Zapper beam-relative light model (A3), default off.** The photodiode's
+ ~19-26-scanline hold is now modelled: `Nes::set_zapper_temporal_light` makes
+ the light bit a function of where the CRT beam is at the moment of the
+ `$4016`/`$4017` read — dark before the beam paints the aim row, lit for the
+ hold, dark once drained. The frame-granular model structurally cannot express
+ this; it returns one answer for the entire frame.
+
+ It holds **no extra state** — light is derived on demand from
+ `(framebuffer, aim, scanline)` — so it adds nothing to serialize and cannot
+ desync a save state or a netplay rollback, and both models share one aperture
+ test so they differ only in *when* they sample. It stays opt-in because no
+ redistributable pass/fail light-gun ROM exists to adjudicate it, and the
+ supported titles re-poll every frame and are satisfied by either model:
+ promoting it would change output with no oracle able to confirm the change is
+ an improvement.
+
+- **`ppu-idle-line-fast` cargo feature (default OFF)** — a second PPU dot-path
+ specialization covering *idle lines* (post-render 240 + vblank 242..=260;
+ 6,820 of the 89,342 NTSC dots), where the per-dot body provably reduces to
+ three assignments. It is byte-identical — proven by `fast_dotloop_diff`,
+ extended with `idle_line_fast_path_matches_exact_under_vblank_io`, a
+ purpose-built NROM that hammers `$2000`/`$2001`/`$2006`/`$2007` throughout
+ vblank so the guard's fall-through arms are exercised rather than assumed.
+ It ships **off** because it does not clear the project's >3% adoption bar:
+ a same-session A/B (±0.7% noise floor) measures −1.3%/−1.5% on
+ rendering-*disabled* content but +0.2%/+0.4% on the rendering-heavy case that
+ dominates real play. Kept behind a compile-time flag rather than deleted —
+ the code is proven and becomes worthwhile if per-dot dispatch gets cheaper;
+ compile-time rather than runtime because the cost *is* the per-dot guard.
+ Full measurement, and the contaminated first A/B that nearly got it deleted,
+ in `docs/performance.md` §P2.
+
+- Desktop setting `[emulation] fast_dotloop` (Settings → Accuracy, labelled
+ "performance, not accuracy") as an escape hatch for the fast-dot-path
+ promotion under **Performance** above — there is no accuracy reason to
+ disable it. Defaulted through `default_fast_dotloop()`
+ rather than `#[serde(default)]` so an existing on-disk config loads as `true`
+ instead of silently opting the user out of an ~11% speedup; pinned by
+ `emulation_fast_dotloop_defaults_on_for_pre_v2_2_3_configs`. The libretro core
+ and the mobile bridge inherit the win from the core default and deliberately
+ gain no new option — neither exposes a comparable knob today.
+
+- **Save-state schema audit as a standing test**
+ (`crates/rustynes-test-harness/tests/snapshot_schema_audit.rs`). Every field
+ of `Ppu` / `Cpu` / `Apu` must be touched by that chip's `snapshot` writer or
+ appear on an exclusion list with a written reason. The same bug has now
+ shipped three times — a live mid-frame field added to a chip struct without
+ the serializer (the v5 ALE fetch state, the v6 sprite-shifter/OAM-corruption
+ state, the v8 sprite-evaluation FSM) — because no straight-`run_frame` test
+ can see an incomplete schema; only run-ahead, netplay rollback and TAS
+ seeking round-trip mid-frame. This audit is a text diff over `include_str!`'d
+ sources: no ROM, no emulation, runs in the default `cargo test` job, and
+ would have caught all three at the commit that introduced them. It scopes the
+ search to the writer body (a whole-file search is satisfied by `restore`'s
+ own upconvert defaults, i.e. by the bug itself) and matches on word
+ boundaries; both properties are pinned by their own tests, and the writer
+ scoping was confirmed by negative control.
+- **`APU_SNAPSHOT_VERSION` 3 → 4** — serializes the scheduled warm-reset
+ `$4017` re-write (`reset_4017_delay` + `reset_4017_value`, 2 bytes), the
+ first gap the schema audit above found on its own rather than after a bug
+ report. `Apu::reset` arms the countdown at 2 and `tick_with_external`
+ decrements it once per CPU cycle, issuing the frame-counter write on zero; a
+ snapshot taken inside that 2-cycle window used to restore `delay = 0` and
+ cancel the re-write, leaving the restored sequencer in the phase the
+ re-write exists to reset. Narrow window and no symptom was ever attributed
+ to it, but it is the same class as the PPU's v5/v6/v8 tails. Pinned
+ behaviourally, not just as a field round trip, by
+ `a_reset_survives_a_snapshot_restore_taken_mid_countdown`. v1..=3 blobs
+ upconvert to "no re-write pending". No additional compatibility cost: the
+ `.rns` container is version-exact per section and the PPU v8 tail in this
+ same change already rejects pre-existing save states.
- Antigravity PR reviewer (`.github/workflows/antigravity-review.yml` +
`scripts/agy-review.sh`): an automated first-pass code review on a self-hosted
runner, driven by the `agy` CLI's OAuth session (Google AI Ultra, no metered
diff --git a/Cargo.lock b/Cargo.lock
index fb2d575e..5b00f659 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"android-activity",
"android_logger",
@@ -4308,7 +4308,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"cc",
"ureq",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"anstyle",
"arboard",
@@ -4411,11 +4411,11 @@ dependencies = [
[[package]]
name = "rustynes-gfx-shaders"
-version = "2.2.2"
+version = "2.2.3"
[[package]]
name = "rustynes-hdpack"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"lewton",
"png",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bytemuck",
"cpal",
@@ -4440,7 +4440,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"libc",
"rust-libretro",
@@ -4449,7 +4449,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4461,7 +4461,7 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"rustynes-core",
"rustynes-hdpack",
@@ -4476,14 +4476,14 @@ dependencies = [
[[package]]
name = "rustynes-monetization"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"uniffi",
]
[[package]]
name = "rustynes-netplay"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"futures-util",
"js-sys",
@@ -4499,7 +4499,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4511,14 +4511,14 @@ dependencies = [
[[package]]
name = "rustynes-ra"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"mlua",
"piccolo",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "2.2.2"
+version = "2.2.3"
dependencies = [
"insta",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index f475f01e..7a5fc85a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,7 @@ members = [
default-members = ["crates/rustynes-libretro"]
[workspace.package]
-version = "2.2.2"
+version = "2.2.3"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
diff --git a/README.md b/README.md
index 32829540..c21714ab 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-

+


@@ -775,20 +775,39 @@ and the Material-for-MkDocs documentation handbook at
## Current Release
-RustyNES's current release is **v2.2.2 "Conduit"**, a **build, distribution,
-and CI-integrity patch**. It takes the libretro buildbot recipe from 1 of 10
-jobs green to **all ten building** — the last step before RustyNES appears in
-RetroArch's built-in core downloader — hardens the GitHub Actions supply chain
-(`persist-credentials: false` on all 19 checkouts, a fail-closed release-tag
-check, a SHA-pinned toolchain action), and collapses the toolchain to a single
-pinned source of truth with **no `nightly` on any build path**. **Zero
-emulation-core changes** — nothing under `crates/rustynes-{cpu,ppu,apu,mappers,
-core}` is touched, so AccuracyCoin holds **141/141 (100.00%)** and every golden
-vector is untouched by construction. The one behavioural improvement in a
-shipped artifact: the libretro **tvOS** core is now built with `panic = "abort"`
-like every other platform.
-
-It follows **v2.2.1**, a **housekeeping patch** on top of
+RustyNES's current release is **v2.2.3 "Datum"**, a **performance and
+accuracy-closure patch**. A measure-first appraisal profiled the emulator and
+acted on what it found rather than on intuition.
+
+**Performance.** The specialized PPU fast dot path — measured at **−11.3%**
+frame time on rendering-heavy content and differential-tested bit-identical
+every frame since v2.1.8 — is now the **default**, and reachable from the
+frontend for the first time (`Nes::set_fast_dotloop` previously had no caller
+outside the core, so the win shipped switched off and unreachable). Release
+builds now ship **PGO-optimized** Linux binaries when the >3%-and-byte-identical
+gate passes, and CI gained a same-runner relative frame-time regression gate to
+close a hole where a 2.5x slowdown could pass the old absolute ceiling.
+
+**Accuracy.** The **last two Holy Mapperel residuals are closed** — all 17 ROMs
+now report `detail=0000`. MMC1's two software WRAM write-protect layers and
+FME-7's open-bus-on-disabled-RAM window are both modelled, the former validated
+against **60/60** commercial ROMs (including seven battery-backed MMC1 saves,
+precisely the titles that corrupt if the RAM enable is wrong) plus **138/138**
+extended. The Sunsoft 5B's absolute level is calibrated against Mesen2, which
+required widening `Mapper::mix_audio` to `i32` — the correct full-scale 5B tone
+does not fit `i16`. A save-state schema gap found by a new standing audit is
+fixed (`PPU_SNAPSHOT_VERSION` 8, plus an APU v4 tail), which is what made
+AccuracyCoin report 141/141 through run-ahead as well as without it.
+
+**Two optimizations were measured and rejected**, and are documented with their
+numbers — the project records what did not clear the bar as well as what did.
+**AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff.
+
+It follows **v2.2.2 "Conduit"**, a build, distribution, and CI-integrity patch
+that took the libretro buildbot recipe from 1 of 10 jobs green to **all ten
+building**, hardened the GitHub Actions supply chain, and collapsed the
+toolchain to a single pinned source of truth with no `nightly` on any build
+path; and **v2.2.1**, a **housekeeping patch** on top of
v2.2.0 "Capstone" (below): archives two batches of dev/research tooling (the
Game Genie header-robust re-key's research scripts and a 2A03-revision
DMA-divergence probe), consolidates six open Dependabot PRs with **zero
diff --git a/crates/rustynes-apu/src/snapshot.rs b/crates/rustynes-apu/src/snapshot.rs
index d6527824..de761400 100644
--- a/crates/rustynes-apu/src/snapshot.rs
+++ b/crates/rustynes-apu/src/snapshot.rs
@@ -49,7 +49,37 @@ use crate::triangle::Triangle;
/// the v2 -> v3 migration may show a 1-cycle transient where a
/// reloaded inhibited state has the CPU IRQ line deasserted as the
/// FC step re-establishes it — acceptable.
-pub const APU_SNAPSHOT_VERSION: u8 = 3;
+/// - v4 (2026-07-22): appends the scheduled warm-reset `$4017` re-write
+/// (`reset_4017_delay` + `reset_4017_value`, 2 bytes). [`Apu::reset`] arms
+/// the countdown at 2 and `tick_with_external` decrements it once per CPU
+/// cycle, issuing `FrameCounter::write` when it hits zero (the v2.0.0
+/// beta.3 A4 cycle-accurate reset, calibrated against blargg
+/// `4017_timing`). Both fields were previously unserialized, so a snapshot
+/// taken inside that 2-cycle window restored `delay = 0` and dropped the
+/// re-write entirely — the restored frame counter then kept the sequencer
+/// phase the re-write was supposed to reset. This is the same class as the
+/// PPU's v5 / v6 / v8 tails (ADR 0030 / ADR 0034): live mid-frame state
+/// absent from the schema, invisible to any straight-`run_frame` test and
+/// reachable only through a snapshot/restore round trip. Surfaced by the
+/// standing schema audit
+/// (`crates/rustynes-test-harness/tests/snapshot_schema_audit.rs`) rather
+/// than by a user-visible symptom.
+///
+/// v1..=3 blobs upconvert with both at `0` — "no re-write pending", which
+/// is the resting value and therefore correct for any pre-v4 state not
+/// captured inside the 2-cycle arming window (and for one that was, the
+/// bytes simply do not exist to recover).
+///
+/// Unlike this module's earlier *trailing-optional* tails (the v1.x DMC-DMA
+/// scheduling bytes and the W3-Stage-4 block, both detected by
+/// `has_remaining`), this one is version-gated. Trailing-optional makes two
+/// different blob lengths both valid at one version, which is workable but
+/// leaves the format ambiguous; a version gate does not. The bump costs no
+/// additional compatibility here because the same change already bumps
+/// `PPU_SNAPSHOT_VERSION` to 8 (ADR 0034), and `rustynes_core`'s `.rns`
+/// container is version-exact per section — pre-existing save states are
+/// already rejected at the PPU section.
+pub const APU_SNAPSHOT_VERSION: u8 = 4;
/// Errors returned by [`Apu::restore`].
#[derive(Debug, Error)]
@@ -586,6 +616,16 @@ impl Apu {
w.bool(self.dmc_edge_arm_suppress);
}
+ // === v4 (2026-07-22) scheduled warm-reset `$4017` re-write ===
+ // Armed by `Apu::reset` (delay = 2, value = the frame counter's last
+ // `$4017`), consumed one CPU cycle at a time in `tick_with_external`.
+ // Live for only those 2 cycles, but a snapshot landing in them used to
+ // restore `delay = 0` and silently cancel the re-write. Version-gated
+ // rather than trailing-optional — see the `APU_SNAPSHOT_VERSION`
+ // rustdoc for why this tail breaks with the convention above it.
+ w.u8(self.reset_4017_delay);
+ w.u8(self.reset_4017_value);
+
w.buf
}
@@ -605,7 +645,7 @@ impl Apu {
// by synthesising a schedule; v2 migrates to v3 by setting
// `irq_line_active = irq_flag` (the IRQ-line and $4015 bit 6
// coincided under the v1/v2 conflated model).
- if version != 1 && version != 2 && version != APU_SNAPSHOT_VERSION {
+ if !matches!(version, 1..=APU_SNAPSHOT_VERSION) {
return Err(ApuSnapshotError::UnsupportedVersion(version));
}
self.region = region_from_u8(r.u8()?)?;
@@ -685,6 +725,19 @@ impl Apu {
// present; the bus re-seeds the boot alignment otherwise.
self.restored_parity_tail = had_stage4_tail;
+ // === v4 scheduled warm-reset `$4017` re-write ===
+ // See the matching block in [`Apu::snapshot`]. Version-gated, so a v4
+ // blob must carry both bytes (a short one reports `Truncated`, which is
+ // the honest error). v1..=3 blobs upconvert to "no re-write pending" —
+ // the resting value, and what a pre-v4 restore left behind.
+ if version >= 4 {
+ self.reset_4017_delay = r.u8()?;
+ self.reset_4017_value = r.u8()?;
+ } else {
+ self.reset_4017_delay = 0;
+ self.reset_4017_value = 0;
+ }
+
Ok(())
}
}
@@ -806,15 +859,18 @@ mod tests {
#[test]
fn pre_stage4_blob_without_tail_upconverts() {
- // Build a current blob, then truncate the Stage-4 tail (21 bytes:
- // bool + u64 + u8 + bool + u8 + bool + bool + bool + u8 + bool*5)
- // to simulate a pre-Stage-4 save.
+ // Build a current blob, then strip BOTH the v4 reset-`$4017` tail
+ // (2 bytes, version-gated) and the Stage-4 tail (21 bytes: bool + u64 +
+ // u8 + bool + u8 + bool + bool + bool + u8 + bool*5) to simulate a
+ // pre-Stage-4 save, rewriting the version byte to v3 so the v4 gate
+ // does not then demand bytes that are no longer there.
let mut a = Apu::new(Region::Ntsc, 44_100);
// Make the DMC "active" so the delayed-4015 upconvert is observable.
a.dmc.sample_length = 16;
a.dmc.bytes_remaining = 8;
let mut blob = a.snapshot();
- blob.truncate(blob.len() - 21);
+ blob.truncate(blob.len() - (2 + 21));
+ blob[0] = 3;
let mut b = Apu::new(Region::Ntsc, 44_100);
b.restore(&blob).unwrap();
assert!(
@@ -828,6 +884,91 @@ mod tests {
}
}
+ #[test]
+ fn v4_round_trips_the_scheduled_reset_4017_rewrite() {
+ // The countdown and its payload are live for the 2 CPU cycles between
+ // `Apu::reset` arming them and `tick_with_external` firing the write.
+ let mut a = Apu::new(Region::Ntsc, 44_100);
+ a.reset_4017_delay = 2;
+ a.reset_4017_value = 0x80;
+ let blob = a.snapshot();
+ assert_eq!(
+ blob[0], APU_SNAPSHOT_VERSION,
+ "blob carries current version"
+ );
+
+ let mut b = Apu::new(Region::Pal, 48_000);
+ b.restore(&blob).unwrap();
+ assert_eq!(b.reset_4017_delay, 2);
+ assert_eq!(b.reset_4017_value, 0x80);
+ }
+
+ #[test]
+ fn pre_v4_blob_upconverts_reset_4017_to_no_pending_rewrite() {
+ // v1..=3 blobs have no reset-`$4017` bytes; they must restore as
+ // "nothing scheduled" — the resting state, and what a pre-v4 restore
+ // left behind. Synthesize one by stripping the 2-byte tail and
+ // rewriting the version byte.
+ let mut a = Apu::new(Region::Ntsc, 44_100);
+ a.reset_4017_delay = 2;
+ a.reset_4017_value = 0x80;
+ let mut blob = a.snapshot();
+ blob.truncate(blob.len() - 2);
+ blob[0] = 3;
+
+ let mut b = Apu::new(Region::Ntsc, 44_100);
+ b.reset_4017_delay = 1; // must be overwritten, not left stale
+ b.reset_4017_value = 0xC0;
+ b.restore(&blob).expect("v3 blob must upconvert");
+ assert_eq!(b.reset_4017_delay, 0);
+ assert_eq!(b.reset_4017_value, 0);
+ }
+
+ #[test]
+ fn a_reset_survives_a_snapshot_restore_taken_mid_countdown() {
+ // The behavioural pin, not just a field round trip: a save/restore
+ // landing inside the arming window must still deliver the `$4017`
+ // re-write on the same cycle a straight run would. Before the v4 tail
+ // the restored APU dropped it, so the frame counter kept the sequencer
+ // phase the re-write exists to reset.
+ let mut plain = Apu::new(Region::Ntsc, 44_100);
+ plain.write_register(0x4017, 0x80); // mode 5-step, so the re-write is observable
+ plain.reset();
+ assert_eq!(plain.reset_4017_delay, 2, "reset arms the countdown");
+
+ // Round-trip through a snapshot taken with the countdown live.
+ let mut restored = Apu::new(Region::Pal, 48_000);
+ restored.restore(&plain.snapshot()).unwrap();
+
+ // Advance far enough for the whole chain to play out: the countdown
+ // fires at t=2, `FrameCounter::write` then schedules its own 3/4-cycle
+ // maturation, and only when THAT lands does the sequencer restart. Ten
+ // cycles clears it with margin. (Four does not — the write has fired
+ // but its effect has not yet matured, and both sides still look alike.)
+ for _ in 0..10 {
+ plain.tick_with_external(0.0);
+ restored.tick_with_external(0.0);
+ }
+ assert_eq!(
+ restored.reset_4017_delay, plain.reset_4017_delay,
+ "countdown diverged across the round trip"
+ );
+ // `frame_counter.cycle` is the discriminating observable. `mode` is not:
+ // `reset_rewrite_4017` retains bit 7, so the re-write always restores the
+ // mode already in effect and the field reads the same either way.
+ // Without the v4 tail the restored APU never issues the write, so its
+ // sequencer keeps counting instead of restarting.
+ assert_eq!(
+ restored.frame_counter.cycle, plain.frame_counter.cycle,
+ "the scheduled $4017 re-write did not survive the round trip — the \
+ restored sequencer never restarted"
+ );
+ assert_eq!(
+ restored.frame_counter.reset_in, plain.frame_counter.reset_in,
+ "frame-counter reset maturation diverged across the round trip"
+ );
+ }
+
#[test]
fn fresh_apu_snapshot_has_zero_irq_clear_schedule() {
let a = Apu::new(Region::Ntsc, 44_100);
diff --git a/crates/rustynes-core/Cargo.toml b/crates/rustynes-core/Cargo.toml
index e97834ae..519338c7 100644
--- a/crates/rustynes-core/Cargo.toml
+++ b/crates/rustynes-core/Cargo.toml
@@ -34,6 +34,9 @@ irq-timing-trace = ["std"]
# inside `Ppu::tick`. Enable with
# `cargo test --features ppu-state-trace ...`.
ppu-state-trace = ["std", "rustynes-ppu/ppu-state-trace"]
+# v2.2.3 P2 — forward to `rustynes-ppu/ppu-idle-line-fast`. Off by default
+# (measured a regression; see that crate's manifest and `docs/performance.md`).
+ppu-idle-line-fast = ["rustynes-ppu/ppu-idle-line-fast"]
# `cpu-boot-trace` enables the per-CPU-instruction boot-trace fixture
# used by the Session-12 cold-boot divergence investigation. Off by
# default (CI gate stays fast); enabled by
diff --git a/crates/rustynes-core/benches/full_frame.rs b/crates/rustynes-core/benches/full_frame.rs
index f2af7cd6..3e47a75e 100644
--- a/crates/rustynes-core/benches/full_frame.rs
+++ b/crates/rustynes-core/benches/full_frame.rs
@@ -65,8 +65,8 @@ fn bench_full_frame(c: &mut Criterion) {
/// far harder — it is the recommended `perf record` input (see
/// `docs/performance.md`).
fn bench_full_frame_rendering(c: &mut Criterion) {
- let bytes = std::fs::read(rom_path("sprint-2/flowing_palette.nes"))
- .expect("sprint-2/flowing_palette.nes vendored in tests/roms/");
+ let bytes = std::fs::read(rom_path("assorted/flowing_palette.nes"))
+ .expect("assorted/flowing_palette.nes vendored in tests/roms/");
c.bench_function("nes_run_frame_flowing_palette", |b| {
b.iter_batched(
@@ -120,8 +120,8 @@ fn bench_full_frame_fast(c: &mut Criterion) {
}
fn bench_full_frame_rendering_fast(c: &mut Criterion) {
- let bytes = std::fs::read(rom_path("sprint-2/flowing_palette.nes"))
- .expect("sprint-2/flowing_palette.nes vendored in tests/roms/");
+ let bytes = std::fs::read(rom_path("assorted/flowing_palette.nes"))
+ .expect("assorted/flowing_palette.nes vendored in tests/roms/");
c.bench_function("nes_run_frame_flowing_palette_fast", |b| {
b.iter_batched(
diff --git a/crates/rustynes-core/benches/snapshot_restore.rs b/crates/rustynes-core/benches/snapshot_restore.rs
index ab0748ad..31e217af 100644
--- a/crates/rustynes-core/benches/snapshot_restore.rs
+++ b/crates/rustynes-core/benches/snapshot_restore.rs
@@ -105,7 +105,7 @@ fn bench_rom(c: &mut Criterion, label: &str, rel: &str) {
}
fn bench_snapshot_restore(c: &mut Criterion) {
- bench_rom(c, "flowing_palette", "sprint-2/flowing_palette.nes");
+ bench_rom(c, "flowing_palette", "assorted/flowing_palette.nes");
bench_rom(c, "mmc3", "holy_mapperel/M4_P128K_CR8K.nes");
}
diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs
index 11ec42eb..0d6d7210 100644
--- a/crates/rustynes-core/src/bus.rs
+++ b/crates/rustynes-core/src/bus.rs
@@ -434,6 +434,10 @@ pub struct LockstepBus {
/// the default + Four Score reads and the determinism contract are
/// unaffected unless a device is explicitly attached.
expansion_device: [Option; 2],
+ /// A3 (v2.2.3), default **off**: serve a Zapper's light bit from the
+ /// beam-relative temporal model instead of the frame-granular one. See
+ /// [`Bus::set_zapper_temporal_light`].
+ zapper_temporal_light: bool,
/// Famicom built-in **microphone** signal (v2.2.0 "Capstone"). The hardwired
/// second Famicom controller carries a push-to-talk microphone whose state is
/// read on **`$4016` bit 2** (not `$4017`) — games such as *The Legend of
@@ -854,6 +858,7 @@ impl LockstepBus {
vs_4016_bit1: false,
vs_4016_bit1_dirty: false,
expansion_device: [None, None],
+ zapper_temporal_light: false,
famicom_mic: false,
nt_mirroring_override: None,
#[cfg(feature = "debug-hooks")]
@@ -1623,6 +1628,32 @@ impl LockstepBus {
}
}
+ /// A3 (v2.2.3): enable the **beam-relative** Zapper light model.
+ ///
+ /// Default **off**, which keeps the frame-granular model and therefore
+ /// byte-identical output on every shipped build.
+ ///
+ /// With it on, the light bit is derived from where the CRT beam is at the
+ /// moment of the `$4016`/`$4017` read rather than from the completed frame:
+ /// dark before the beam paints the aim row, lit while the photodiode holds
+ /// (~19-26 scanlines), dark once it drains. That is what real hardware
+ /// does, and the frame model structurally cannot express it — it returns
+ /// one answer for the whole frame.
+ ///
+ /// Opt-in rather than promoted because there is **no pass/fail light-gun
+ /// test ROM** to adjudicate it: the supported titles re-poll every frame and
+ /// are satisfied by either model, so promoting it would change output with
+ /// no oracle able to confirm the change is an improvement.
+ pub const fn set_zapper_temporal_light(&mut self, on: bool) {
+ self.zapper_temporal_light = on;
+ }
+
+ /// Whether the beam-relative Zapper light model is enabled (A3).
+ #[must_use]
+ pub const fn zapper_temporal_light(&self) -> bool {
+ self.zapper_temporal_light
+ }
+
/// Attach (or replace) a non-standard overlay device on `port` (0 =
/// `$4016`, 1 = `$4017`). Pass `None` to unplug the device and return the
/// port to the standard controller / Four Score path (byte-identical).
@@ -2446,7 +2477,7 @@ impl LockstepBus {
/// advancing the shift register. Four Score off → just
/// `controllers[port].read()`; on → the multiplexed 24-read sequence
/// (primary pad → secondary pad → signature → 1s).
- const fn read_port(&mut self, port: usize) -> u8 {
+ fn read_port(&mut self, port: usize) -> u8 {
// v1.6.0 Workstream A3 (`TAStudio` lag log): any read of $4016/$4017
// counts as the game polling input this frame. Output-only; gated.
#[cfg(feature = "debug-hooks")]
@@ -2458,6 +2489,28 @@ impl LockstepBus {
// bits 3/4) instead of the standard D0 shift-register bit. The
// standard controller is still strobed (in `commit_controller_strobe`)
// so detaching the device restores byte-identical behavior.
+ // A3 (v2.2.3, opt-in): serve the Zapper's light bit from the
+ // beam-relative model. `read_at_scanline` takes `&self` and the PPU is a
+ // different field, so these are disjoint borrows. Off by default, so the
+ // shipped path below is byte-identical.
+ if self.zapper_temporal_light
+ && let Some(crate::input_device::InputDevice::Zapper(z)) = &self.expansion_device[port]
+ {
+ // `scanline()` is `i16` but is non-negative on every current region
+ // (visible 0..=239, then post-render / vblank up to the pre-render
+ // line — 261 NTSC / 311 PAL, NOT -1), so `try_from` always succeeds
+ // and this resolves to `read_at_scanline`, which already yields
+ // no-light for the pre-render line (`prerender - y >= HOLD` for every
+ // visible aim). The `Err` arm is a total-conversion fallback: if a
+ // future convention ever produced a negative scanline (a -1
+ // pre-render), the correct answer is "no light yet" —
+ // `read_before_visible` — rather than the row-0 fold a bare
+ // `unwrap_or(0)` would give.
+ return match u16::try_from(self.ppu.scanline()) {
+ Ok(sl) => z.read_at_scanline(self.ppu.framebuffer(), sl),
+ Err(_) => z.read_before_visible(),
+ };
+ }
if let Some(d) = &mut self.expansion_device[port] {
return d.read();
}
@@ -2483,7 +2536,23 @@ impl LockstepBus {
}
/// Side-effect-free companion to [`Self::read_port`] (debugger peek).
- const fn peek_port(&self, port: usize) -> u8 {
+ fn peek_port(&self, port: usize) -> u8 {
+ // Mirror the temporal-Zapper branch in `read_port` so a debugger peek
+ // shows the same `$4016`/`$4017` byte the CPU would receive. Without
+ // this, with `zapper_temporal_light` on, `peek_port` fell through to the
+ // overlay's frame-granular `peek()` and could disagree with the live
+ // read. `peek` is non-mutating and all of `scanline()` / `framebuffer()`
+ // / `read_at_scanline` / `read_before_visible` take `&self`, so this is a
+ // pure read; it costs `peek_port` its `const` (try_from/match are not
+ // const here), which nothing relied on. Off by default → byte-identical.
+ if self.zapper_temporal_light
+ && let Some(crate::input_device::InputDevice::Zapper(z)) = &self.expansion_device[port]
+ {
+ return u16::try_from(self.ppu.scanline()).map_or_else(
+ |_| z.read_before_visible(),
+ |sl| z.read_at_scanline(self.ppu.framebuffer(), sl),
+ );
+ }
if let Some(d) = &self.expansion_device[port] {
return d.peek();
}
@@ -2979,11 +3048,18 @@ impl LockstepBus {
self.ppu.on_cpu_cycle();
self.mapper.notify_cpu_cycle();
// Sample the mapper's audio extension AFTER notify_cpu_cycle has
- // advanced its oscillators. `Mapper::mix_audio` returns i16; we
- // scale to approximately the same [-0.5, 0.5] range as the APU
- // mixer's own output. Mappers without on-cart audio return 0,
- // which scales to 0.0 -- a no-op for the standard cartridges.
- let mapper_sample = f32::from(self.mapper.mix_audio()) / 65536.0;
+ // advanced its oscillators. `Mapper::mix_audio` returns i32 (widened
+ // from i16 in v2.2.3 so the Sunsoft 5B's ~3.6x full-volume level is
+ // representable); we scale to approximately the same [-0.5, 0.5] range
+ // as the APU mixer's own output. Mappers without on-cart audio return
+ // 0, which scales to 0.0 -- a no-op for the standard cartridges.
+ //
+ // `as f32` rather than `f32::from`: there is no lossless From for
+ // f32. The cast is exact for every value any board actually produces
+ // (|sample| well under 2^24, where f32 is still integer-exact); the
+ // widening exists to raise a ~32k ceiling to ~16.7M, not to use it.
+ #[allow(clippy::cast_precision_loss)]
+ let mapper_sample = self.mapper.mix_audio() as f32 / 65536.0;
self.apu.tick_with_external(mapper_sample);
// Fan-out the APU frame-counter events to any on-cart audio
// extension that shares the 2A03 frame-counter cadence (MMC5).
@@ -4083,8 +4159,9 @@ impl LockstepBus {
/// and boards without the frame hook have the default no-op. Skipping
/// both saves two virtual calls + an f32 divide per CPU cycle.
fn apu_advance_one(&mut self) {
+ #[allow(clippy::cast_precision_loss)] // see `mix_audio`'s call site above
let mapper_sample = if self.mapper_caps.audio {
- f32::from(self.mapper.mix_audio()) / 65536.0
+ self.mapper.mix_audio() as f32 / 65536.0
} else {
0.0
};
@@ -5138,6 +5215,62 @@ mod four_score_tests {
}
}
+ /// With the beam-relative Zapper model on, a debugger peek of `$4017` must
+ /// return the SAME light contribution the CPU read produces — at the
+ /// pre-render line and at a visible line — and must not advance device
+ /// state.
+ ///
+ /// Regression pin for the `peek_port` parity fix: before it, `peek_port`
+ /// fell through to the overlay's frame-granular `peek()` and could report a
+ /// different light bit than `read_port` at the same instant. (This is the
+ /// real defect the fix addressed; the separate `read_before_visible`
+ /// conversion fallback is defensive, since `scanline()` is non-negative on
+ /// every current region — pre-render is line 261 NTSC / 311 PAL, not -1.)
+ #[test]
+ fn temporal_zapper_debugger_peek_matches_cpu_read() {
+ use crate::input_device::{InputDevice, ZapperState};
+
+ // $4017 bit 3 is the (inverted) light bit; the open-bus upper bits differ
+ // between the read and peek paths, so compare only the device bit.
+ const LIGHT: u8 = 0b0000_1000;
+
+ // The two models are constructed to DISAGREE, so the test fails if
+ // `peek_port` does not mirror `read_port`'s temporal branch:
+ // * frame model (`peek()` -> `ZapperState::read()`) reads `light_seen`,
+ // which we force TRUE via `from_parts` -> reports light;
+ // * temporal model (`read_at_scanline`) reads the current scanline. A
+ // fresh bus sits on the pre-render line (261 NTSC), past the
+ // photodiode hold window -> reports NO light.
+ // So a peek that (wrongly) fell through to the frame `peek()` would
+ // return light while the CPU read returns none. No framebuffer or
+ // scanline poke is needed — the injected `light_seen` supplies the
+ // divergence, and the default dark framebuffer keeps the temporal path
+ // at no-light on every line anyway.
+ let mut bus = test_bus();
+ // from_parts(x, y, trigger, light_seen): trigger + light_seen both true.
+ let zapper = ZapperState::from_parts(128, 12, true, true);
+ bus.set_expansion_device(1, Some(InputDevice::Zapper(zapper)));
+ bus.set_zapper_temporal_light(true);
+
+ assert!(
+ bus.ppu.scanline() > 239,
+ "fresh PPU is on the pre-render line"
+ );
+ let cpu = bus.read_port(1) & LIGHT;
+ let peek = bus.peek_port(1) & LIGHT;
+ assert_eq!(cpu, LIGHT, "temporal read at pre-render reports NO light");
+ assert_eq!(
+ peek, cpu,
+ "debugger peek must match the CPU read, not the frame `peek()` \
+ (which would report light from the injected light_seen)",
+ );
+
+ // The peek must be side-effect-free: repeating it does not change the
+ // answer (guards a regression where a peek routes through mutating state).
+ assert_eq!(bus.peek_port(1) & LIGHT, peek);
+ assert_eq!(bus.peek_port(1) & LIGHT, peek);
+ }
+
#[test]
fn pre_v2_1_0_save_state_decodes_with_no_expansion_device() {
// A pre-v2.1.0 blob lacks the 2 trailing device-tag bytes (one None
diff --git a/crates/rustynes-core/src/input_device.rs b/crates/rustynes-core/src/input_device.rs
index 1ab60cc7..928d15a2 100644
--- a/crates/rustynes-core/src/input_device.rs
+++ b/crates/rustynes-core/src/input_device.rs
@@ -219,6 +219,16 @@ pub(crate) const ZAPPER_APERTURE_RADIUS: i32 = 1;
/// flash, which lights the whole aperture. Calibrated for the 3x3 aperture.
pub(crate) const ZAPPER_APERTURE_MIN_BRIGHT: u32 = 2;
+/// Photodiode hold, in scanlines (A3, v2.2.3 — used only by the opt-in
+/// temporal model).
+///
+/// The Zapper's photodiode charges when the CRT beam paints a bright pixel in
+/// its field of view, then drains exponentially: `NESdev` "Zapper" puts the
+/// resulting light-sense window at roughly **19-26 scanlines**. 22 is the
+/// midpoint, and the span is wide enough that no supported title distinguishes
+/// values inside it.
+pub(crate) const ZAPPER_LIGHT_HOLD_SCANLINES: u16 = 22;
+
impl ZapperState {
/// New zapper aimed off-screen, trigger released, no light.
#[must_use]
@@ -261,13 +271,22 @@ impl ZapperState {
/// against the beam position is a documented future refinement — see
/// `docs/frontend.md`.
pub fn sample_light(&mut self, framebuffer: &[u8]) {
+ self.light_seen = Self::aperture_is_bright(framebuffer, self.x, self.y);
+ }
+
+ /// Shared aperture test: is the `(2r+1)x(2r+1)` photodiode field of view at
+ /// `(x, y)` bright enough to charge the sensor?
+ ///
+ /// A3 (v2.2.3) factored this out of [`Self::sample_light`] so the
+ /// frame-granular and beam-relative models cannot drift apart — the
+ /// temporal model differs from the frame model ONLY in *when* it samples,
+ /// never in what counts as light.
+ fn aperture_is_bright(framebuffer: &[u8], x: u16, y: u16) -> bool {
const W: i32 = 256;
const H: i32 = 240;
- let (ax, ay) = (i32::from(self.x), i32::from(self.y));
+ let (ax, ay) = (i32::from(x), i32::from(y));
if ax >= W || ay >= H {
- // Aimed off-screen: never sees light.
- self.light_seen = false;
- return;
+ return false; // aimed off-screen: never sees light
}
let mut bright = 0u32;
let r = ZAPPER_APERTURE_RADIUS;
@@ -295,7 +314,77 @@ impl ZapperState {
}
}
}
- self.light_seen = bright >= ZAPPER_APERTURE_MIN_BRIGHT;
+ bright >= ZAPPER_APERTURE_MIN_BRIGHT
+ }
+
+ /// A3 (v2.2.3): does the photodiode see light **right now**, given where
+ /// the CRT beam currently is?
+ ///
+ /// The frame-granular [`Self::sample_light`] answers "was the aim point
+ /// bright in the completed frame", which is constant for the whole frame —
+ /// so a game polling immediately after its flash and one polling 100
+ /// scanlines later get the same answer. Real hardware does not work that
+ /// way: the photodiode charges as the beam *passes* the aim point and
+ /// drains over ~19-26 scanlines afterwards.
+ ///
+ /// This models that directly, as a pure function of
+ /// `(framebuffer, aim, current scanline)`:
+ ///
+ /// * before the beam reaches the aim row (`scanline < y`) — dark, because
+ /// this frame has not painted it yet;
+ /// * from the aim row until the hold expires — bright iff the aperture is
+ /// bright, the same aperture test [`Self::sample_light`] uses;
+ /// * after the hold — dark again, the capacitor having drained.
+ ///
+ /// Holding **no extra state** is deliberate: light is derived on demand at
+ /// read time rather than latched by a per-scanline callback, so it adds no
+ /// field to serialize, cannot desync a save state or a netplay rollback,
+ /// and keeps the determinism contract (same framebuffer + aim + scanline
+ /// always yields the same answer).
+ ///
+ /// One consequence is physically right rather than a compromise: the
+ /// aperture rows *below* the beam still hold the previous frame's pixels,
+ /// which is exactly what the sensor sees, since the beam has not repainted
+ /// them yet.
+ #[must_use]
+ pub fn light_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> bool {
+ let y = self.y;
+ if scanline < y {
+ return false; // beam has not painted the aim row yet this frame
+ }
+ if scanline - y >= ZAPPER_LIGHT_HOLD_SCANLINES {
+ return false; // photodiode has drained
+ }
+ Self::aperture_is_bright(framebuffer, self.x, y)
+ }
+
+ /// The device byte as [`Self::read`] would return it, but using the
+ /// beam-relative light state from [`Self::light_at_scanline`].
+ #[must_use]
+ pub fn read_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> u8 {
+ let light_not_detected = u8::from(!self.light_at_scanline(framebuffer, scanline));
+ let trigger = u8::from(self.trigger);
+ (trigger << 4) | (light_not_detected << 3)
+ }
+
+ /// The device byte for a read taken **before the visible frame begins** —
+ /// the answer when the PPU's scanline is *negative*, which no light is
+ /// detectable for (the beam has painted nothing this frame yet).
+ ///
+ /// This is a total-conversion fallback, not a fix for a live defect. In this
+ /// engine `Ppu::scanline()` is non-negative on every region — the pre-render
+ /// line is 261 (NTSC) / 311 (PAL), not -1 — so the visible/vblank path
+ /// through [`Self::read_at_scanline`] already yields no-light for pre-render
+ /// (`prerender - y >= ZAPPER_LIGHT_HOLD_SCANLINES` for every on-screen aim),
+ /// and this branch is not reached. It exists so that the caller's
+ /// `u16::try_from(scanline)` has a *correct* `Err` answer — "no light yet" —
+ /// rather than the row-0 fold a bare `unwrap_or(0)` would produce, should a
+ /// future scanline convention (a -1 pre-render, as some emulators use) ever
+ /// hand this a negative value.
+ #[must_use]
+ pub const fn read_before_visible(&self) -> u8 {
+ let trigger = self.trigger as u8;
+ (trigger << 4) | (1 << 3) // light NOT detected (bit 3 is inverted)
}
/// The device byte for a `$4016`/`$4017` access. Bit 3 = light (0 detected /
@@ -1683,4 +1772,154 @@ mod tests {
let _ = bd.read();
let _ = bd.peek();
}
+
+ // ---------------------------------------------------------------
+ // A3 (v2.2.3): beam-relative temporal light integration.
+ // ---------------------------------------------------------------
+
+ /// Build a framebuffer with a bright 3x3 target centred on `(x, y)`.
+ /// Clamped at the edges so a target on row/column 0 is expressible (the
+ /// pre-render regression test needs `y == 0`); the 3x3 aperture is simply
+ /// clipped there, exactly as it is on hardware at the screen edge.
+ fn fb_with_target(x: usize, y: usize) -> alloc::vec::Vec {
+ let mut fb = alloc::vec![0u8; 256 * 240 * 4];
+ for py in y.saturating_sub(1)..=(y + 1).min(239) {
+ for px in x.saturating_sub(1)..=(x + 1).min(255) {
+ let idx = (py * 256 + px) * 4;
+ fb[idx] = 0xFF;
+ fb[idx + 1] = 0xFF;
+ fb[idx + 2] = 0xFF;
+ }
+ }
+ fb
+ }
+
+ /// The core of A3: light is a function of WHERE THE BEAM IS, not merely of
+ /// the frame. Before the beam paints the aim row there is no light, however
+ /// bright the target; during the photodiode hold there is; after it drains
+ /// there is not.
+ #[test]
+ fn zapper_temporal_light_follows_the_beam() {
+ let mut z = ZapperState::new();
+ z.set(100, 120, false);
+ let fb = fb_with_target(100, 120);
+
+ // Beam still above the aim row: the row has not been painted yet.
+ assert!(!z.light_at_scanline(&fb, 0));
+ assert!(!z.light_at_scanline(&fb, 119));
+
+ // Beam reaches the aim row -> charged.
+ assert!(z.light_at_scanline(&fb, 120));
+ // Still within the ~19-26 scanline hold.
+ assert!(z.light_at_scanline(&fb, 120 + ZAPPER_LIGHT_HOLD_SCANLINES - 1));
+ // Drained.
+ assert!(!z.light_at_scanline(&fb, 120 + ZAPPER_LIGHT_HOLD_SCANLINES));
+ assert!(!z.light_at_scanline(&fb, 239));
+ }
+
+ /// The frame-granular model cannot express the above: it reports the SAME
+ /// answer at every scanline. This test is what makes A3 worth having.
+ #[test]
+ fn zapper_frame_model_is_scanline_invariant_but_temporal_is_not() {
+ let mut z = ZapperState::new();
+ z.set(100, 120, false);
+ let fb = fb_with_target(100, 120);
+ z.sample_light(&fb);
+ // Frame model: one answer for the whole frame.
+ assert!(z.light_seen);
+ // Temporal model: three different answers within that same frame.
+ let before = z.light_at_scanline(&fb, 10);
+ let during = z.light_at_scanline(&fb, 125);
+ let after = z.light_at_scanline(&fb, 200);
+ assert!(
+ !before && during && !after,
+ "temporal model must vary within a frame"
+ );
+ }
+
+ /// The temporal path must reuse the SAME aperture rule, not a looser one:
+ /// a lone bright pixel still fails to charge the sensor even at the exact
+ /// beam position.
+ #[test]
+ fn zapper_temporal_rejects_lone_bright_pixel() {
+ let mut z = ZapperState::new();
+ z.set(50, 60, false);
+ let mut fb = alloc::vec![0u8; 256 * 240 * 4];
+ let idx = (60 * 256 + 50) * 4;
+ fb[idx] = 0xFF;
+ fb[idx + 1] = 0xFF;
+ fb[idx + 2] = 0xFF;
+ assert!(
+ !z.light_at_scanline(&fb, 60),
+ "one pixel must not trip the aperture"
+ );
+ z.sample_light(&fb);
+ assert!(!z.light_seen, "frame model must agree");
+ }
+
+ /// Aiming off-screen never sees light at any beam position.
+ #[test]
+ fn zapper_temporal_off_screen_never_sees_light() {
+ let mut z = ZapperState::new();
+ z.set(300, 250, false);
+ let fb = alloc::vec![0xFFu8; 256 * 240 * 4];
+ for sl in [0u16, 120, 239] {
+ assert!(!z.light_at_scanline(&fb, sl));
+ }
+ }
+
+ /// `read_at_scanline` carries the same inverted polarity and trigger bit as
+ /// `read`, so a caller can swap models without re-deriving the byte format.
+ #[test]
+ fn zapper_temporal_read_byte_matches_the_documented_bit_layout() {
+ let mut z = ZapperState::new();
+ z.set(100, 120, true); // trigger pulled
+ let fb = fb_with_target(100, 120);
+ // In the hold: light detected -> bit 3 = 0; trigger -> bit 4 = 1.
+ assert_eq!(z.read_at_scanline(&fb, 121) & 0b0001_1000, 0b0001_0000);
+ // Drained: light NOT detected -> bit 3 = 1.
+ assert_eq!(z.read_at_scanline(&fb, 200) & 0b0001_1000, 0b0001_1000);
+ }
+
+ /// [`ZapperState::read_before_visible`] — the total-conversion fallback for a
+ /// negative scanline — reports no light regardless of the framebuffer, and
+ /// still carries the trigger bit.
+ ///
+ /// This pins the fallback's contract, not a live defect: `Ppu::scanline()`
+ /// is non-negative on every region (pre-render is line 261 NTSC / 311 PAL,
+ /// not -1), so the bus reaches this only if a future convention ever hands a
+ /// negative scanline to `u16::try_from`. The paired assertion below shows the
+ /// difference the fallback guards against — the real pre-render line, being
+ /// past the photodiode hold window, already reads no-light through the normal
+ /// `read_at_scanline` path.
+ #[test]
+ fn zapper_read_before_visible_reports_no_light() {
+ let mut z = ZapperState::new();
+ z.set(100, 0, true); // aim on visible row 0, trigger pulled
+ let fb = fb_with_target(100, 0);
+
+ // Row 0 itself is inside the hold window: light IS detected there.
+ assert_eq!(
+ z.read_at_scanline(&fb, 0) & 0b0000_1000,
+ 0,
+ "row 0 detects light (contrast for the fallback below)"
+ );
+
+ // The real pre-render line (261 NTSC) is already no-light via the normal
+ // path — it is past the hold window, so no fallback is needed there.
+ assert_eq!(
+ z.read_at_scanline(&fb, 261) & 0b0000_1000,
+ 0b0000_1000,
+ "the real pre-render line (261) already reads no-light",
+ );
+
+ // The negative-scanline fallback: no light regardless of framebuffer.
+ assert_eq!(
+ z.read_before_visible() & 0b0000_1000,
+ 0b0000_1000,
+ "read_before_visible reports light NOT detected",
+ );
+ // ...and it still carries the trigger bit like every other read.
+ assert_eq!(z.read_before_visible() & 0b0001_0000, 0b0001_0000);
+ }
}
diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs
index 3b3efc5b..5133eb27 100644
--- a/crates/rustynes-core/src/nes.rs
+++ b/crates/rustynes-core/src/nes.rs
@@ -1261,6 +1261,28 @@ impl Nes {
self.bus.set_zapper(port, x, y, trigger);
}
+ /// A3 (v2.2.3): enable the **beam-relative** Zapper light model.
+ ///
+ /// Default **off**. See [`crate::bus::LockstepBus::set_zapper_temporal_light`]
+ /// for the model; in short, the light bit becomes a function of where the
+ /// CRT beam is at the moment of the read (dark before the beam paints the
+ /// aim row, lit for the ~19-26-scanline photodiode hold, dark after)
+ /// instead of one answer for the whole frame.
+ ///
+ /// Deterministic either way: the temporal answer is a pure function of
+ /// framebuffer + aim + current scanline and holds no extra state, so it
+ /// adds nothing to serialize and cannot desync a save state or a netplay
+ /// rollback.
+ pub const fn set_zapper_temporal_light(&mut self, on: bool) {
+ self.bus.set_zapper_temporal_light(on);
+ }
+
+ /// Whether the beam-relative Zapper light model is enabled (A3).
+ #[must_use]
+ pub const fn zapper_temporal_light(&self) -> bool {
+ self.bus.zapper_temporal_light()
+ }
+
/// Drive the Famicom built-in **microphone** (read on `$4016` bit 2).
///
/// The hardwired second Famicom controller carries a push-to-talk mic that
@@ -2169,16 +2191,19 @@ impl Nes {
/// common "clean" visible BG-render dots (visible scanline, dots `1..=256`,
/// rendering stably enabled, no sub-dot disturbance) to a straight-line
/// handler that runs the identical helper sequence with the statically-dead
- /// event branches pruned. **Off by default** and **byte-identical** to a
- /// build without it — proven bit-for-bit by the differential test
- /// (`fast_dotloop_diff`) and the full `AccuracyCoin` / visual-regression /
- /// nestest oracle. A frontend/config knob, NOT part of the save-state.
+ /// event branches pruned. **On by default since v2.2.3** (OFF through
+ /// v2.2.2) and **byte-identical** to the exact path — proven bit-for-bit
+ /// every frame by the differential test (`fast_dotloop_diff`) and the full
+ /// `AccuracyCoin` / visual-regression / nestest oracle, and measured at
+ /// **-11.3%** on the rendering-heavy `full_frame` bench. Setting it `false`
+ /// selects the fully-general per-dot path and remains the fallback. A
+ /// frontend/config knob, NOT part of the save-state.
pub const fn set_fast_dotloop(&mut self, enabled: bool) {
self.bus.set_fast_dotloop(enabled);
}
/// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled
- /// (`false` = default, byte-identical to a build without it).
+ /// (`true` = default since v2.2.3; both settings produce identical frames).
#[must_use]
pub const fn fast_dotloop(&self) -> bool {
self.bus.fast_dotloop()
@@ -3530,4 +3555,18 @@ mod tests {
nes.nsf_set_song(1); // no-op, must not panic or reset spuriously
assert_eq!(nes.nsf_current_song(), 0);
}
+
+ /// A3 (v2.2.3): the beam-relative Zapper model is OFF by default, so the
+ /// shipped `$4017` byte is exactly what the frame-granular model produced.
+ #[test]
+ fn zapper_temporal_light_is_off_by_default() {
+ let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
+ assert!(!nes.zapper_temporal_light(), "A3 must default OFF");
+ nes.set_zapper(1, 100, 120, false);
+ // Toggling it on and back off must restore the default exactly.
+ nes.set_zapper_temporal_light(true);
+ assert!(nes.zapper_temporal_light());
+ nes.set_zapper_temporal_light(false);
+ assert!(!nes.zapper_temporal_light());
+ }
}
diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs
index 1ceab53f..c5a2af2a 100644
--- a/crates/rustynes-frontend/src/app.rs
+++ b/crates/rustynes-frontend/src/app.rs
@@ -5654,6 +5654,12 @@ impl App {
nes.set_ppu_revision(revision);
nes.set_power_up_palette(palette);
nes.set_power_on_ram(ram);
+ // v2.2.3 — the specialized PPU fast dot path. Unlike the three
+ // knobs above this is a performance selector, not an accuracy
+ // model: both paths emit the identical frame (pinned every frame
+ // by `fast_dotloop_diff`), so pushing it here is purely about
+ // honouring the user's escape hatch. Default on.
+ nes.set_fast_dotloop(self.config.emulation.fast_dotloop);
}
}
@@ -9257,6 +9263,13 @@ impl ApplicationHandler for App {
if settings.oam_decay {
self.apply_oam_decay();
}
+ // v2.2.3 — PPU fast-dot-path toggle live-apply. Routed through
+ // `apply_ppu_hardware_config` (which pushes the whole
+ // `[emulation]` PPU knob set); re-pushing the other three is
+ // idempotent. Either setting emits the identical frame.
+ if settings.fast_dotloop {
+ self.apply_ppu_hardware_config();
+ }
// v1.0.0 — act on a Save-States manager Save / Load click this
// frame, routing through the existing slot handlers; a Save
// invalidates that slot's cached thumbnail so the grid refreshes.
diff --git a/crates/rustynes-frontend/src/config.rs b/crates/rustynes-frontend/src/config.rs
index 7bb31ed2..fd6844e0 100644
--- a/crates/rustynes-frontend/src/config.rs
+++ b/crates/rustynes-frontend/src/config.rs
@@ -1670,10 +1670,16 @@ pub struct EnhancementsConfig {
///
/// Unlike [`EnhancementsConfig`] (non-accuracy "improvement" modes that are never
/// applied while the oracle runs), these make emulation *more* faithful to real
-/// hardware and DO feed the deterministic core when enabled. Every field is
-/// off/neutral by default, so a pre-v2.1.4 config (no `[emulation]` section) loads
-/// **byte-identical** to today's behaviour.
-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
+/// hardware and DO feed the deterministic core when enabled. Every *accuracy*
+/// field is off/neutral by default, so a pre-v2.1.4 config (no `[emulation]`
+/// section) loads **byte-identical** to today's behaviour.
+///
+/// [`Self::fast_dotloop`] is the one field that is NOT an accuracy knob: it
+/// selects a PPU code path that produces the identical frame either way, and it
+/// defaults **on**. It lives here because it is pushed into the core through the
+/// same `apply_ppu_hardware_config` path as the rest, not because it changes
+/// emulation.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
// These are independent, orthogonal accuracy toggles (each maps to a distinct
// core knob), not a state machine — a bitfield/enum would only obscure the
// serde config surface. v2.1.7 P5 pushed the count past the 3-bool lint.
@@ -1716,6 +1722,50 @@ pub struct EmulationConfig {
/// Ignored when randomization is off.
#[serde(default)]
pub power_on_ram_seed: u64,
+
+ /// v2.1.8 A1 / v2.2.3 — use the specialized visible-scanline fast dot path
+ /// (`Nes::set_fast_dotloop`). **On by default**, and unlike every other
+ /// field here it is **not an accuracy knob**: the fast path runs the same
+ /// helper sequence as the general path with statically-dead branches
+ /// pruned, so it emits the identical framebuffer, audio and cycle count —
+ /// pinned bit-for-bit every frame by `fast_dotloop_diff`. It is ~11% faster
+ /// on rendering-heavy content and neutral when rendering is disabled.
+ ///
+ /// Exposed as a setting only as an escape hatch: if a future change ever
+ /// made the two paths disagree, turning this off selects the
+ /// fully-general per-dot path without a rebuild. There is no accuracy
+ /// reason to disable it.
+ ///
+ /// Defaulted through `default_fast_dotloop` rather than `#[serde(default)]`
+ /// so an existing config written before this key existed loads as `true`
+ /// (the shipped default) instead of silently opting the user out.
+ #[serde(default = "default_fast_dotloop")]
+ pub fast_dotloop: bool,
+}
+
+/// Serde + [`Default`] value for [`EmulationConfig::fast_dotloop`] — `true`.
+///
+/// A named function because `bool`'s `Default` is `false`, which would both
+/// deserialize a pre-v2.2.3 config to the wrong value and make
+/// `EmulationConfig::default()` disagree with the core's own default.
+const fn default_fast_dotloop() -> bool {
+ true
+}
+
+impl Default for EmulationConfig {
+ /// Hand-written rather than derived: every accuracy field is `false`/`0`
+ /// (the byte-identical baseline), but [`Self::fast_dotloop`] must default
+ /// to `true` to match the core, which `#[derive(Default)]` cannot express.
+ fn default() -> Self {
+ Self {
+ oam_decay: false,
+ ppu_oamaddr_corruption: false,
+ blargg_power_up_palette: false,
+ randomize_power_on_ram: false,
+ power_on_ram_seed: 0,
+ fast_dotloop: default_fast_dotloop(),
+ }
+ }
}
/// `[retroachievements]` section (v2.7.0).
@@ -2516,6 +2566,43 @@ debug_overlay = "Backquote"
assert_eq!(cfg, back);
}
+ #[test]
+ fn emulation_fast_dotloop_defaults_on_for_pre_v2_2_3_configs() {
+ // The subtle case this guards: `fast_dotloop` is the one `[emulation]`
+ // field whose default is `true`, so a plain `#[serde(default)]` (bool ->
+ // false) would silently opt every existing user OUT of the fast path on
+ // upgrade — a ~11% slowdown, invisible because both paths render
+ // identically. Cover all three shapes an on-disk config can take.
+
+ // 1. A pre-v2.2.3 `[emulation]` section that predates the key.
+ let older: EmulationConfig = toml::from_str("oam_decay = true\n").unwrap();
+ assert!(older.fast_dotloop, "missing key must default ON");
+ assert!(older.oam_decay, "sibling keys still parse");
+
+ // 2. No `[emulation]` section at all (pre-v2.1.4).
+ let absent: EmulationConfig = toml::from_str("").unwrap();
+ assert!(absent.fast_dotloop, "empty section must default ON");
+
+ // 3. An explicit opt-out must survive a round trip — the escape hatch
+ // is worthless if it silently reverts.
+ let off = EmulationConfig {
+ fast_dotloop: false,
+ ..EmulationConfig::default()
+ };
+ let back: EmulationConfig = toml::from_str(&toml::to_string_pretty(&off).unwrap()).unwrap();
+ assert!(!back.fast_dotloop, "explicit opt-out must round-trip");
+
+ // 4. `Default` must agree with serde, and with the core's own default.
+ assert!(EmulationConfig::default().fast_dotloop);
+ // The accuracy knobs stay off — promotion must not have disturbed them.
+ let d = EmulationConfig::default();
+ assert!(!d.oam_decay);
+ assert!(!d.ppu_oamaddr_corruption);
+ assert!(!d.blargg_power_up_palette);
+ assert!(!d.randomize_power_on_ram);
+ assert_eq!(d.power_on_ram_seed, 0);
+ }
+
#[test]
fn config_without_fds_section_loads_unchanged() {
// v2.2.0 back-compat: a pre-v2.2.0 config has no `[fds]` section and
diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs
index c1554ee6..bc8e8d0c 100644
--- a/crates/rustynes-frontend/src/debugger/settings_panel.rs
+++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs
@@ -129,6 +129,10 @@ pub struct SettingsApply {
/// the new `[emulation] oam_decay` into the core under the emu lock. Off (the
/// default) is byte-identical to a decay-free core.
pub oam_decay: bool,
+ /// v2.2.3 — the PPU fast-dot-path toggle changed; the app re-pushes the
+ /// `[emulation]` PPU knobs into the core under the emu lock. Both settings
+ /// emit the identical frame, so this only honours the user's escape hatch.
+ pub fast_dotloop: bool,
}
impl SettingsApply {
@@ -153,6 +157,7 @@ impl SettingsApply {
|| self.shader_stack
|| self.palette_select
|| self.oam_decay
+ || self.fast_dotloop
}
}
@@ -1816,6 +1821,29 @@ pub fn advanced_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi
save_config(config);
}
+ // v2.2.3 — the specialized PPU fast dot path. NOT an accuracy toggle: both
+ // paths emit the identical framebuffer/audio/cycle count (pinned every frame
+ // by `fast_dotloop_diff`), so this is a performance selector with an escape
+ // hatch, defaulted on. Grouped under Accuracy because it is pushed through
+ // the same core-knob path, and labelled to say plainly that it is not one.
+ if ui
+ .checkbox(
+ &mut config.emulation.fast_dotloop,
+ "Fast PPU dot path (performance, not accuracy)",
+ )
+ .on_hover_text(
+ "Run the specialized straight-line handler for undisturbed visible \
+ background dots. Emits the identical frame either way (verified \
+ bit-for-bit every frame) and is ~11% faster on rendering-heavy \
+ games. Leave on unless you are diagnosing a suspected PPU \
+ difference.",
+ )
+ .changed()
+ {
+ state.apply.fast_dotloop = true;
+ save_config(config);
+ }
+
ui.add_space(8.0);
enhancements_section(ui, state, config);
@@ -1829,6 +1857,10 @@ pub fn advanced_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi
config.emulation = crate::config::EmulationConfig::default();
state.apply.rewind_enabled = true;
state.apply.oam_decay = true;
+ // `EmulationConfig::default()` restores `fast_dotloop = true`, so the
+ // reset must re-push it too (a user who had turned it off gets the
+ // default back live, not on next launch).
+ state.apply.fast_dotloop = true;
save_config(config);
}
}
diff --git a/crates/rustynes-frontend/src/history_viewer.rs b/crates/rustynes-frontend/src/history_viewer.rs
index 21340742..8b6f3548 100644
--- a/crates/rustynes-frontend/src/history_viewer.rs
+++ b/crates/rustynes-frontend/src/history_viewer.rs
@@ -373,7 +373,7 @@ mod tests {
#[test]
fn records_span_and_anchors() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
let mut hv = HistoryViewer::new(1000, 10);
for f in 0..50u64 {
@@ -394,7 +394,7 @@ mod tests {
#[test]
fn budget_evicts_oldest_input_but_keeps_a_base_anchor() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
let mut hv = HistoryViewer::new(20, 10);
for f in 0..60u64 {
@@ -413,7 +413,7 @@ mod tests {
/// fresh emulator and confirm the final framebuffer equals the live one.
#[test]
fn exported_clip_replays_bit_identically() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
let mut hv = HistoryViewer::new(10_000, 10);
@@ -467,7 +467,7 @@ mod tests {
#[test]
fn clear_resets() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
let mut hv = HistoryViewer::default();
nes.run_frame();
diff --git a/crates/rustynes-frontend/src/runahead.rs b/crates/rustynes-frontend/src/runahead.rs
index 033ffc6d..b5e778e5 100644
--- a/crates/rustynes-frontend/src/runahead.rs
+++ b/crates/rustynes-frontend/src/runahead.rs
@@ -120,7 +120,7 @@ mod tests {
/// VISIBLE frame equals the plain run one frame later (N=1).
#[test]
fn runahead_persistent_timeline_matches_plain_run() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut ahead = Nes::from_rom(&bytes).expect("rom parses");
let mut plain = Nes::from_rom(&bytes).expect("rom parses");
// Rewind armed on BOTH so the capture-suppression path is exercised.
@@ -190,7 +190,7 @@ mod tests {
/// run-ahead interaction.
#[test]
fn runahead_preserves_genie_codes() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
nes.enable_rewind();
nes.add_genie_code("SXIOPO").expect("valid 6-char code");
@@ -273,7 +273,7 @@ mod tests {
/// ring, and the rollback must not clear it.
#[test]
fn runahead_rewind_ring_holds_persistent_frames_only() {
- let bytes = rom("sprint-2/flowing_palette.nes");
+ let bytes = rom("assorted/flowing_palette.nes");
let mut nes = Nes::from_rom(&bytes).expect("rom parses");
nes.enable_rewind();
let mut ra = RunAhead::default();
diff --git a/crates/rustynes-mappers/src/bmc_simple.rs b/crates/rustynes-mappers/src/bmc_simple.rs
new file mode 100644
index 00000000..d19b0e37
--- /dev/null
+++ b/crates/rustynes-mappers/src/bmc_simple.rs
@@ -0,0 +1,688 @@
+//! Simple `BMC` multicart boards sharing one parameterised core: Waixing 164,
+//! `BMC-810544-C-A1`, `BMC-60311C`, `BMC-830425C`, `K-3046`, `G-146`, and BS-5.
+//!
+//! Each is a handful of latch registers with no IRQ and no audio, differing
+//! only in which address bits select which bank and in the NROM/UNROM-style
+//! mode each supports. Rather than seven near-identical implementations,
+//! [`SimpleBmc`] carries the common banking machinery and [`SimpleBoard`]
+//! selects the per-board decode -- so a fix to the shared wrap-and-index math
+//! lands on all of them at once.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_2K: usize = 0x0800;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// Which discrete BMC board the [`SimpleBmc`] body models.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SimpleBoard {
+ /// Mapper 164 (Waixing "Final Fantasy V", 32 KiB PRG).
+ M164,
+ /// Mapper 261 (BMC-810544-C-A1).
+ M261,
+ /// Mapper 289 (BMC-60311C).
+ M289,
+ /// Mapper 320 (BMC-830425C-4391T).
+ M320,
+ /// Mapper 336 (BMC-K-3046).
+ M336,
+ /// Mapper 349 (BMC-G-146).
+ M349,
+ /// Mapper 286 (Waixing BS-5).
+ M286,
+}
+
+/// A discrete BMC multicart with a simple (IRQ-free) register surface.
+pub struct SimpleBmc {
+ board: SimpleBoard,
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ /// 16 KiB PRG window for $8000 and $C000.
+ prg0: usize,
+ prg1: usize,
+ /// 8 KiB CHR window (164/261/289/320/336/349).
+ chr8: usize,
+ /// Per-2 KiB CHR windows (286).
+ chr2: [usize; 4],
+ /// Per-8 KiB PRG window for 286 (four 8 KiB windows).
+ prg8: [usize; 4],
+ // Board scratch registers.
+ reg_inner: u8,
+ reg_outer: u8,
+ reg_mode: u8,
+ dip: u8,
+}
+
+impl SimpleBmc {
+ const SAVE_LEN: usize = 4 + 8 + 8 + 4 + 1;
+
+ fn new(
+ board: SimpleBoard,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ id: u16,
+ ) -> Result {
+ check_prg(&prg_rom, id)?;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ let mut m = Self {
+ board,
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg0: 0,
+ prg1: 0,
+ chr8: 0,
+ chr2: [0; 4],
+ prg8: [0; 4],
+ reg_inner: 0,
+ reg_outer: 0,
+ reg_mode: 0,
+ dip: 0,
+ };
+ m.reset_banks();
+ Ok(m)
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn prg_count_8k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_8K).max(1)
+ }
+
+ fn chr_count_8k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_8K).max(1)
+ }
+
+ /// Initial / power-on bank layout per board.
+ fn reset_banks(&mut self) {
+ let last16 = self.prg_count_16k() - 1;
+ match self.board {
+ SimpleBoard::M164 => {
+ // $5000/$5100 split 32 KiB PRG select; power-on 0x0F.
+ self.reg_inner = 0x0F;
+ self.update_m164();
+ }
+ SimpleBoard::M286 => {
+ let last8 = self.prg_count_8k() - 1;
+ let last_chr2 = (self.chr.len() / CHR_BANK_2K).max(1) - 1;
+ for s in &mut self.prg8 {
+ *s = last8;
+ }
+ for c in &mut self.chr2 {
+ *c = last_chr2;
+ }
+ }
+ SimpleBoard::M320 => self.update_m320(),
+ SimpleBoard::M289 => self.update_m289(),
+ _ => {
+ self.prg0 = 0;
+ self.prg1 = last16;
+ }
+ }
+ }
+
+ fn update_m164(&mut self) {
+ // 32 KiB PRG window selected by the 8-bit split register.
+ let count32 = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank32 = (self.reg_inner as usize) % count32;
+ self.prg0 = bank32 * 2;
+ self.prg1 = bank32 * 2 + 1;
+ }
+
+ fn update_m289(&mut self) {
+ let page = self.reg_outer as usize
+ | (if self.reg_mode & 0x04 != 0 {
+ 0
+ } else {
+ self.reg_inner as usize
+ });
+ match self.reg_mode & 0x03 {
+ 0 => {
+ self.prg0 = page;
+ self.prg1 = page;
+ }
+ 1 => {
+ let b = page & 0xFE;
+ self.prg0 = b;
+ self.prg1 = b | 1;
+ }
+ 2 => {
+ self.prg0 = page;
+ self.prg1 = self.reg_outer as usize | 7;
+ }
+ _ => {}
+ }
+ self.mirroring = if self.reg_mode & 0x08 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+
+ fn update_m320(&mut self) {
+ let outer = (self.reg_outer as usize) << 3;
+ if self.reg_mode != 0 {
+ // UNROM mode.
+ self.prg0 = (self.reg_inner as usize & 0x07) | outer;
+ self.prg1 = 0x07 | outer;
+ } else {
+ // UOROM mode.
+ self.prg0 = (self.reg_inner as usize) | outer;
+ self.prg1 = 0x0F | outer;
+ }
+ }
+
+ fn prg_byte(&self, slot16: usize, addr: u16) -> u8 {
+ let count = self.prg_count_16k();
+ let bank = slot16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for SimpleBmc {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if self.board == SimpleBoard::M286 {
+ if let 0x8000..=0xFFFF = addr {
+ let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
+ let count = self.prg_count_8k();
+ let bank = self.prg8[slot] % count;
+ return self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)];
+ }
+ return 0;
+ }
+ match addr {
+ 0x8000..=0xBFFF => self.prg_byte(self.prg0, addr),
+ 0xC000..=0xFFFF => self.prg_byte(self.prg1, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match self.board {
+ SimpleBoard::M164 => {
+ if (0x5000..=0x5FFF).contains(&addr) {
+ match addr & 0x7300 {
+ 0x5000 => self.reg_inner = (self.reg_inner & 0xF0) | (value & 0x0F),
+ 0x5100 => self.reg_inner = (self.reg_inner & 0x0F) | ((value & 0x0F) << 4),
+ _ => {}
+ }
+ self.update_m164();
+ }
+ }
+ SimpleBoard::M261 => {
+ if addr >= 0x8000 {
+ let bank = ((addr >> 6) & 0xFFFE) as usize;
+ if addr & 0x40 != 0 {
+ self.prg0 = bank;
+ self.prg1 = bank | 1;
+ } else {
+ let b = bank | ((addr >> 5) & 0x01) as usize;
+ self.prg0 = b;
+ self.prg1 = b;
+ }
+ self.chr8 = (addr & 0x0F) as usize;
+ self.mirroring = if addr & 0x10 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ SimpleBoard::M289 => {
+ if addr >= 0x8000 {
+ self.reg_inner = value & 0x07;
+ } else {
+ match addr & 0xE001 {
+ 0x6000 => self.reg_mode = value & 0x0F,
+ 0x6001 => self.reg_outer = value,
+ _ => {}
+ }
+ }
+ self.update_m289();
+ }
+ SimpleBoard::M320 => {
+ if addr >= 0x8000 {
+ self.reg_inner = value & 0x0F;
+ if addr & 0xFFE0 == 0xF0E0 {
+ self.reg_outer = (addr & 0x0F) as u8;
+ self.reg_mode = ((addr >> 4) & 0x01) as u8;
+ }
+ self.update_m320();
+ }
+ }
+ SimpleBoard::M336 => {
+ if addr >= 0x8000 {
+ let inner = value as usize & 0x07;
+ let outer = value as usize & 0x38;
+ self.prg0 = outer | inner;
+ self.prg1 = outer | 7;
+ }
+ }
+ SimpleBoard::M349 => {
+ if addr >= 0x8000 {
+ let a = addr as usize;
+ if a & 0x800 != 0 {
+ self.prg0 = (a & 0x1F) | (a & ((a & 0x40) >> 6));
+ self.prg1 = (a & 0x18) | 0x07;
+ } else if a & 0x40 != 0 {
+ self.prg0 = a & 0x1F;
+ self.prg1 = a & 0x1F;
+ } else {
+ let b = a & 0x1E;
+ self.prg0 = b;
+ self.prg1 = b | 1;
+ }
+ self.mirroring = if a & 0x80 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ SimpleBoard::M286 => {
+ let bank = ((addr >> 10) & 0x03) as usize;
+ match addr & 0xF000 {
+ 0x8000 => self.chr2[bank] = (addr & 0x1F) as usize,
+ 0xA000 if addr & (1u16 << (self.dip + 4)) != 0 => {
+ self.prg8[bank] = (addr & 0x0F) as usize;
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&(self.prg0 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.prg1 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.chr8 as u32).to_le_bytes());
+ for c in &self.chr2 {
+ out.extend_from_slice(&(*c as u32).to_le_bytes());
+ }
+ for p in &self.prg8 {
+ out.extend_from_slice(&(*p as u32).to_le_bytes());
+ }
+ out.push(self.reg_inner);
+ out.push(self.reg_outer);
+ out.push(self.reg_mode);
+ out.push(self.dip);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ // header(1) + prg0/prg1/chr8(12) + chr2(16) + prg8(16) + 4 regs + mirror(1)
+ let scratch = 1 + 12 + 16 + 16 + 4 + 1;
+ let expected = scratch + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let rd = |c: usize| {
+ u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
+ };
+ let mut c = 1;
+ self.prg0 = rd(c);
+ self.prg1 = rd(c + 4);
+ self.chr8 = rd(c + 8);
+ c += 12;
+ for s in &mut self.chr2 {
+ *s = rd(c);
+ c += 4;
+ }
+ for s in &mut self.prg8 {
+ *s = rd(c);
+ c += 4;
+ }
+ self.reg_inner = data[c];
+ self.reg_outer = data[c + 1];
+ self.reg_mode = data[c + 2];
+ self.dip = data[c + 3];
+ self.mirroring = byte_to_mirroring(data[c + 4], self.mirroring);
+ c += 5;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+macro_rules! simple_ctor {
+ ($fn_name:ident, $board:expr, $id:expr, $doc:expr) => {
+ #[doc = $doc]
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG/CHR size.
+ pub fn $fn_name(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ SimpleBmc::new($board, prg_rom, chr_rom, mirroring, $id)
+ }
+ };
+}
+
+simple_ctor!(
+ new_m164,
+ SimpleBoard::M164,
+ 164,
+ "Mapper 164 (Waixing Final Fantasy V, 32 KiB-PRG split register)."
+);
+simple_ctor!(
+ new_m261,
+ SimpleBoard::M261,
+ 261,
+ "Mapper 261 (BMC-810544-C-A1 address-as-data multicart)."
+);
+simple_ctor!(
+ new_m289,
+ SimpleBoard::M289,
+ 289,
+ "Mapper 289 (BMC-60311C NROM/UNROM multicart)."
+);
+simple_ctor!(
+ new_m320,
+ SimpleBoard::M320,
+ 320,
+ "Mapper 320 (BMC-830425C-4391T UNROM/UOROM multicart)."
+);
+simple_ctor!(
+ new_m336,
+ SimpleBoard::M336,
+ 336,
+ "Mapper 336 (BMC-K-3046 UNROM-style multicart)."
+);
+simple_ctor!(
+ new_m349,
+ SimpleBoard::M349,
+ 349,
+ "Mapper 349 (BMC-G-146 NROM/UNROM/NROM-256 multicart)."
+);
+simple_ctor!(
+ new_m286,
+ SimpleBoard::M286,
+ 286,
+ "Mapper 286 (Waixing BS-5 Olympic multicart)."
+);
+
+// ===========================================================================
+// Kaiser FDS-conversion boards with a CPU-cycle (M2) IRQ:
+// 56/142 (Kaiser202 / KS202 / KS7032), 303 (Kaiser7017), 253 (Waixing253).
+// Plus the simple Kaiser PRG-window boards 305 (KS7031), 306 (KS7016),
+// 312 (KS7013B). The CPU-cycle IRQ ones declare MapperCaps::CYCLE_IRQ.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_2k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_2K];
+ for b in 0..banks {
+ v[b * CHR_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn waixing164_split_prg_register() {
+ let mut m = new_m164(synth_prg_16k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x5000, 0x02); // low nibble = 2
+ m.cpu_write(0x5100, 0x00); // high nibble = 0 -> 32 KiB bank 2
+ // 32 KiB bank 2 -> 16 KiB banks 4 and 5.
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn bmc_simple_save_state_round_trip() {
+ let mut m = new_m164(synth_prg_16k(16), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x5000, 0x03);
+ m.ppu_write(0x0007, 0x2B);
+ let blob = m.save_state();
+ let mut m2 = new_m164(synth_prg_16k(16), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0007), 0x2B);
+ }
+
+ #[test]
+ fn bmc810544_address_decode() {
+ let mut m = new_m261(synth_prg_16k(32), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // addr bit 6 set -> 32 KiB mode: bank = (addr>>6)&0xFFFE.
+ m.cpu_write(0x8040 | (4 << 6), 0); // bank = (4<<6 ... ) wait compute below.
+ // Simpler: write a precise address.
+ m.cpu_write(0x8000 | (2 << 6) | 0x40, 0); // (addr>>6)&0xFFFE
+ let lo = m.cpu_read(0x8000);
+ let hi = m.cpu_read(0xC000);
+ assert_eq!(hi, lo.wrapping_add(1)); // 32 KiB -> consecutive 16 KiB banks.
+ }
+
+ #[test]
+ fn bmc60311_nrom_mode() {
+ let mut m = new_m289(synth_prg_16k(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6001, 0x02); // outer = 2
+ m.cpu_write(0x6000, 0x00); // mode 0 = NROM-128 (mirror inner/outer)
+ m.cpu_write(0x8000, 0x01); // inner = 1 -> page = 2|1 = 3
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3); // mirrored.
+ }
+
+ #[test]
+ fn bmc830425_unrom_mode() {
+ let mut m = new_m320(synth_prg_16k(32), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xF0E0 | 0x10 | 0x02, 0x03); // outer=2, mode=1 (UNROM), inner=3
+ // UNROM: prg0 = (3&7)|(2<<3)=19; prg1 = 7|(2<<3)=23.
+ assert_eq!(m.cpu_read(0x8000), 19);
+ assert_eq!(m.cpu_read(0xC000), 23);
+ }
+
+ #[test]
+ fn bmc_k3046_unrom() {
+ let mut m = new_m336(synth_prg_16k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x0A); // inner=2, outer=8 -> prg0=8|2=10, prg1=8|7=15
+ assert_eq!(m.cpu_read(0x8000), 10);
+ assert_eq!(m.cpu_read(0xC000), 15);
+ }
+
+ #[test]
+ fn bmc_g146_32k_mode() {
+ let mut m = new_m349(synth_prg_16k(32), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ // bit 11 clear, bit 6 clear -> 32 KiB mode: prg0=addr&0x1E, prg1=that|1.
+ m.cpu_write(0x8000 | 0x04, 0); // addr&0x1E = 4
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn bs5_chr_bank_decode() {
+ let mut m = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ // $8000 with bank in bits 10-11, CHR index in bits 0-4.
+ m.cpu_write(0x8000 | (1 << 10) | 0x05, 0); // chr bank 1 -> index 5
+ assert_eq!(m.ppu_read(0x0800), 5); // 2 KiB slot 1 -> CHR bank 5.
+ }
+
+ #[test]
+ fn bs5_save_state_round_trip() {
+ let mut m = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000 | 0x03, 0);
+ let blob = m.save_state();
+ let mut m2 = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs
index 46838000..2f2419a0 100644
--- a/crates/rustynes-mappers/src/fds.rs
+++ b/crates/rustynes-mappers/src/fds.rs
@@ -1057,10 +1057,12 @@ impl FdsAudio {
/// not advance, so a clock is a no-op (mirrors the gated path so the
/// shared NSF expansion router can call `clock()` unconditionally).
#[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
pub(crate) fn clock(&mut self) {}
/// Feature-off shim: silence when `mapper-audio` is disabled.
#[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::unused_self)]
pub(crate) fn output(&self) -> i16 {
0
}
@@ -2325,8 +2327,8 @@ impl Mapper for Fds {
}
#[cfg(feature = "mapper-audio")]
- fn mix_audio(&mut self) -> i16 {
- self.audio.output()
+ fn mix_audio(&mut self) -> i32 {
+ i32::from(self.audio.output())
}
fn current_mirroring(&self) -> Mirroring {
diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs
new file mode 100644
index 00000000..4e9c38e9
--- /dev/null
+++ b/crates/rustynes-mappers/src/homebrew_boards.rs
@@ -0,0 +1,1435 @@
+//! Modern homebrew flash boards: `INL`-NSF (mapper 31), Magic Floor
+//! (mapper 218), `RET-CUFROM` (mapper 29), and `GTROM` (mapper 111).
+//!
+//! Unlike the pirate boards elsewhere in this crate, these were designed
+//! *after* the console, by homebrew developers who could pick any mapping they
+//! liked -- so they optimise for what a modern toolchain wants rather than for
+//! 1980s discrete-logic cost. Mapper 31 exposes eight independently-latched
+//! 4 KiB PRG slots (chosen so an NSF player can page music banks freely);
+//! Magic Floor uses no CHR memory at all, serving pattern *and* nametable
+//! fetches out of the console's own CIRAM; `GTROM` banks its own nametable
+//! alongside PRG and CHR so a game can double-buffer whole screens.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_4K: usize = 0x1000;
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+// ===========================================================================
+// Mapper 31 — INL / NSF-style 4 KiB-banked board ("2A03 Puritans").
+//
+// Eight 4 KiB PRG slots ($8000/$9000/.../$F000), each latched by a write to
+// $5FF8-$5FFF (the low three address bits pick the slot). Power-on fixes the
+// last slot ($F000) to the final 4 KiB bank (0xFF & mask). CHR is 8 KiB RAM.
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 31 (`INL`-NSF-style 4 KiB-banked board).
+pub struct Inl31 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_slots: [u8; 8],
+ mirroring: Mirroring,
+}
+
+impl Inl31 {
+ /// Construct a new mapper 31 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 4 KiB.
+ #[allow(clippy::cast_possible_truncation)]
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_4K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 31 PRG-ROM size {} is not a non-zero multiple of 4 KiB",
+ prg_rom.len()
+ )));
+ }
+ // The last 4 KiB bank index is bounded by the slot register width; the
+ // truncation is benign (bank selects wrap by `% count` anyway).
+ let last = ((prg_rom.len() / PRG_BANK_4K).max(1) - 1) as u8;
+ let mut prg_slots = [0u8; 8];
+ prg_slots[7] = last;
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_slots,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Inl31 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The latch window lives at $5FF8-$5FFF (write-only); reads there fall
+ // through to open bus, so the default `cpu_read_unmapped` is correct.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_4K).max(1);
+ let slot = ((addr >> 12) & 0x07) as usize;
+ let bank = (self.prg_slots[slot] as usize) % count;
+ self.prg_rom[bank * PRG_BANK_4K + (addr as usize & 0x0FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x5FF8..=0x5FFF).contains(&addr) {
+ self.prg_slots[(addr & 0x07) as usize] = value;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + 8 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.prg_slots);
+ 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 = 1 + 8 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_slots.copy_from_slice(&data[1..9]);
+ let mut cursor = 9;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Custom mirroring/CHR-source mode for mapper 218 ("Magic Floor").
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum MagicFloorMode {
+ Vertical,
+ Horizontal,
+ ScreenA,
+ ScreenB,
+}
+
+impl MagicFloorMode {
+ /// Resolve a logical 1 KiB block index (0..=3) to a physical CIRAM 1 KiB
+ /// bank (0 or 1). Matches `GeraNES` `customMirroring`.
+ const fn physical_bank(self, block: u8) -> usize {
+ match self {
+ Self::Vertical => (block & 0x01) as usize,
+ Self::Horizontal => ((block >> 1) & 0x01) as usize,
+ Self::ScreenA => 0,
+ Self::ScreenB => 1,
+ }
+ }
+}
+
+/// Mapper 218 ("Magic Floor").
+pub struct MagicFloor218 {
+ prg_rom: Box<[u8]>,
+ /// 2 KiB CIRAM serving both the pattern table and nametables.
+ ciram: Box<[u8]>,
+ mode: MagicFloorMode,
+}
+
+impl MagicFloor218 {
+ /// Construct a new mapper 218 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB. Any supplied CHR-ROM is rejected (the board has none). Real Magic
+ /// Floor dumps are 16 KiB (NROM-128-style, mirrored across the 32 KiB CPU
+ /// window); a 32 KiB image is also accepted.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 218 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.is_empty() {
+ return Err(MapperError::Invalid(format!(
+ "mapper 218 has no CHR-ROM (CIRAM is used as CHR); got {} bytes",
+ chr_rom.len()
+ )));
+ }
+ // The four screen modes come from the cart's mirroring + four-screen
+ // wiring. Without a four-screen flag we use vertical / horizontal;
+ // the single-screen modes are reachable from those header values.
+ let mode = match mirroring {
+ Mirroring::Vertical | Mirroring::FourScreen => MagicFloorMode::Vertical,
+ Mirroring::SingleScreenA => MagicFloorMode::ScreenA,
+ Mirroring::SingleScreenB => MagicFloorMode::ScreenB,
+ Mirroring::Horizontal | Mirroring::MapperControlled => MagicFloorMode::Horizontal,
+ };
+ Ok(Self {
+ prg_rom,
+ ciram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mode,
+ })
+ }
+
+ /// Map a $0000-$1FFF pattern-table address into the 2 KiB CIRAM, treating
+ /// the 8 KiB pattern space as four 1 KiB blocks under the custom mirroring.
+ const fn chr_offset(&self, addr: u16) -> usize {
+ let block = ((addr >> 10) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ self.mode.physical_bank(block) * NAMETABLE_SIZE + local
+ }
+
+ const fn nt_offset(&self, addr: u16) -> usize {
+ let block = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ self.mode.physical_bank(block) * NAMETABLE_SIZE + local
+ }
+}
+
+impl Mapper for MagicFloor218 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Mirror the PRG across the 32 KiB window: a 16 KiB image
+ // (NROM-128-style) repeats, a 32 KiB image maps 1:1.
+ self.prg_rom[(addr as usize - 0x8000) % self.prg_rom.len()]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, _addr: u16, _value: u8) {}
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.ciram[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = self.nt_offset(addr);
+ self.ciram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn nametable_fetch(&mut self, addr: u16) -> Option {
+ Some(self.ciram[self.nt_offset(addr)])
+ }
+
+ fn nametable_write(&mut self, addr: u16, value: u8) -> bool {
+ let off = self.nt_offset(addr);
+ self.ciram[off] = value;
+ true
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.mode {
+ MagicFloorMode::Vertical => Mirroring::Vertical,
+ MagicFloorMode::Horizontal => Mirroring::Horizontal,
+ MagicFloorMode::ScreenA => Mirroring::SingleScreenA,
+ MagicFloorMode::ScreenB => Mirroring::SingleScreenB,
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + self.ciram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.ciram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 1 + self.ciram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.ciram.copy_from_slice(&data[1..=self.ciram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 29 — Sealie RET-CUFROM homebrew.
+//
+// $8000-$FFFF latch: CHR (8 KiB RAM) bank = data & 0x03; PRG (16 KiB) bank =
+// (data >> 2) & 0x07. $8000 reads the selected 16 KiB bank; $C000 is fixed to
+// the last 16 KiB bank. CHR is 8 KiB RAM (32 KiB on the board, but the visible
+// window is 8 KiB selected by the 2-bit CHR bank). Mirroring header-fixed.
+// ===========================================================================
+
+/// Mapper 29 (Sealie `RET-CUFROM`).
+pub struct Cufrom29 {
+ prg_rom: Box<[u8]>,
+ /// 32 KiB CHR-RAM (four 8 KiB banks).
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Cufrom29 {
+ /// Construct a new mapper 29 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 29 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; 4 * CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_ram.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + (addr as usize & 0x1FFF)
+ }
+}
+
+impl Mapper for Cufrom29 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ 0xC000..=0xFFFF => {
+ let last = (self.prg_rom.len() / PRG_BANK_16K).max(1) - 1;
+ self.prg_rom[last * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.chr_bank = value & 0x03;
+ self.prg_bank = (value >> 2) & 0x07;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_ram[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ 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(self.prg_bank);
+ out.push(self.chr_bank);
+ 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::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 111 (`GTROM`/Cheapocabra).
+pub struct Gtrom111 {
+ prg_rom: Box<[u8]>,
+ /// 16 KiB CHR-RAM: two 8 KiB banks.
+ chr_ram: Box<[u8]>,
+ /// 8 KiB nametable RAM: two banks of four 1 KiB screens.
+ nt_ram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ nt_bank: u8,
+}
+
+impl Gtrom111 {
+ /// Construct a new mapper 111 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB.
+ 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 111 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; 2 * CHR_BANK_8K].into_boxed_slice(),
+ nt_ram: vec![0u8; 2 * 4 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ nt_bank: 0,
+ })
+ }
+
+ #[allow(clippy::cast_possible_truncation)]
+ fn update_register(&mut self, value: u8) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ // `(value & 0x0F) % count` < 16, so the cast cannot truncate.
+ self.prg_bank = ((value & 0x0F) as usize % count) as u8;
+ self.chr_bank = (value >> 4) & 0x01;
+ self.nt_bank = (value >> 5) & 0x01;
+ }
+
+ const fn chr_offset(&self, addr: u16) -> usize {
+ (self.chr_bank as usize) * CHR_BANK_8K + (addr as usize & 0x1FFF)
+ }
+
+ const fn nt_offset(&self, addr: u16) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as usize;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ (self.nt_bank as usize) * 4 * NAMETABLE_SIZE + table * NAMETABLE_SIZE + local
+ }
+}
+
+impl Mapper for Gtrom111 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_rom[(self.prg_bank as usize) * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // The register decodes anywhere in the $5000-$7FFF window.
+ if (0x5000..=0x7FFF).contains(&addr) {
+ self.update_register(value);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_ram[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = self.nt_offset(addr);
+ self.nt_ram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn nametable_fetch(&mut self, addr: u16) -> Option {
+ Some(self.nt_ram[self.nt_offset(addr)])
+ }
+
+ fn nametable_write(&mut self, addr: u16, value: u8) -> bool {
+ let off = self.nt_offset(addr);
+ self.nt_ram[off] = value;
+ true
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ Mirroring::FourScreen
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.chr_ram.len() + self.nt_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.push(self.nt_bank);
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&self.nt_ram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 4 + self.chr_ram.len() + self.nt_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.nt_bank = data[3];
+ let mut cursor = 4;
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ cursor += self.chr_ram.len();
+ self.nt_ram
+ .copy_from_slice(&data[cursor..cursor + self.nt_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 28 (Action 53 homebrew multicart).
+pub struct Action53M28 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg_select: u8,
+ chr_reg: u8,
+ inner_prg: u8,
+ mode: u8,
+ outer_prg: u8,
+}
+
+impl Action53M28 {
+ /// Construct a new mapper 28 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ _mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 28 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg_select: 0,
+ chr_reg: 0,
+ inner_prg: 0,
+ mode: 0,
+ outer_prg: 0,
+ })
+ }
+
+ /// Resolve the 16 KiB PRG bank serving a CPU address in $8000-$FFFF.
+ fn prg_bank_for(&self, addr: u16) -> usize {
+ let count16 = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ // The outer bank is shifted left by the size mask (bits 4-5 of mode).
+ let size = (self.mode >> 4) & 0x03;
+ let outer = (self.outer_prg as usize) << (size + 1);
+ let prg_mode = (self.mode >> 2) & 0x03;
+ let high = addr >= 0xC000;
+ let inner = self.inner_prg as usize;
+ let bank = match prg_mode {
+ // NROM-256: a 32 KiB bank; the high half is +1.
+ 0 | 1 => (outer & !1) | usize::from(high),
+ // UNROM: low half selectable, high half fixed to the outer top.
+ 2 => {
+ if high {
+ outer | 0x01
+ } else {
+ (outer & !1) | (inner & 0x01)
+ }
+ }
+ // NROM-128: both halves are the same 16 KiB bank.
+ _ => outer | (inner & 0x01),
+ };
+ bank % count16
+ }
+}
+
+impl Mapper for Action53M28 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let bank = self.prg_bank_for(addr);
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x5000..=0x5FFF => self.reg_select = value & 0x81,
+ 0x8000..=0xFFFF => match self.reg_select {
+ 0x00 => self.chr_reg = value,
+ 0x01 => self.inner_prg = value,
+ 0x80 => self.mode = value,
+ _ => self.outer_prg = value,
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.mode & 0x03 {
+ 0 => Mirroring::SingleScreenA,
+ 1 => Mirroring::SingleScreenB,
+ 2 => Mirroring::Vertical,
+ _ => Mirroring::Horizontal,
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(6 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg_select);
+ out.push(self.chr_reg);
+ out.push(self.inner_prg);
+ out.push(self.mode);
+ out.push(self.outer_prg);
+ 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 = 6 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg_select = data[1];
+ self.chr_reg = data[2];
+ self.inner_prg = data[3];
+ self.mode = data[4];
+ self.outer_prg = data[5];
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 30 — UNROM-512 (RetroUSB / InfiniteNESLives / Broke Studio).
+//
+// A single latch register decodes as `[N CC P PPPP]`: bits 0-4 = 16 KiB PRG
+// bank at $8000, bits 5-6 = 8 KiB CHR-RAM bank, bit 7 = nametable select
+// (only when the cart is wired for software-controlled mirroring). $C000 is
+// fixed to the last 16 KiB bank. CHR is 32 KiB RAM (carts with no CHR-ROM)
+// or, for the converted Waixing `.WXN` dumps, CHR-ROM. No IRQ.
+//
+// Submapper / battery semantics (NESdev "UNROM 512", verified against the
+// Mesen2 `UnRom512` board):
+//
+// * Submapper 0 *without* the battery bit, or submapper 2: the latch
+// responds to the whole $8000-$FFFF range and the board has BUS CONFLICTS
+// (the written value is ANDed with the PRG byte at that address).
+// * Submapper 0 *with* the battery bit, or submappers 1/3/4: NO bus
+// conflicts; the latch responds only to $C000-$FFFF (A14 high) and
+// $8000-$BFFF is the flash-write window (a write there does NOT bank-switch
+// — we accept it but do not model the SST39SF040 flash chip, so reads of
+// that window return PRG-ROM and self-flashing persistence is a no-op).
+//
+// The battery bit, not a save-RAM presence, is what selects the no-bus-conflict
+// wiring on iNES (submapper 0). Self-flashing homebrew such as *Wampus* and the
+// *PROTO DERE .NES* beta set it; applying bus conflicts to those carts ANDs the
+// boot-time bank-switch value with ROM and jumps the CPU into garbage (a solid
+// backdrop frame). See `docs/mappers.md`.
+//
+// Nametable arrangement bits in iNES byte 6 (`%....N..M`, N = bit 3 = the
+// four-screen flag, M = bit 0). UNROM-512 uses the *standard* iNES byte-6
+// convention (no inversion) — verified against Mesen2 `UnRom512::InitMapper`,
+// which decodes `Byte6 & 0x09`:
+//
+// * `00` (N=0,M=0) -> Horizontal mirroring (the wiki's "vertical arrangement").
+// * `01` (N=0,M=1) -> Vertical mirroring (the wiki's "horizontal arrangement").
+// * `10` (N=1,M=0) -> 1-screen, software-switchable A/B via latch bit 7.
+// * `11` (N=1,M=1) -> 4-screen, cartridge VRAM (last 8 KiB of CHR-RAM; latch
+// bit 7 is inert for mirroring here, per Mesen2).
+//
+// The wiki phrases the M bit in *arrangement* terms ("vertical arrangement" =
+// horizontal mirroring); this codebase's `Mirroring` enum is in *mirroring*
+// terms, so M=1 -> `Mirroring::Vertical`. That matches both Mesen2 and the
+// generic header parser (`header.rs`: `byte6 bit0 -> Vertical`). The raw flags
+// are still threaded through the constructor so the 1-screen / 4-screen N=1
+// wirings (which the generic parser collapses) can be reconstructed precisely.
+// ===========================================================================
+
+/// Per-board nametable wiring resolved from the iNES header for mapper 30.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum M30Nametable {
+ /// Hard-wired horizontal mirroring.
+ Horizontal,
+ /// Hard-wired vertical mirroring.
+ Vertical,
+ /// Submapper 3: latch bit 7 selects horizontal vs vertical mirroring at
+ /// runtime (Mesen2 `UnRom512`: `value & 0x80 ? Vertical : Horizontal`).
+ SwitchableHv,
+ /// Software-switchable single-screen (latch bit 7 picks A/B).
+ OneScreen,
+ /// Four-screen, cartridge VRAM. On real hardware (the `InfiniteNESLives`
+ /// variant) the four nametables come from the last 8 KiB of the 32 KiB
+ /// CHR-RAM, not a separate 4 KiB VRAM chip. We allocate only the standard
+ /// 2 KiB CIRAM, so this is approximated as single-screen rather than true
+ /// 4-screen — an honest `BestEffort` limitation, not a true 4-screen claim.
+ /// No game in the corpus exercises it; revisit if one appears.
+ FourScreen,
+}
+
+/// Mapper 30 (`UNROM-512`).
+///
+/// The four booleans mirror distinct iNES-header-derived wirings (CHR-ROM vs
+/// RAM, the latch nametable bit, bus-conflict presence, and the flash-window
+/// banking mode), so they don't fold into an enum without losing fidelity.
+#[allow(clippy::struct_excessive_bools)]
+pub struct Unrom512M30 {
+ prg_rom: Box<[u8]>,
+ /// CHR storage: 32 KiB RAM by default, or CHR-ROM for `.WXN` conversions.
+ chr: Box<[u8]>,
+ /// True when `chr` is read-only ROM (no PPU writes land).
+ chr_is_rom: bool,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ /// Latch bit 7 (software nametable select), only meaningful for the
+ /// 1-screen / 4-screen wirings.
+ nt_bit: bool,
+ nametable: M30Nametable,
+ /// True when the board has bus conflicts (submapper 0 w/o battery, or 2).
+ bus_conflicts: bool,
+ /// 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,
+}
+
+impl Unrom512M30 {
+ /// Construct a new mapper 30 board.
+ ///
+ /// `four_screen` is iNES byte-6 bit 3, `vertical` is byte-6 bit 0 (the raw
+ /// flags, before the generic parser's standard-convention mapping). The
+ /// `submapper` and `has_battery` flags select the bus-conflict / flash
+ /// wiring per the nesdev-wiki `UNROM 512` submapper table.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: &[u8],
+ four_screen: bool,
+ vertical: bool,
+ submapper: u8,
+ has_battery: bool,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 30 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+
+ // Nametable wiring. Submapper 3 = runtime mapper-controlled H/V select
+ // (latch bit 7); power-on default is Vertical (matching Mesen2).
+ // Otherwise the byte-6 N/M bits select the four configurations.
+ let nametable = if submapper == 3 {
+ M30Nametable::SwitchableHv
+ } else if four_screen && vertical {
+ M30Nametable::FourScreen
+ } else if four_screen {
+ M30Nametable::OneScreen
+ } else if vertical {
+ M30Nametable::Vertical
+ } else {
+ M30Nametable::Horizontal
+ };
+
+ // Bus conflicts / flash wiring per submapper + battery bit.
+ let bus_conflicts = (submapper == 0 && !has_battery) || submapper == 2;
+ let flash_window = (submapper == 0 && has_battery) || matches!(submapper, 1 | 3 | 4);
+
+ // CHR: prefer CHR-ROM when the dump carries it (e.g. the converted
+ // `.WXN` Waixing carts); otherwise the standard 32 KiB CHR-RAM.
+ let (chr, chr_is_rom) = if chr_rom.is_empty() {
+ (vec![0u8; 4 * CHR_BANK_8K].into_boxed_slice(), false)
+ } else {
+ (chr_rom.to_vec().into_boxed_slice(), true)
+ };
+
+ // Power-on `nt_bit`: for submapper 3 the board defaults to Vertical
+ // (Mesen2 `UnRom512::InitMapper`), and `current_mirroring()` maps a set
+ // bit to Vertical, so seed it `true` to match that default before the
+ // first latch write. For every other wiring the bit only matters for
+ // the single-screen case, whose A/B default is `false` (ScreenA).
+ let nt_bit = nametable == M30Nametable::SwitchableHv;
+
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ nt_bit,
+ nametable,
+ bus_conflicts,
+ flash_window,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + (addr as usize & 0x1FFF)
+ }
+
+ /// Apply a write to the banking latch (already known to target the latch).
+ fn write_latch(&mut self, addr: u16, value: u8) {
+ let effective = if self.bus_conflicts {
+ // Bus conflict: AND with the PRG byte actually driving the bus at the
+ // write address. The switchable bank serves $8000-$BFFF; the FIXED
+ // last 16 KiB bank serves $C000-$FFFF, so a write there conflicts
+ // with the fixed bank, not the currently-selected low bank (matches
+ // Mesen2's address-based `BaseMapper` conflict resolution).
+ let conflict_bank = if addr >= 0xC000 {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1) - 1
+ } else {
+ self.prg_bank as usize
+ };
+ value & self.read_prg(conflict_bank, addr)
+ } else {
+ value
+ };
+ self.prg_bank = effective & 0x1F;
+ self.chr_bank = (effective >> 5) & 0x03;
+ self.nt_bit = (effective & 0x80) != 0;
+ }
+}
+
+impl Mapper for Unrom512M30 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(self.prg_bank as usize, addr),
+ 0xC000..=0xFFFF => {
+ let last = (self.prg_rom.len() / PRG_BANK_16K).max(1) - 1;
+ self.read_prg(last, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return;
+ }
+ if self.flash_window {
+ // No-bus-conflict wiring: the banking latch lives at $C000-$FFFF;
+ // $8000-$BFFF is the SST39SF040 flash-command window (not modelled
+ // — accepted as a no-op so self-flashing writes don't bank-switch).
+ if addr >= 0xC000 {
+ self.write_latch(addr, value);
+ }
+ } else {
+ // Submapper 0 w/o battery or submapper 2: the latch responds to the
+ // whole $8000-$FFFF range, with bus conflicts.
+ self.write_latch(addr, value);
+ }
+ }
+
+ 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[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if !self.chr_is_rom {
+ let off = self.chr_offset(addr);
+ self.chr[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.nametable {
+ M30Nametable::Horizontal => Mirroring::Horizontal,
+ M30Nametable::Vertical => Mirroring::Vertical,
+ // Submapper 3: latch bit 7 picks vertical (set) vs horizontal
+ // (clear) at runtime (Mesen2 `value & 0x80 ? Vertical : Horizontal`).
+ M30Nametable::SwitchableHv => {
+ if self.nt_bit {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ }
+ }
+ // Software-switchable single-screen: latch bit 7 selects which CIRAM
+ // half (A10=0 lower, A10=1 upper). The 4-screen wiring routes its
+ // nametables through cartridge CHR-RAM, so the mapper still reports a
+ // single-screen base here; the bit is otherwise inert for it.
+ M30Nametable::OneScreen | M30Nametable::FourScreen => {
+ if self.nt_bit {
+ Mirroring::SingleScreenB
+ } else {
+ Mirroring::SingleScreenA
+ }
+ }
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_len = if self.chr_is_rom { 0 } else { self.chr.len() };
+ let mut out = Vec::with_capacity(4 + self.vram.len() + chr_len);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.push(u8::from(self.nt_bit));
+ out.extend_from_slice(&self.vram);
+ // CHR-ROM is immutable; only persist CHR-RAM contents.
+ if !self.chr_is_rom {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_len = if self.chr_is_rom { 0 } else { self.chr.len() };
+ let expected = 4 + self.vram.len() + chr_len;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ // Mask the register indices to their live-invariant widths so a
+ // corrupted / hand-edited save-state can't seed an out-of-range value
+ // (mirrors the write-latch masks; same defensive treatment as the
+ // JY-ASIC `chr_latch` clamp in `m035_jy_asic.rs`). The read paths already
+ // wrap with `% count`, so this is belt-and-suspenders, not a panic fix.
+ self.prg_bank = data[1] & 0x1F;
+ self.chr_bank = data[2] & 0x03;
+ self.nt_bit = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if !self.chr_is_rom {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_4k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_4K];
+ for b in 0..banks {
+ v[b * PRG_BANK_4K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m31_slots_latch_per_window() {
+ let mut m = Inl31::new(synth_prg_4k(8), &[], Mirroring::Vertical).unwrap();
+ // Slot 0 ($8000) <- bank 3; slot 7 ($F000) <- bank 5.
+ m.cpu_write(0x5FF8, 3);
+ m.cpu_write(0x5FFF, 5);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xF000), 5);
+ // Untouched slot 1 ($9000) stays at power-on 0.
+ assert_eq!(m.cpu_read(0x9000), 0);
+ }
+
+ #[test]
+ fn m31_save_state_round_trip() {
+ let mut m = Inl31::new(synth_prg_4k(8), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x5FF8, 2);
+ m.ppu_write(0x0001, 0xCD);
+ let blob = m.save_state();
+ let mut m2 = Inl31::new(synth_prg_4k(8), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 2);
+ assert_eq!(m2.ppu_read(0x0001), 0xCD);
+ }
+
+ #[test]
+ fn m218_ciram_serves_chr_and_nametable() {
+ let mut m = MagicFloor218::new(synth_prg_32k(1), &[], Mirroring::Vertical).unwrap();
+ // Vertical: pattern block 0 -> physical bank 0; block 1 -> bank 1.
+ // Write CHR at $0000 (block 0) and a nametable at $2400 (table 1).
+ m.ppu_write(0x0000, 0x11);
+ m.ppu_write(0x2400, 0x22);
+ // $2400 = table 1 -> physical bank 1; $0400 = pattern block 1 -> bank 1.
+ assert_eq!(m.ppu_read(0x0400), 0x22);
+ // $2000 = table 0 -> bank 0 = the CHR byte written at $0000.
+ assert_eq!(m.ppu_read(0x2000), 0x11);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m218_accepts_16k_prg_and_mirrors_it() {
+ // Real Magic Floor dumps are 16 KiB (NROM-128-style). The board must
+ // accept them and mirror PRG across the full 32 KiB CPU window.
+ let mut prg = synth_prg_16k(1);
+ prg[0] = 0xAB; // marker at the start of the 16 KiB image
+ let mut m = MagicFloor218::new(prg, &[], Mirroring::Horizontal).unwrap();
+ // $8000 and the mirror at $C000 both read the same byte.
+ assert_eq!(m.cpu_read(0x8000), 0xAB);
+ assert_eq!(m.cpu_read(0xC000), 0xAB);
+ }
+
+ #[test]
+ fn m218_save_state_round_trip() {
+ let mut m = MagicFloor218::new(synth_prg_32k(1), &[], Mirroring::Horizontal).unwrap();
+ m.ppu_write(0x0005, 0x42);
+ let blob = m.save_state();
+ let mut m2 = MagicFloor218::new(synth_prg_32k(1), &[], Mirroring::Horizontal).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0005), 0x42);
+ }
+
+ #[test]
+ fn m29_latch_selects_prg_and_chr_bank() {
+ let mut m = Cufrom29::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ // value: CHR = data&3, PRG = (data>>2)&7. 0b0001_0110 = 0x16:
+ // CHR = 0b10 = 2, PRG = 0b101 = 5.
+ m.cpu_write(0x8000, 0b0001_0110);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ // $C000 is fixed to the last 16 KiB bank (7).
+ assert_eq!(m.cpu_read(0xC000), 7);
+ // CHR-RAM round-trip in the selected (bank 2) window.
+ m.ppu_write(0x0003, 0x77);
+ assert_eq!(m.ppu_read(0x0003), 0x77);
+ }
+
+ #[test]
+ fn m29_save_state_round_trip() {
+ let mut m = Cufrom29::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0b0000_1101); // CHR 1, PRG 3
+ m.ppu_write(0x0007, 0x55);
+ let blob = m.save_state();
+ let mut m2 = Cufrom29::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 3);
+ assert_eq!(m2.ppu_read(0x0007), 0x55);
+ }
+
+ #[test]
+ fn m111_register_selects_prg_chr_nt() {
+ let mut m = Gtrom111::new(synth_prg_32k(8), &[]).unwrap();
+ // value 0b0011_0101 (0x35): PRG = 5; CHR = (v>>4)&1 = 1; NT = (v>>5)&1 = 1.
+ m.cpu_write(0x5000, 0b0011_0101);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ // CHR bank 1 round-trip.
+ m.ppu_write(0x0000, 0x88);
+ assert_eq!(m.ppu_read(0x0000), 0x88);
+ // Nametable bank 1 round-trip via the fetch hook.
+ assert!(m.nametable_write(0x2000, 0x99));
+ assert_eq!(m.nametable_fetch(0x2000), Some(0x99));
+ assert_eq!(m.current_mirroring(), Mirroring::FourScreen);
+ // Switching nt bank to 0 hides the byte written under bank 1.
+ m.cpu_write(0x5000, 0x00);
+ assert_eq!(m.nametable_fetch(0x2000), Some(0x00));
+ }
+
+ #[test]
+ fn m111_save_state_round_trip() {
+ let mut m = Gtrom111::new(synth_prg_32k(8), &[]).unwrap();
+ m.cpu_write(0x5000, 0b0011_0011); // PRG 3, CHR 1, NT 1
+ m.ppu_write(0x0001, 0xAA);
+ m.nametable_write(0x2001, 0xBB);
+ let blob = m.save_state();
+ let mut m2 = Gtrom111::new(synth_prg_32k(8), &[]).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 3);
+ assert_eq!(m2.ppu_read(0x0001), 0xAA);
+ assert_eq!(m2.nametable_fetch(0x2001), Some(0xBB));
+ }
+
+ #[test]
+ fn m28_nrom128_mode_mirrors_one_bank() {
+ let mut m = Action53M28::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ // mode reg: select reg 0x80, write PRG mode 3 (NROM-128), mirroring V (2),
+ // size mask 0.
+ m.cpu_write(0x5000, 0x80);
+ m.cpu_write(0x8000, 0b0000_1110); // mode bits 2-3 = 3, mirroring bits 0-1 = 2
+ // inner reg
+ m.cpu_write(0x5000, 0x01);
+ m.cpu_write(0x8000, 0x01); // inner = 1
+ // outer reg
+ m.cpu_write(0x5000, 0x81);
+ m.cpu_write(0x8000, 0x02); // outer = 2
+ // size mask (mode bits 4-5) = 0 -> outer is shifted left by (size+1)=1,
+ // so outer = 2<<1 = 4. NROM-128 mode: both halves = outer|(inner&1)
+ // = 4|1 = 5.
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m28_save_state_round_trip() {
+ let mut m = Action53M28::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ // Set NROM-128 mode (mode bits 2-3 = 3, mirroring bits 0-1 = 2).
+ m.cpu_write(0x5000, 0x80);
+ m.cpu_write(0x8000, 0x0E);
+ // Set outer = 1.
+ m.cpu_write(0x5000, 0x81);
+ m.cpu_write(0x8000, 0x01);
+ m.ppu_write(0x0007, 0x5A);
+ let resolved = m.cpu_read(0x8000);
+ let blob = m.save_state();
+ let mut m2 = Action53M28::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0007), 0x5A);
+ assert_eq!(m2.cpu_read(0x8000), resolved);
+ assert_eq!(m2.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m30_latch_selects_prg_chr_and_fixed_high() {
+ // Submapper 0 without battery -> bus conflicts on $8000-$FFFF.
+ let mut m = Unrom512M30::new(synth_prg_16k(8), &[], false, true, 0, false).unwrap();
+ // PRG bits 0-4 = 3, CHR bits 5-6 = 1. value = 0b0010_0011 = 0x23.
+ // Offset 1 (no marker, 0xFF) -> bus conflict harmless.
+ m.cpu_write(0x8001, 0x23);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // $C000 fixed to last (7).
+ assert_eq!(m.cpu_read(0xC000), 7);
+ // CHR bank 1.
+ m.ppu_write(0x0000, 0xEE);
+ assert_eq!(m.ppu_read(0x0000), 0xEE);
+ }
+
+ #[test]
+ fn m30_battery_cart_no_bus_conflict_high_window_only() {
+ // Submapper 0 WITH battery (e.g. Wampus / PROTO DERE): no bus conflicts;
+ // the banking latch responds only to $C000-$FFFF, and $8000-$BFFF is
+ // the (un-modelled) flash window that must NOT bank-switch.
+ let mut m = Unrom512M30::new(synth_prg_16k(8), &[], false, true, 0, true).unwrap();
+ // A write to the flash window leaves the bank untouched (still 0).
+ m.cpu_write(0x8000, 0x05);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ // A write to $C000-$FFFF switches the bank with NO bus-conflict AND.
+ // Bank 5 even though the PRG byte read there (the bank index) differs.
+ m.cpu_write(0xC000, 0x05);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ }
+
+ #[test]
+ fn m30_save_state_round_trip() {
+ let mut m = Unrom512M30::new(synth_prg_16k(8), &[], false, true, 0, false).unwrap();
+ m.cpu_write(0x8001, 0x45);
+ m.ppu_write(0x0003, 0x77);
+ let blob = m.save_state();
+ let mut m2 = Unrom512M30::new(synth_prg_16k(8), &[], false, true, 0, false).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0003), 0x77);
+ }
+
+ #[test]
+ fn m30_header_mirroring_matches_mesen2() {
+ // byte6 N/M decode, mirroring vocabulary (Mesen2 `UnRom512`):
+ // 00 (four_screen=0, vertical=0) -> Horizontal mirroring
+ // 01 (four_screen=0, vertical=1) -> Vertical mirroring
+ // No latch write needed; this is the hard-wired arrangement.
+ let m_h = Unrom512M30::new(synth_prg_16k(2), &[], false, false, 0, false).unwrap();
+ assert_eq!(m_h.current_mirroring(), Mirroring::Horizontal);
+ let m_v = Unrom512M30::new(synth_prg_16k(2), &[], false, true, 0, false).unwrap();
+ assert_eq!(m_v.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m30_submapper3_runtime_hv_switch() {
+ // Submapper 3: latch bit 7 flips H/V at runtime; power-on default is
+ // Vertical (Mesen2). No bus conflicts (flash wiring), latch at $C000+.
+ let mut m = Unrom512M30::new(synth_prg_16k(8), &[], false, false, 3, false).unwrap();
+ assert_eq!(
+ m.current_mirroring(),
+ Mirroring::Vertical,
+ "power-on default"
+ );
+ // Clear bit 7 -> Horizontal.
+ m.cpu_write(0xC000, 0x00);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // Set bit 7 -> Vertical.
+ m.cpu_write(0xC000, 0x80);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m30_bus_conflict_high_window_uses_fixed_bank() {
+ // Bus-conflict cart (submapper 0, no battery): the latch responds across
+ // the whole $8000-$FFFF. A $C000-$FFFF write ANDs against the FIXED last
+ // bank's byte, NOT the currently-selected low bank. In an 8-bank
+ // `synth_prg_16k` ROM, bank b holds `b` at offset 0 and `0xFF` elsewhere.
+ let mut m = Unrom512M30::new(synth_prg_16k(8), &[], false, true, 0, false).unwrap();
+ // Seed the low bank to 2 via a write at offset 1 (current low bank 0,
+ // byte 1 = 0xFF, so the value passes through unmasked).
+ m.cpu_write(0x8001, 0x02);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // Write 0x1F at $C000 (offset 0). The AND source is the FIXED bank 7
+ // (byte 0 = 0x07): 0x1F & 0x07 = 0x07 -> low bank becomes 7. The old
+ // (buggy) behaviour would source the now-bank-2 low window (byte 0 =
+ // 0x02): 0x1F & 0x02 = 0x02 -> bank 2. Asserting 7 proves the fix.
+ m.cpu_write(0xC000, 0x1F);
+ assert_eq!(m.cpu_read(0x8000), 7);
+ }
+}
diff --git a/crates/rustynes-mappers/src/jaleco_discrete.rs b/crates/rustynes-mappers/src/jaleco_discrete.rs
new file mode 100644
index 00000000..e7b94cb5
--- /dev/null
+++ b/crates/rustynes-mappers/src/jaleco_discrete.rs
@@ -0,0 +1,858 @@
+//! Jaleco `JF-13` (mapper 86) and `JF-11` / `JF-14` (mapper 140) -- discrete
+//! PRG/CHR latch boards.
+//!
+//! Both put their single bank-select latch in the PRG-RAM window rather than
+//! at `$8000`: `$6000-$6FFF` for mapper 86, the full `$6000-$7FFF` for
+//! mapper 140.
+//!
+//! Jaleco's larger ASICs live in `m087_jaleco87.rs` and `m018_jaleco_ss88006.rs`.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 86 (Jaleco `JF-13`).
+pub struct Jaleco86 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Jaleco86 {
+ /// Construct a new mapper 86 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 86 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 86 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Jaleco86 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // Bank register at $6000-$6FFF only; $7000-$7FFF is the ADPCM port.
+ if (0x6000..=0x6FFF).contains(&addr) {
+ self.prg_bank = (value >> 4) & 0x03;
+ self.chr_bank = (value & 0x03) | ((value >> 4) & 0x04);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 140 — Jaleco JF-11 / JF-14.
+//
+// Single latch across the whole $6000-$7FFF window. The byte PPPP_CCCC selects:
+// PRG = (value >> 4) & 0x03 (32 KiB)
+// CHR = value & 0x0F (8 KiB, 4-bit)
+// Mirroring is header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 140 (Jaleco `JF-11`/`JF-14`).
+pub struct Jaleco140 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Jaleco140 {
+ /// Construct a new mapper 140 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 140 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 140 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Jaleco140 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x6000..=0x7FFF).contains(&addr) {
+ self.prg_bank = (value >> 4) & 0x03;
+ self.chr_bank = value & 0x0F;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Shared register/strobe state for the Jaleco JF-17/19 family (mappers 72/92).
+// The four flags each model a distinct hardware signal (CHR-RAM presence, the
+// two edge-triggered latch strobes, and the JF-17-vs-JF-19 PRG layout); they
+// are not a bitfield-able state and reading them as named bools is clearest.
+#[allow(clippy::struct_excessive_bools)]
+struct JalecoLatch {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ prev_prg_strobe: bool,
+ prev_chr_strobe: bool,
+ mirroring: Mirroring,
+ /// Mask applied to the PRG-bank nibble (0x0F for 72, 0x1F for 92).
+ prg_field_mask: u8,
+ /// PRG window layout. `false` (mapper 72, JF-17): switchable bank at
+ /// `$8000-$BFFF`, fixed LAST bank at `$C000-$FFFF`. `true` (mapper 92,
+ /// JF-19): fixed FIRST bank at `$8000-$BFFF`, switchable bank at
+ /// `$C000-$FFFF` — the reset vector lives in the fixed half, so this layout
+ /// is load-bearing for boot.
+ switchable_high: bool,
+}
+
+impl JalecoLatch {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ prg_field_mask: u8,
+ switchable_high: bool,
+ id: u16,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ prev_prg_strobe: false,
+ prev_chr_strobe: false,
+ mirroring,
+ prg_field_mask,
+ switchable_high,
+ })
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn read_prg_bank(&self, bank: usize, off: usize) -> u8 {
+ let bank = bank % self.prg_count_16k();
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+
+ fn cpu_read(&self, addr: u16) -> u8 {
+ let last = self.prg_count_16k() - 1;
+ match addr {
+ 0x8000..=0xBFFF => {
+ // JF-19 (mapper 92): fixed FIRST bank here. JF-17 (mapper 72):
+ // switchable bank here.
+ let bank = if self.switchable_high {
+ 0
+ } else {
+ self.prg_bank as usize
+ };
+ self.read_prg_bank(bank, addr as usize - 0x8000)
+ }
+ 0xC000..=0xFFFF => {
+ // JF-19: switchable bank here. JF-17: fixed LAST bank here.
+ let bank = if self.switchable_high {
+ self.prg_bank as usize
+ } else {
+ last
+ };
+ self.read_prg_bank(bank, addr as usize - 0xC000)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict: AND the written byte with the underlying PRG byte.
+ let effective = value & self.cpu_read(addr);
+ let prg_strobe = (effective & 0x80) != 0;
+ let chr_strobe = (effective & 0x40) != 0;
+ if prg_strobe && !self.prev_prg_strobe {
+ self.prg_bank = effective & self.prg_field_mask;
+ }
+ if chr_strobe && !self.prev_chr_strobe {
+ self.chr_bank = effective & 0x0F;
+ }
+ self.prev_prg_strobe = prg_strobe;
+ self.prev_chr_strobe = chr_strobe;
+ }
+ }
+
+ fn ppu_read(&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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(5 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.push(u8::from(self.prev_prg_strobe));
+ out.push(u8::from(self.prev_chr_strobe));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 5 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.prev_prg_strobe = data[3] != 0;
+ self.prev_chr_strobe = data[4] != 0;
+ let mut cursor = 5;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 72 (Jaleco `JF-17`/`JF-19`).
+pub struct Jaleco72 {
+ inner: JalecoLatch,
+}
+
+impl Jaleco72 {
+ /// Construct a new mapper 72 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ Ok(Self {
+ inner: JalecoLatch::new(prg_rom, chr_rom, mirroring, 0x0F, false, 72)?,
+ })
+ }
+}
+
+impl Mapper for Jaleco72 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ 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 ppu_read(&mut self, addr: u16) -> u8 {
+ self.inner.ppu_read(addr)
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ self.inner.ppu_write(addr, value);
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.inner.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ self.inner.save_state()
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ self.inner.load_state(data)
+ }
+}
+
+/// Mapper 92 (Jaleco `JF-19`-variant — like 72 with a 5-bit PRG field).
+pub struct Jaleco92 {
+ inner: JalecoLatch,
+}
+
+impl Jaleco92 {
+ /// Construct a new mapper 92 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ Ok(Self {
+ inner: JalecoLatch::new(prg_rom, chr_rom, mirroring, 0x1F, true, 92)?,
+ })
+ }
+}
+
+impl Mapper for Jaleco92 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ 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 ppu_read(&mut self, addr: u16) -> u8 {
+ self.inner.ppu_read(addr)
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ self.inner.ppu_write(addr, value);
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.inner.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ self.inner.save_state()
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ self.inner.load_state(data)
+ }
+}
+
+/// Mapper 101 (Jaleco `JF-10` CHR latch).
+pub struct Jaleco101 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Jaleco101 {
+ /// Construct a new mapper 101 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 101 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 101 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Jaleco101 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_rom[addr as usize - 0x8000]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // The CHR latch is in the PRG-RAM ($6000-$7FFF) window.
+ if (0x6000..=0x7FFF).contains(&addr) {
+ self.chr_bank = value;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 2 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.chr_bank = data[1];
+ self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ /// 16 KiB-banked PRG: byte 0 of each 16 KiB bank holds the bank index.
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m86_latch_selects_prg_and_chr() {
+ let mut m = Jaleco86::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // value VVdd_pPcc; PRG = (v>>4)&3, CHR = (v&3) | ((v>>4)&4).
+ // 0b0011_0010: PRG = (0b0011_0010>>4)&3 = 0b11&3 = 3.
+ // CHR = (0b10) | ((0b0011)&4 -> 0) = 2.
+ m.cpu_write(0x6000, 0b0011_0010);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.ppu_read(0x0000), 2);
+ // High CHR bit: value 0b0100_0001 -> CHR = 1 | ((0b0100)&4 = 4) = 5.
+ m.cpu_write(0x6000, 0b0100_0001);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // $7000 (ADPCM port) must NOT change banking.
+ m.cpu_write(0x7000, 0xFF);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ }
+
+ #[test]
+ fn m140_latch_selects_prg_and_chr() {
+ let mut m =
+ Jaleco140::new(synth_prg_32k(4), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // value PPPP_CCCC -> PRG = (v>>4)&3, CHR = v&0x0F.
+ m.cpu_write(0x6FFF, 0b0010_1010); // PRG 2, CHR 10
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.ppu_read(0x0000), 10);
+ // $7FFF is still in the $6000-$7FFF window.
+ m.cpu_write(0x7FFF, 0b0001_0011); // PRG 1, CHR 3
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ }
+
+ #[test]
+ fn m72_strobe_latches_on_rising_edge() {
+ let mut m = Jaleco72::new(synth_prg_16k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // PRG window offsets >0 hold 0xFF, so bus conflict is transparent there.
+ // Write to $8001 (byte 0xFF). PRG strobe (bit7) rising + bank 3.
+ m.cpu_write(0x8001, 0b1000_0011);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Last 16 KiB bank fixed at $C000: bank 7.
+ assert_eq!(m.cpu_read(0xC000), 7);
+ // CHR strobe (bit6) rising + bank 5.
+ m.cpu_write(0x8001, 0b0100_0101);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ }
+
+ #[test]
+ fn m72_no_relatch_without_falling_edge() {
+ let mut m = Jaleco72::new(synth_prg_16k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8001, 0b1000_0011); // latch PRG 3
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Strobe still high, new bank value -> must NOT re-latch.
+ m.cpu_write(0x8001, 0b1000_0101);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Drop strobe, then raise again -> re-latches.
+ m.cpu_write(0x8001, 0b0000_0000);
+ m.cpu_write(0x8001, 0b1000_0101);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ }
+
+ #[test]
+ fn m92_uses_5bit_prg_field() {
+ let mut m =
+ Jaleco92::new(synth_prg_16k(32), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // 5-bit PRG field: value 0b1001_0001 -> strobe + bank 0x11 = 17.
+ m.cpu_write(0x8001, 0b1001_0001);
+ // JF-19 layout: $8000 is the FIXED first bank (0); the switchable bank
+ // appears at $C000.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 17);
+ }
+
+ #[test]
+ fn m72_save_state_round_trips_strobe_state() {
+ let mut j = Jaleco72::new(synth_prg_16k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ j.cpu_write(0x8001, 0b1100_0011); // PRG 3 + CHR 3, both strobes high
+ let blob = j.save_state();
+ let mut j2 =
+ Jaleco72::new(synth_prg_16k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ j2.load_state(&blob).unwrap();
+ assert_eq!(j2.cpu_read(0x8000), 3);
+ assert_eq!(j2.ppu_read(0x0000), 3);
+ // Strobe still high after restore -> a same-value write must not relatch
+ // from a fresh edge.
+ j2.cpu_write(0x8001, 0b1100_0101);
+ assert_eq!(j2.cpu_read(0x8000), 3);
+ }
+
+ #[test]
+ fn m101_chr_latch_in_6000_window() {
+ let mut m =
+ Jaleco101::new(synth_prg_32k(1), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ m.cpu_write(0x6000, 5);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // PRG is fixed.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ }
+
+ #[test]
+ fn m101_save_state_round_trip() {
+ let mut m =
+ Jaleco101::new(synth_prg_32k(1), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ m.cpu_write(0x7FFF, 6);
+ let blob = m.save_state();
+ let mut m2 =
+ Jaleco101::new(synth_prg_32k(1), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0000), 6);
+ }
+}
diff --git a/crates/rustynes-mappers/src/kaiser.rs b/crates/rustynes-mappers/src/kaiser.rs
new file mode 100644
index 00000000..e43e481b
--- /dev/null
+++ b/crates/rustynes-mappers/src/kaiser.rs
@@ -0,0 +1,751 @@
+//! Kaiser boards: `KS202` (mapper 56), `KS7017` (142), `KS7031` (303),
+//! `KS7016` (305), `KS7013B` (306) and relatives.
+//!
+//! Kaiser's pirate boards are unusual for their size class in carrying real
+//! IRQ counters, and in disagreeing about direction: `KS202` counts *up* to a
+//! target while `KS7017` counts *down* to zero. `KS7031` is stranger still --
+//! it maps four independently-selected 2 KiB PRG windows, a granularity no
+//! licensed board uses.
+//!
+//! One [`KaiserMapper`] with a [`KaiserBoard`] discriminant rather than five
+//! types, because the boards share their register-file and save-state shape
+//! and differ only in decode and IRQ direction.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_2K: usize = 0x0800;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// Which Kaiser variant a [`KaiserMapper`] models.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum KaiserBoard {
+ /// Mapper 56 (KS202) — extra CHR + mirror writes; up-counting M2 IRQ.
+ M56,
+ /// Mapper 142 (KS7032) — like 56 without the extra CHR/mirror writes.
+ M142,
+ /// Mapper 303 (KS7017) — address-decoded PRG + down-counting M2 IRQ.
+ M303,
+ /// Mapper 305 (KS7031) — four 2 KiB $6000 PRG-ROM windows (no IRQ).
+ M305,
+ /// Mapper 306 (KS7016) — address-decoded $6000 PRG window (no IRQ).
+ M306,
+ /// Mapper 312 (KS7013B) — $6000 PRG select + $8000 mirroring (no IRQ).
+ M312,
+}
+
+/// A Kaiser FDS-conversion / window board.
+pub struct KaiserMapper {
+ board: KaiserBoard,
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ wram: Box<[u8]>,
+ mirroring: Mirroring,
+ // KS202/KS7032 (56/142).
+ prg_regs: [u8; 4],
+ selected_reg: u8,
+ use_rom: bool,
+ chr_banks: [u8; 8],
+ // KS7016 (306) PRG-ROM $6000 window.
+ win_6000: u8,
+ // KS7031 (305) four 2 KiB windows.
+ regs4: [u8; 4],
+ // 312 PRG select (16 KiB).
+ prg16: u8,
+ // IRQ (56/142 up-count, 303 down-count).
+ irq_counter: u16,
+ irq_reload: u16,
+ irq_enabled: bool,
+ irq_control: u8,
+ irq_pending: bool,
+}
+
+impl KaiserMapper {
+ const SAVE_LEN: usize = 4 + 1 + 1 + 8 + 1 + 4 + 1 + 2 + 2 + 1 + 1 + 1 + 1;
+
+ fn new(
+ board: KaiserBoard,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ id: u16,
+ ) -> Result {
+ check_prg(&prg_rom, id)?;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ Ok(Self {
+ board,
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ wram: vec![0u8; PRG_BANK_8K].into_boxed_slice(),
+ mirroring,
+ prg_regs: [0; 4],
+ selected_reg: 0,
+ use_rom: false,
+ chr_banks: [0; 8],
+ win_6000: 8,
+ regs4: [0; 4],
+ prg16: 0,
+ irq_counter: 0,
+ irq_reload: 0,
+ irq_enabled: false,
+ irq_control: 0,
+ irq_pending: false,
+ })
+ }
+
+ fn prg_count_8k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_8K).max(1)
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn prg_count_2k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_2K).max(1)
+ }
+
+ fn chr_count_1k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_1K).max(1)
+ }
+}
+
+impl Mapper for KaiserMapper {
+ fn caps(&self) -> MapperCaps {
+ match self.board {
+ KaiserBoard::M56 | KaiserBoard::M142 | KaiserBoard::M303 => MapperCaps::CYCLE_IRQ,
+ _ => MapperCaps::NONE,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match self.board {
+ KaiserBoard::M56 | KaiserBoard::M142 => match addr {
+ 0x6000..=0x7FFF => {
+ if self.use_rom {
+ let count = self.prg_count_8k();
+ let bank = (self.prg_regs[3] as usize) % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ } else {
+ self.wram[addr as usize & 0x1FFF]
+ }
+ }
+ 0x8000..=0xFFFF => {
+ let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
+ let count = self.prg_count_8k();
+ let bank = if slot == 3 {
+ count - 1
+ } else {
+ self.prg_regs[slot] as usize % count
+ };
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ },
+ KaiserBoard::M303 => match addr {
+ 0x4030 => {
+ let p = self.irq_pending;
+ self.irq_pending = false;
+ u8::from(p)
+ }
+ 0x8000..=0xBFFF => {
+ let count = self.prg_count_16k();
+ let bank = (self.prg16 as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ 0xC000..=0xFFFF => {
+ let count = self.prg_count_16k();
+ let bank = 2 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ _ => 0,
+ },
+ KaiserBoard::M305 => match addr {
+ 0x6000..=0x7FFF => {
+ let win = (addr as usize - 0x6000) / PRG_BANK_2K;
+ let count = self.prg_count_2k();
+ let bank = (self.regs4[win] as usize) % count;
+ self.prg_rom[bank * PRG_BANK_2K + (addr as usize & 0x7FF)]
+ }
+ 0x8000..=0xFFFF => {
+ // Fixed last 32 KiB (16 x 2 KiB windows = banks count-16..count-1).
+ let count = self.prg_count_2k();
+ let win = (addr as usize - 0x8000) / PRG_BANK_2K;
+ let bank = count.saturating_sub(16 - win) % count;
+ self.prg_rom[bank * PRG_BANK_2K + (addr as usize & 0x7FF)]
+ }
+ _ => 0,
+ },
+ KaiserBoard::M306 => match addr {
+ 0x6000..=0x7FFF => {
+ let count = self.prg_count_8k();
+ let bank = (self.win_6000 as usize) % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x8000..=0xFFFF => {
+ // Fixed last 32 KiB.
+ let count = self.prg_count_8k();
+ let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
+ let bank = count.saturating_sub(4 - slot) % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ },
+ KaiserBoard::M312 => match addr {
+ 0x8000..=0xBFFF => {
+ let count = self.prg_count_16k();
+ let bank = (self.prg16 as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ 0xC000..=0xFFFF => {
+ let count = self.prg_count_16k();
+ let bank = count - 1;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ _ => 0,
+ },
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ match self.board {
+ KaiserBoard::M303 => addr != 0x4030 && (0x4020..=0x5FFF).contains(&addr),
+ _ => (0x4020..=0x5FFF).contains(&addr),
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match self.board {
+ KaiserBoard::M56 | KaiserBoard::M142 => match addr & 0xF000 {
+ 0x8000 => self.irq_reload = (self.irq_reload & 0xFFF0) | (value as u16 & 0x0F),
+ 0x9000 => {
+ self.irq_reload = (self.irq_reload & 0xFF0F) | ((value as u16 & 0x0F) << 4);
+ }
+ 0xA000 => {
+ self.irq_reload = (self.irq_reload & 0xF0FF) | ((value as u16 & 0x0F) << 8);
+ }
+ 0xB000 => {
+ self.irq_reload = (self.irq_reload & 0x0FFF) | ((value as u16 & 0x0F) << 12);
+ }
+ 0xC000 => {
+ self.irq_control = value;
+ if value & 0x02 != 0 {
+ self.irq_counter = self.irq_reload;
+ }
+ self.irq_enabled = value & 0x02 != 0;
+ self.irq_pending = false;
+ }
+ 0xD000 => self.irq_pending = false,
+ 0xE000 => self.selected_reg = (value & 0x07).wrapping_sub(1),
+ 0xF000 => {
+ match self.selected_reg {
+ 0..=3 => {
+ let i = self.selected_reg as usize;
+ self.prg_regs[i] = (self.prg_regs[i] & 0x10) | (value & 0x0F);
+ }
+ 4 => self.use_rom = value & 0x04 != 0,
+ _ => {}
+ }
+ if self.board == KaiserBoard::M56 {
+ match addr & 0xFC00 {
+ 0xF000 => {
+ let bank = (addr & 0x03) as usize;
+ self.prg_regs[bank] = (value & 0x10) | (self.prg_regs[bank] & 0x0F);
+ }
+ 0xF800 => {
+ self.mirroring = if value & 0x01 != 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ 0xFC00 => self.chr_banks[(addr & 0x07) as usize] = value,
+ _ => {}
+ }
+ }
+ }
+ _ => {}
+ },
+ KaiserBoard::M303 => {
+ if addr & 0xFF00 == 0x4A00 {
+ self.prg16 = (((addr >> 2) & 0x03) | ((addr >> 4) & 0x04)) as u8;
+ } else if addr == 0x4020 {
+ self.irq_pending = false;
+ self.irq_counter = (self.irq_counter & 0xFF00) | value as u16;
+ } else if addr == 0x4021 {
+ self.irq_pending = false;
+ self.irq_counter = (self.irq_counter & 0x00FF) | ((value as u16) << 8);
+ self.irq_enabled = true;
+ } else if addr == 0x4025 {
+ self.mirroring = if (value >> 3) & 0x01 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ KaiserBoard::M305 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.regs4[((addr >> 11) & 0x03) as usize] = value;
+ }
+ }
+ KaiserBoard::M306 => {
+ if addr >= 0x8000 {
+ let mode = (addr & 0x30) == 0x30;
+ match addr & 0xD943 {
+ 0xD943 => {
+ self.win_6000 = if mode {
+ 0x0B
+ } else {
+ ((addr >> 2) & 0x0F) as u8
+ };
+ }
+ 0xD903 => {
+ self.win_6000 = if mode {
+ 0x08 | ((addr >> 2) & 0x03) as u8
+ } else {
+ 0x0B
+ };
+ }
+ _ => {}
+ }
+ }
+ }
+ KaiserBoard::M312 => {
+ if addr < 0x8000 {
+ if (0x6000..=0x7FFF).contains(&addr) {
+ self.prg16 = value;
+ }
+ } else {
+ self.mirroring = if value & 0x01 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (self.chr.len() - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ match self.board {
+ KaiserBoard::M56 | KaiserBoard::M142 => {
+ if self.irq_control & 0x02 != 0 {
+ self.irq_counter = self.irq_counter.wrapping_add(1);
+ if self.irq_counter == 0xFFFF {
+ self.irq_counter = self.irq_reload;
+ self.irq_control &= !0x02;
+ self.irq_pending = true;
+ }
+ }
+ }
+ KaiserBoard::M303 if self.irq_enabled && self.irq_counter != 0 => {
+ self.irq_counter -= 1;
+ if self.irq_counter == 0 {
+ self.irq_enabled = false;
+ self.irq_pending = true;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out =
+ Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.prg_regs);
+ out.push(self.selected_reg);
+ out.push(u8::from(self.use_rom));
+ out.extend_from_slice(&self.chr_banks);
+ out.push(self.win_6000);
+ out.extend_from_slice(&self.regs4);
+ out.push(self.prg16);
+ out.extend_from_slice(&self.irq_counter.to_le_bytes());
+ out.extend_from_slice(&self.irq_reload.to_le_bytes());
+ out.push(u8::from(self.irq_enabled));
+ out.push(self.irq_control);
+ out.push(u8::from(self.irq_pending));
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.wram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let mut c = 1;
+ self.prg_regs.copy_from_slice(&data[c..c + 4]);
+ c += 4;
+ self.selected_reg = data[c];
+ self.use_rom = data[c + 1] != 0;
+ c += 2;
+ self.chr_banks.copy_from_slice(&data[c..c + 8]);
+ c += 8;
+ self.win_6000 = data[c];
+ c += 1;
+ self.regs4.copy_from_slice(&data[c..c + 4]);
+ c += 4;
+ self.prg16 = data[c];
+ c += 1;
+ self.irq_counter = u16::from_le_bytes([data[c], data[c + 1]]);
+ self.irq_reload = u16::from_le_bytes([data[c + 2], data[c + 3]]);
+ c += 4;
+ self.irq_enabled = data[c] != 0;
+ self.irq_control = data[c + 1];
+ self.irq_pending = data[c + 2] != 0;
+ self.mirroring = byte_to_mirroring(data[c + 3], self.mirroring);
+ c += 4;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ self.wram.copy_from_slice(&data[c..c + self.wram.len()]);
+ c += self.wram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+macro_rules! kaiser_ctor {
+ ($fn_name:ident, $board:expr, $id:expr, $doc:expr) => {
+ #[doc = $doc]
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG/CHR size.
+ pub fn $fn_name(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ KaiserMapper::new($board, prg_rom, chr_rom, mirroring, $id)
+ }
+ };
+}
+
+kaiser_ctor!(new_m56, KaiserBoard::M56, 56, "Mapper 56 (Kaiser KS202).");
+kaiser_ctor!(
+ new_m142,
+ KaiserBoard::M142,
+ 142,
+ "Mapper 142 (Kaiser KS7032)."
+);
+kaiser_ctor!(
+ new_m303,
+ KaiserBoard::M303,
+ 303,
+ "Mapper 303 (Kaiser KS7017)."
+);
+kaiser_ctor!(
+ new_m305,
+ KaiserBoard::M305,
+ 305,
+ "Mapper 305 (Kaiser KS7031)."
+);
+kaiser_ctor!(
+ new_m306,
+ KaiserBoard::M306,
+ 306,
+ "Mapper 306 (Kaiser KS7016)."
+);
+kaiser_ctor!(
+ new_m312,
+ KaiserBoard::M312,
+ 312,
+ "Mapper 312 (Kaiser KS7013B)."
+);
+
+// ===========================================================================
+// Waixing253 (mapper 253) — Waixing VRC4-clone, Dragon Ball Z.
+//
+// Per-1 KiB CHR low/high registers ($B000-$E00C), a CHR-RAM escape (CHR reg
+// value 4/5 + a force-ROM toggle on slot 0 via $88/$C8), two 8 KiB PRG selects
+// ($8010/$A010), $9400 mirroring, and a /114-scaled CPU-cycle IRQ ($F000 etc.).
+// Ported from Mesen2 Waixing/Mapper253.h.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_2k_tagged(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_2K];
+ for b in 0..banks {
+ v[b * PRG_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn kaiser202_prg_regs_and_up_count_irq() {
+ let mut m = new_m142(synth_prg_8k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xE000, 0x01); // select reg (1-1=0)
+ m.cpu_write(0xF000, 0x03); // prg_regs[0] low = 3
+ assert_eq!(m.cpu_read(0x8000), 3);
+
+ // IRQ: reload, enable, count up to 0xFFFF.
+ m.cpu_write(0x8000, 0x0E); // reload low nibble
+ m.cpu_write(0xC000, 0x02); // enable + load
+ // Counter loads 0x...E; count up until 0xFFFF wraps.
+ let mut fired = false;
+ for _ in 0..0x20000 {
+ m.notify_cpu_cycle();
+ if m.irq_pending() {
+ fired = true;
+ break;
+ }
+ }
+ assert!(fired);
+ }
+
+ #[test]
+ fn kaiser202_save_state_round_trip() {
+ let mut m = new_m56(synth_prg_8k(16), synth_chr_1k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xE000, 0x01);
+ m.cpu_write(0xF000, 0x05);
+ m.cpu_write(0xFC00, 0x02); // m56 CHR write
+ m.ppu_write(0x2002, 0x44);
+ let blob = m.save_state();
+ let mut m2 = new_m56(synth_prg_8k(16), synth_chr_1k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x2002), 0x44);
+ }
+
+ #[test]
+ fn kaiser7017_prg_and_down_count_irq() {
+ let mut m = new_m303(synth_prg_16k(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ // $4Axx address-decoded PRG select.
+ m.cpu_write(0x4A00 | (1 << 2), 0); // prg16 = ((1<<2)>>2)&3 = 1
+ assert_eq!(m.cpu_read(0x8000), 1);
+
+ m.cpu_write(0x4020, 0x03); // counter low
+ m.cpu_write(0x4021, 0x00); // counter high + enable -> counter = 3
+ for _ in 0..3 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ assert_eq!(m.cpu_read(0x4030), 0x01); // read-ack returns pending then clears.
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn kaiser7031_windowed_prg() {
+ // 8 KiB == 4 x 2 KiB pages; use a 2 KiB-tagged 16 KiB image (8 pages).
+ let mut m = new_m305(synth_prg_2k_tagged(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ // $8000-$FFFF: window = (addr>>11)&3, value = 2 KiB page index.
+ m.cpu_write(0x8000, 5); // regs4[0] = 5
+ assert_eq!(m.cpu_read(0x6000), 5); // first 2 KiB $6000 window -> page 5.
+ }
+
+ #[test]
+ fn kaiser7031_save_state_round_trip() {
+ let mut m = new_m305(synth_prg_8k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 3);
+ m.cpu_write(0x8800, 4);
+ m.ppu_write(0x0005, 0x21);
+ let blob = m.save_state();
+ let mut m2 = new_m305(synth_prg_8k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x6000), m.cpu_read(0x6000));
+ assert_eq!(m2.ppu_read(0x0005), 0x21);
+ }
+
+ #[test]
+ fn kaiser7016_window_decode() {
+ let mut m = new_m306(synth_prg_8k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ // $D943 with mode bits (addr&0x30 != 0x30) -> _prgReg = (addr>>2)&0x0F.
+ let addr = 0xD943; // addr&0x30 = 0x00 -> not mode -> reg = (0xD943>>2)&0x0F
+ m.cpu_write(addr, 0);
+ let v = m.cpu_read(0x6000);
+ assert!((v as usize) < 16);
+ }
+
+ #[test]
+ fn kaiser7013b_prg_and_mirror() {
+ let mut m = new_m312(synth_prg_16k(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6000, 3); // prg16 = 3
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 7); // fixed last bank.
+ m.cpu_write(0x8000, 0x01); // horizontal
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+}
diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs
index b36b56f4..4936a20c 100644
--- a/crates/rustynes-mappers/src/lib.rs
+++ b/crates/rustynes-mappers/src/lib.rs
@@ -35,138 +35,216 @@ extern crate std;
use alloc::{boxed::Box, string::ToString};
-mod axrom;
-mod bandai152;
-mod bandai74;
-mod bandai_fcg;
+mod bmc_simple;
mod cartridge;
-mod cnrom;
mod fds;
-mod gxrom;
mod header;
-mod irem_g101;
-mod irem_h3001;
-mod jaleco87;
-mod jaleco_ss88006;
-mod jy_asic;
-mod konami_vs;
-mod m78;
+mod homebrew_boards;
+mod jaleco_discrete;
+mod kaiser;
+mod m000_nrom;
+mod m001_mmc1;
+mod m002_uxrom;
+mod m003_cnrom;
+mod m004_mmc3;
+mod m005_mmc5;
+mod m007_axrom;
+mod m009_mmc2;
+mod m010_mmc4;
+mod m011_color_dreams;
+mod m013_cprom;
+mod m016_bandai_fcg;
+mod m018_jaleco_ss88006;
+mod m019_namco163;
+mod m021_vrc4;
+mod m022_vrc2;
+mod m024_vrc6;
+mod m032_irem_g101;
+mod m033_taito_tc0190;
+mod m034_bnrom_nina001;
+mod m035_jy_asic;
+mod m036_txc_policeman;
+mod m038_bitcorp38;
+mod m039_subor39;
+mod m041_caltron41;
+mod m042_fds_conv_bio_miracle;
+mod m048_taito_tc0690;
+mod m050_fds_conv_smb2j;
+mod m064_rambo1;
+mod m065_irem_h3001;
+mod m066_gxrom;
+mod m067_sunsoft3;
+mod m068_sunsoft4;
+mod m069_sunsoft_fme7;
+mod m070_bandai74;
+mod m071_camerica_bf9093;
+mod m073_vrc3;
+mod m075_vrc1;
+mod m076_namcot3446;
+mod m077_irem_napoleon;
+mod m078_irem_jaleco78;
+mod m079_ave_nina03_06;
+mod m080_taito_x1_005;
+mod m082_taito_x1_017;
+mod m085_vrc7;
+mod m087_jaleco87;
+mod m088_namco118;
+mod m089_sunsoft2;
+mod m093_sunsoft3r;
+mod m094_un1rom;
+mod m095_namcot3425;
+mod m096_bandai96;
+mod m097_irem_tam_s1;
+mod m099_vs_system;
+mod m107_magic_dragon107;
+mod m113_ave_nina006;
+mod m118_txsrom;
+mod m119_tqrom;
+mod m132_txc_22211;
+mod m136_sachen_3011;
+mod m151_konami_vs;
+mod m152_bandai152;
+mod m156_daou156;
+mod m176_bmc_fk23c;
+mod m177_hengedianzi;
+mod m179_hengedianzi;
+mod m180_nichibutsu180;
+mod m184_sunsoft1;
+mod m185_cnrom185;
+mod m210_namco175;
+mod m232_camerica_bf9096;
+mod m240_cne_multicart;
+mod m241_bxrom241;
+mod m244_cne_decathlon;
+mod m246_fong_shen_bang246;
+mod m250_nitra250;
+mod m268_bmc_coolboy;
+mod m513_sachen_9602;
mod mapper;
-mod mmc1;
-mod mmc3;
-mod mmc5;
-mod namco118;
-mod namco175;
-mod nrom;
+mod mmc3_clones;
+mod multicart_discrete;
mod nsf;
mod nsf_expansion;
-mod rambo1;
-mod sprint10;
-mod sprint11;
-mod sprint12;
-mod sprint13;
-mod sprint2;
-mod sprint3;
-mod sprint5;
-mod sprint6;
-mod sprint7;
-mod sprint8;
-mod sprint9;
-mod sunsoft1;
-mod sunsoft2;
-mod sunsoft3;
-mod sunsoft3r;
-mod sunsoft4;
-mod taito_tc0190;
-mod taito_tc0690;
-mod taito_x1_005;
-mod taito_x1_017;
+mod ntdec;
+mod sachen_8259;
+mod sachen_discrete;
mod tier;
-mod tqrom;
-mod txsrom;
mod unif;
-mod uxrom;
-mod vrc3;
-mod vs_system;
+mod waixing;
-pub use axrom::AxRom;
-pub use bandai_fcg::{BandaiFcg, FcgVariant};
-pub use bandai74::Bandai74;
-pub use bandai152::Bandai152;
+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 cnrom::CnRom;
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 gxrom::GxRom;
pub use header::{Header, parse_header, serialize_header};
-pub use irem_g101::IremG101;
-pub use irem_h3001::IremH3001;
-pub use jaleco_ss88006::JalecoSs88006;
-pub use jaleco87::Jaleco87;
-pub use jy_asic::{JyAsic, JyBoard};
-pub use konami_vs::KonamiVs;
-pub use m78::{M78, M78Variant};
+pub use homebrew_boards::{Action53M28, Cufrom29, Gtrom111, Inl31, MagicFloor218, Unrom512M30};
+pub use jaleco_discrete::{Jaleco72, Jaleco86, Jaleco92, Jaleco101, Jaleco140};
+pub use kaiser::{new_m56, new_m142, new_m303, new_m305, new_m306, new_m312};
+pub use m000_nrom::Nrom;
+pub use m001_mmc1::Mmc1;
+pub use m002_uxrom::UxRom;
+pub use m003_cnrom::CnRom;
+pub use m004_mmc3::{Mmc3, Mmc3Revision};
+pub use m005_mmc5::Mmc5;
+pub use m007_axrom::AxRom;
+pub use m009_mmc2::Mmc2;
+pub use m010_mmc4::Mmc4;
+pub use m011_color_dreams::ColorDreams;
+pub use m013_cprom::Cprom;
+pub use m016_bandai_fcg::{BandaiFcg, FcgVariant};
+pub use m018_jaleco_ss88006::JalecoSs88006;
+pub use m019_namco163::Namco163;
+pub use m021_vrc4::Vrc4;
+pub use m022_vrc2::Vrc2;
+pub use m024_vrc6::Vrc6;
+pub use m032_irem_g101::IremG101;
+pub use m033_taito_tc0190::TaitoTc0190;
+pub use m034_bnrom_nina001::{M34, M34Variant};
+pub use m035_jy_asic::{JyAsic, JyBoard};
+pub use m036_txc_policeman::Txc36;
+pub use m038_bitcorp38::Bitcorp38;
+pub use m039_subor39::Subor39;
+pub use m041_caltron41::Caltron41;
+pub use m042_fds_conv_bio_miracle::Mapper42;
+pub use m048_taito_tc0690::TaitoTc0690;
+pub use m050_fds_conv_smb2j::Mapper50;
+pub use m064_rambo1::Rambo1;
+pub use m065_irem_h3001::IremH3001;
+pub use m066_gxrom::GxRom;
+pub use m067_sunsoft3::Sunsoft3;
+pub use m068_sunsoft4::Sunsoft4;
+pub use m069_sunsoft_fme7::Fme7;
+pub use m070_bandai74::Bandai74;
+pub use m071_camerica_bf9093::Camerica;
+pub use m073_vrc3::Vrc3;
+pub use m075_vrc1::Vrc1;
+pub use m076_namcot3446::Namcot3446M76;
+pub use m077_irem_napoleon::Irem77;
+pub use m078_irem_jaleco78::{M78, M78Variant};
+pub use m079_ave_nina03_06::Nina0379;
+pub use m080_taito_x1_005::TaitoX1005;
+pub use m082_taito_x1_017::TaitoX1017;
+pub use m085_vrc7::Vrc7;
+pub use m087_jaleco87::Jaleco87;
+pub use m088_namco118::{Namco118, Namco118Board};
+pub use m089_sunsoft2::Sunsoft2;
+pub use m093_sunsoft3r::Sunsoft3r;
+pub use m094_un1rom::Un1rom94;
+pub use m095_namcot3425::Namcot3425M95;
+pub use m096_bandai96::Bandai96;
+pub use m097_irem_tam_s1::Irem97;
+pub use m099_vs_system::VsSystem;
+pub use m107_magic_dragon107::MagicDragon107;
+pub use m113_ave_nina006::Nina006M113;
+pub use m118_txsrom::TxSrom;
+pub use m119_tqrom::Tqrom;
+pub use m132_txc_22211::Txc132;
+pub use m136_sachen_3011::new_m136;
+pub use m151_konami_vs::KonamiVs;
+pub use m152_bandai152::Bandai152;
+pub use m156_daou156::Daou156;
+pub use m176_bmc_fk23c::new_m176;
+pub use m177_hengedianzi::Hengedianzi177;
+pub use m179_hengedianzi::Hengedianzi179;
+pub use m180_nichibutsu180::Nichibutsu180;
+pub use m184_sunsoft1::Sunsoft1;
+pub use m185_cnrom185::CnRom185;
+pub use m210_namco175::{Namco175, Namco175Board};
+pub use m232_camerica_bf9096::Camerica232;
+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 m268_bmc_coolboy::new_m268;
+pub use m513_sachen_9602::new_m513;
pub use mapper::{
BgSplitState, ExAttribute, Mapper, MapperCaps, MapperDebugInfo, MapperError, MapperFrameEvents,
mirroring_name,
};
-pub use mmc1::Mmc1;
-pub use mmc3::{Mmc3, Mmc3Revision};
-pub use mmc5::Mmc5;
-pub use namco118::{Namco118, Namco118Board};
-pub use namco175::{Namco175, Namco175Board};
-pub use nrom::Nrom;
-pub use nsf::{Nsf, NsfMapper, is_nsf, parse_nsf};
-pub use rambo1::Rambo1;
-pub use sprint2::{Camerica, ColorDreams, Cprom, M34, M34Variant, Mmc2, Mmc4, Vrc1};
-pub use sprint3::{Fme7, Namco163, Vrc2, Vrc4, Vrc6, Vrc7};
-pub use sprint5::{
- Bitcorp38, Bxrom241, Caltron41, Camerica232, Cne240, Jaleco86, Jaleco140, Nina006M113, Nina0379,
-};
-pub use sprint6::{
- Bandai96, Irem77, Irem97, Jaleco72, Jaleco92, Multicart15, Multicart61, Multicart62, Sachen133,
- Sachen145, Sachen146, Subor39, Txc36, Txc132,
-};
-pub use sprint7::{
- CnRom185, Multicart200, Multicart201, Multicart202, Multicart203, Multicart212, Multicart213,
- Multicart214, Nichibutsu180, Sachen148, Sachen149, Sachen150, Sachen3018M147,
-};
-pub use sprint8::{
- Cufrom29, Gtrom111, Hengedianzi177, Hengedianzi179, Inl31, Jaleco101, MagicDragon107,
- MagicFloor218, Maxi15M234, Multicart58, Multicart60, Multicart231, SachenTca01M143, Un1rom94,
+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,
};
-pub use sprint9::{
- Action53M28, FongShenBang246, Multicart225, Multicart226, Multicart227, Multicart229,
- Multicart233, Namcot3446M76, Ntdec63, Ntdec174, Unrom512M30, Waixing242,
+pub use multicart_discrete::{
+ DiscreteBoard, DiscreteMapper, Maxi15M234, Multicart15, Multicart58, Multicart60, Multicart61,
+ Multicart62, Multicart200, Multicart201, Multicart202, Multicart203, Multicart212,
+ Multicart213, Multicart214, Multicart225, Multicart226, Multicart227, Multicart229,
+ Multicart231, Multicart233, new_m46, new_m51, new_m57, new_m104, new_m120, new_m204, new_m290,
+ new_m299, new_m301,
};
-pub use sprint10::{
- Daou156, Decathlon244, Namcot3425M95, Nitra250, Ntdec81, Ntdec2722M40, NtdecAsder112,
- Sachen8259M137, Waixing178, WaixingFs304M162,
-};
-pub use sprint11::{
- DiscreteBoard, DiscreteMapper, Mapper42, Mapper50, Mmc3CloneMapper, Sachen8259,
- Sachen8259Variant, new_m44, new_m46, new_m49, new_m51, new_m52, new_m57, new_m104, new_m115,
- new_m120, new_m134, new_m189, new_m205, new_m238, new_m245, new_m290, new_m301, new_m348,
- new_m366,
+pub use nsf::{Nsf, NsfMapper, is_nsf, parse_nsf};
+pub use ntdec::{Ntdec63, Ntdec81, Ntdec174, Ntdec2722M40, NtdecAsder112, new_m193, new_m221};
+pub use sachen_8259::{Sachen8259, Sachen8259M137, Sachen8259Variant};
+pub use sachen_discrete::{
+ Sachen133, Sachen145, Sachen146, Sachen148, Sachen149, Sachen150, Sachen3018M147,
+ SachenTca01M143,
};
-pub use sunsoft1::Sunsoft1;
-pub use sunsoft2::Sunsoft2;
-pub use sunsoft3::Sunsoft3;
-pub use sunsoft3r::Sunsoft3r;
-pub use sunsoft4::Sunsoft4;
-pub use taito_tc0190::TaitoTc0190;
-pub use taito_tc0690::TaitoTc0690;
-pub use taito_x1_005::TaitoX1005;
-pub use taito_x1_017::TaitoX1017;
pub use tier::{MapperTier, mapper_tier};
-pub use tqrom::Tqrom;
-pub use txsrom::TxSrom;
pub use unif::{UnifError, UnifImage, board_to_mapper, parse_unif, unif_to_ines};
-pub use uxrom::UxRom;
-pub use vrc3::Vrc3;
-pub use vs_system::VsSystem;
+pub use waixing::{Waixing178, Waixing242, WaixingFs304M162, new_m253};
/// Returns the crate version string.
#[must_use]
@@ -389,7 +467,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
// MMC5 v0: banking + ExRAM modes 10/11 + scanline IRQ. Several
// features deferred (vertical split, dual sprite/BG CHR for
// 8x16 sprites, ExGrafix attribute injection, audio extension);
- // see `crates/rustynes-mappers/src/mmc5.rs` module docs.
+ // see `crates/rustynes-mappers/src/m005_mmc5.rs` module docs.
let prg_ram_bytes = if h.prg_ram_size == 0 {
0
} else {
@@ -436,9 +514,9 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
// 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 vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring)
+ 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(vrc4)
+ Box::new(m021_vrc4)
}
22 => {
let vrc2 = Vrc2::new(prg_rom, chr_rom, 22, h.submapper, h.mirroring)
@@ -675,7 +753,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
// TQROM (Pin*Bot, High Speed): MMC3 PRG/IRQ/mirroring plus a mixed
// CHR address space — 64 KiB CHR-ROM + 8 KiB CHR-RAM, selected per
// 1 KiB bank by bit 6 of the resolved CHR bank number (set =
- // CHR-RAM). See `crates/rustynes-mappers/src/tqrom.rs`.
+ // CHR-RAM). See `crates/rustynes-mappers/src/m119_tqrom.rs`.
let prg_ram_bytes = if h.prg_ram_size == 0 {
0
} else {
@@ -759,10 +837,10 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?;
Box::new(vrc7)
}
- // --- v1.2.0 Workstream A, curated (Tier-1) long-tail boards (sprint5). ---
+ // --- v1.2.0 Workstream A, curated (Tier-1) long-tail boards. ---
// Simple discrete-logic mappers; see `tier.rs` (`MapperTier::Curated`)
// and `docs/adr/0011-mapper-tiering.md`. Each is register-decode
- // unit-tested in `sprint5.rs`.
+ // unit-tested in its own per-board module.
38 => Box::new(
Bitcorp38::new(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
@@ -801,7 +879,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.2.0 Workstream A, best-effort (Tier-2) long-tail sweep
- // (sprint6 + sprint7). Reference-ported discrete/multicart boards,
+ // Reference-ported discrete / multicart boards,
// register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
// (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
15 => Box::new(
@@ -911,7 +989,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.3.0 "Bedrock" Workstream D1, best-effort (Tier-2) sweep
- // (sprint8). Reference-ported discrete / multicart boards,
+ // Reference-ported discrete / multicart boards,
// register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
// (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
29 => Box::new(
@@ -972,7 +1050,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.4.0 "Fidelity" Workstream G, best-effort (Tier-2) sweep
- // (sprint9). Reference-ported discrete / multicart boards,
+ // Reference-ported discrete / multicart boards,
// register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
// (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
28 => Box::new(
@@ -1040,7 +1118,7 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.5.0 "Lens" Workstream F, best-effort (Tier-2) sweep
- // (sprint10). Reference-ported discrete / multicart / pirate boards,
+ // Reference-ported discrete / multicart / pirate boards,
// register-decode + save-state unit-tested only, NOT accuracy-gated.
// See `tier.rs` (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
40 => Box::new(
@@ -1108,51 +1186,51 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.6.0 "Studio" Workstream E, best-effort (Tier-2) sweep
- // (sprint11). MMC3-clone variants (shared MMC3-style core + A12 IRQ),
+ // MMC3-clone variants (shared MMC3-style core + A12 IRQ),
// Sachen 8259 A/B/C, and discrete multicarts. Register-decode +
// save-state unit-tested only, NOT accuracy-gated (`tier.rs`).
44 => Box::new(
- sprint11::new_m44(prg_rom, chr_rom, h.mirroring)
+ new_m44(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
49 => Box::new(
- sprint11::new_m49(prg_rom, chr_rom, h.mirroring)
+ new_m49(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
52 => Box::new(
- sprint11::new_m52(prg_rom, chr_rom, h.mirroring)
+ new_m52(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
115 => Box::new(
- sprint11::new_m115(prg_rom, chr_rom, h.mirroring)
+ new_m115(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
134 => Box::new(
- sprint11::new_m134(prg_rom, chr_rom, h.mirroring)
+ new_m134(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
189 => Box::new(
- sprint11::new_m189(prg_rom, chr_rom, h.mirroring)
+ new_m189(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
205 => Box::new(
- sprint11::new_m205(prg_rom, chr_rom, h.mirroring)
+ new_m205(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
238 => Box::new(
- sprint11::new_m238(prg_rom, chr_rom, h.mirroring)
+ new_m238(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
245 => Box::new(
- sprint11::new_m245(prg_rom, chr_rom, h.mirroring)
+ new_m245(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
348 => Box::new(
- sprint11::new_m348(prg_rom, chr_rom, h.mirroring)
+ new_m348(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
366 => Box::new(
- sprint11::new_m366(prg_rom, chr_rom, h.mirroring)
+ new_m366(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// Sachen 8259 A/B/C (the 2 KiB-CHR variants; 8259D is mapper 137).
@@ -1179,126 +1257,126 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
46 => Box::new(
- sprint11::new_m46(prg_rom, chr_rom, h.mirroring)
+ new_m46(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
51 => Box::new(
- sprint11::new_m51(prg_rom, chr_rom, h.mirroring)
+ new_m51(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
57 => Box::new(
- sprint11::new_m57(prg_rom, chr_rom, h.mirroring)
+ new_m57(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
104 => Box::new(
- sprint11::new_m104(prg_rom, chr_rom, h.mirroring)
+ new_m104(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
120 => Box::new(
- sprint11::new_m120(prg_rom, chr_rom, h.mirroring)
+ new_m120(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
290 => Box::new(
- sprint11::new_m290(prg_rom, chr_rom, h.mirroring)
+ new_m290(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
301 => Box::new(
- sprint11::new_m301(prg_rom, chr_rom, h.mirroring)
+ new_m301(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.7.0 "Forge" Workstream G1, best-effort (Tier-2) reusable-ASIC
- // BMC / pirate cores (sprint12). FK23C / COOLBOY / MINDKIDS / Sachen /
+ // BMC / pirate cores. FK23C / COOLBOY / MINDKIDS / Sachen /
// Waixing / Kaiser clusters. Register-decode + save-state unit-tested
// only, NOT accuracy-gated (`tier.rs`).
176 => Box::new(
- sprint12::new_m176(prg_rom, chr_rom, h.mirroring)
+ new_m176(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
268 => Box::new(
- sprint12::new_m268(prg_rom, chr_rom, h.mirroring)
+ new_m268(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
513 => Box::new(
- sprint12::new_m513(prg_rom, chr_rom, h.mirroring)
+ new_m513(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
136 => Box::new(
- sprint12::new_m136(prg_rom, chr_rom, h.mirroring)
+ new_m136(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
164 => Box::new(
- sprint12::new_m164(prg_rom, chr_rom, h.mirroring)
+ new_m164(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
253 => Box::new(
- sprint12::new_m253(prg_rom, chr_rom, h.mirroring)
+ new_m253(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
286 => Box::new(
- sprint12::new_m286(prg_rom, chr_rom, h.mirroring)
+ new_m286(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
56 => Box::new(
- sprint12::new_m56(prg_rom, chr_rom, h.mirroring)
+ new_m56(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
142 => Box::new(
- sprint12::new_m142(prg_rom, chr_rom, h.mirroring)
+ new_m142(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
303 => Box::new(
- sprint12::new_m303(prg_rom, chr_rom, h.mirroring)
+ new_m303(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
305 => Box::new(
- sprint12::new_m305(prg_rom, chr_rom, h.mirroring)
+ new_m305(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
306 => Box::new(
- sprint12::new_m306(prg_rom, chr_rom, h.mirroring)
+ new_m306(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
312 => Box::new(
- sprint12::new_m312(prg_rom, chr_rom, h.mirroring)
+ new_m312(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
261 => Box::new(
- sprint12::new_m261(prg_rom, chr_rom, h.mirroring)
+ new_m261(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
289 => Box::new(
- sprint12::new_m289(prg_rom, chr_rom, h.mirroring)
+ new_m289(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
320 => Box::new(
- sprint12::new_m320(prg_rom, chr_rom, h.mirroring)
+ new_m320(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
336 => Box::new(
- sprint12::new_m336(prg_rom, chr_rom, h.mirroring)
+ new_m336(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
349 => Box::new(
- sprint12::new_m349(prg_rom, chr_rom, h.mirroring)
+ new_m349(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
// --- v1.8.9 "Backlog" beta.6, best-effort (Tier-2) NTDEC / TXC / BMC
- // multicart cores (sprint13). Register-decode + save-state unit-tested
+ // multicart cores. Register-decode + save-state unit-tested
// only, NOT accuracy-gated (`tier.rs`).
193 => Box::new(
- sprint13::new_m193(prg_rom, chr_rom, h.mirroring)
+ new_m193(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
204 => Box::new(
- sprint13::new_m204(prg_rom, chr_rom, h.mirroring)
+ new_m204(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
221 => Box::new(
- sprint13::new_m221(prg_rom, chr_rom, h.mirroring)
+ new_m221(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
299 => Box::new(
- sprint13::new_m299(prg_rom, chr_rom, h.mirroring)
+ new_m299(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
other => return Err(RomError::UnsupportedMapper(other)),
@@ -1451,3 +1529,81 @@ mod tests {
assert_eq!(cart.prg_rom.len(), 32 * 1024);
}
}
+
+/// Tripwire pinning every expansion-audio **level** constant.
+///
+/// ## Why this exists
+///
+/// Changing one of these constants changes the audio of every game on that
+/// board — correctly, when the change is a deliberate recalibration. The
+/// problem is *where the evidence of that lives*. The 60-ROM commercial oracle
+/// (`tests/external_real_games.rs`) hashes real cartridge audio and is the only
+/// gate that would notice, but it needs `--features commercial-roms` **and**
+/// local gitignored ROM dumps, so neither CI nor the default
+/// `--features test-roms` gate can run it. A golden vector nothing executes is
+/// not a gate.
+///
+/// That gap bit for real. `VRC6_MIX_SCALE` and all three MMC5 constants were
+/// recalibrated in v2.1.6 (`fd82485c`, 2026-07-11); the commercial-oracle
+/// snapshots had last been blessed on 2026-06-13, 28 days earlier. Six rows sat
+/// silently stale across several releases until someone ran the suite by hand.
+///
+/// This test **can** run in CI. It fails the moment a level constant moves,
+/// with instructions naming the suites that must be re-blessed in the same
+/// change. It asserts nothing about correctness — the oracles do that
+/// (`audio_expansion.rs`'s `level_db_*` decibel tests, `docs/apu-2a03.md`
+/// §Expansion-audio levels). Its only job is to make a silent change loud.
+///
+/// **If this test fails and the change was intentional:** update the value
+/// here, then re-bless BOTH `cargo test -p rustynes-test-harness --features
+/// test-roms --test audio_expansion` and `cargo test -p rustynes-test-harness
+/// --features test-roms,commercial-roms --test external_real_games` (the latter
+/// needs the local dumps; if you do not have them, say so in the PR rather than
+/// leaving the rows stale).
+#[cfg(test)]
+mod expansion_level_tripwire {
+ const RE_BLESS: &str = "expansion-audio level constant changed -- re-bless \
+ `audio_expansion` AND the gitignored `external_real_games` \
+ (--features commercial-roms) in this same change; see this module's docs";
+
+ #[test]
+ fn expansion_audio_levels_are_pinned() {
+ assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 979, "VRC6: {RE_BLESS}");
+ assert_eq!(
+ crate::m005_mmc5::MMC5_PULSE_SCALE,
+ 650,
+ "MMC5 pulse: {RE_BLESS}"
+ );
+ assert_eq!(crate::m005_mmc5::MMC5_PCM_SCALE, 40, "MMC5 PCM: {RE_BLESS}");
+ assert_eq!(
+ crate::m019_namco163::NAMCO163_MIX_SCALE,
+ 261,
+ "N163: {RE_BLESS}"
+ );
+ assert_eq!(
+ crate::m069_sunsoft_fme7::SUNSOFT5B_MIX_SCALE_NUM,
+ 2549,
+ "5B numerator: {RE_BLESS}"
+ );
+ assert_eq!(
+ crate::m069_sunsoft_fme7::SUNSOFT5B_MIX_SCALE_DEN,
+ 138,
+ "5B denominator: {RE_BLESS}"
+ );
+ }
+
+ /// `MMC5_MIX_BIAS` is derived from the two MMC5 scales, so it cannot drift
+ /// independently — but pin the derivation itself, since an edit to the
+ /// formula would move every MMC5 game's DC offset without touching a scale.
+ #[test]
+ fn mmc5_mix_bias_stays_the_midpoint_of_its_two_scales() {
+ assert_eq!(
+ crate::m005_mmc5::MMC5_MIX_BIAS,
+ i16::midpoint(
+ 30 * crate::m005_mmc5::MMC5_PULSE_SCALE,
+ 127 * crate::m005_mmc5::MMC5_PCM_SCALE
+ ),
+ "MMC5 bias formula: {RE_BLESS}"
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/nrom.rs b/crates/rustynes-mappers/src/m000_nrom.rs
similarity index 100%
rename from crates/rustynes-mappers/src/nrom.rs
rename to crates/rustynes-mappers/src/m000_nrom.rs
diff --git a/crates/rustynes-mappers/src/mmc1.rs b/crates/rustynes-mappers/src/m001_mmc1.rs
similarity index 82%
rename from crates/rustynes-mappers/src/mmc1.rs
rename to crates/rustynes-mappers/src/m001_mmc1.rs
index 7c92503a..63dd3eb5 100644
--- a/crates/rustynes-mappers/src/mmc1.rs
+++ b/crates/rustynes-mappers/src/m001_mmc1.rs
@@ -216,6 +216,31 @@ impl Mmc1 {
}
}
+ /// Is the `$6000-$7FFF` PRG-RAM window currently disabled?
+ ///
+ /// A2 (v2.2.3). MMC1 has **two** software write-protect layers and `RustyNES`
+ /// previously modelled neither, reading and writing `prg_ram`
+ /// unconditionally:
+ ///
+ /// * **`$E000` bit 4** — the PRG-RAM disable common to every MMC1 board.
+ /// Set = RAM deselected (`/CE` deasserted).
+ /// * **SNROM's second layer** — on a board whose CHR is 8 KiB of RAM, the
+ /// CHR bank register doubles as a PRG-RAM enable: `$A000` bit 4 is wired
+ /// to the RAM's second enable. Only meaningful when `chr_is_ram`, which
+ /// is how SNROM is distinguished from the CHR-ROM boards (SJROM etc.)
+ /// that route those bits to real CHR banking instead.
+ ///
+ /// Holy Mapperel distinguishes the two: `M1_P128K_C32K` (SJROM, CHR-ROM)
+ /// reported `1000` — the `$E000` layer alone — while `M1_P128K_CR8K`
+ /// (SNROM, CHR-RAM) reported `5000`, both layers
+ /// (`MAPTEST_WRAMEN2 $40 | MAPTEST_WRAMEN $10`).
+ const fn prg_ram_disabled(&self) -> bool {
+ if self.prg & 0x10 != 0 {
+ return true;
+ }
+ self.chr_is_ram && (self.chr0 & 0x10) != 0
+ }
+
/// Apply a completed 5-bit write to the appropriate internal register.
const fn commit(&mut self, addr: u16, value: u8) {
match addr & 0xE000 {
@@ -246,6 +271,17 @@ impl Mapper for Mmc1 {
}
}
+ /// A2: a disabled PRG-RAM window is not driven, so the databus floats.
+ ///
+ /// Same contract as the FME-7 fix: report the window unmapped and let the
+ /// bus preserve its open-bus latch, rather than inventing a byte here.
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ if matches!(addr, 0x6000..=0x7FFF) {
+ return self.prg_ram.is_empty() || self.prg_ram_disabled();
+ }
+ addr < 0x6000
+ }
+
fn cpu_read(&mut self, addr: u16) -> u8 {
match addr {
0x6000..=0x7FFF => {
@@ -264,6 +300,10 @@ impl Mapper for Mmc1 {
fn cpu_write(&mut self, addr: u16, value: u8) {
match addr {
0x6000..=0x7FFF => {
+ // A2: a disabled window is write-protected (both layers).
+ if self.prg_ram_disabled() {
+ return;
+ }
let idx = (addr - 0x6000) as usize;
if idx < self.prg_ram.len() {
self.prg_ram[idx] = value;
@@ -616,4 +656,75 @@ mod tests {
// PRG register should have round-tripped.
assert_eq!(m2.cpu_read(0x8000), 1);
}
+
+ // ---------------------------------------------------------------
+ // A2 (v2.2.3): the two PRG-RAM write-protect layers.
+ // ---------------------------------------------------------------
+
+ /// `$E000` bit 4 is the disable common to every MMC1 board. Write-protect
+ /// AND read-float, on a CHR-ROM board where the SNROM layer is inert.
+ #[test]
+ fn mmc1_e000_bit4_write_protects_and_floats_prg_ram() {
+ let mut m = Mmc1::new(synth_prg(8), synth_chr(8), Mirroring::Vertical, 0).unwrap();
+ // Enabled by default: a write sticks and the window is mapped.
+ m.cpu_write(0x6000, 0xA5);
+ assert_eq!(m.cpu_read(0x6000), 0xA5);
+ assert!(
+ !m.cpu_read_unmapped(0x6000),
+ "enabled window must be mapped"
+ );
+
+ // $E000 bit 4 set -> RAM deselected.
+ write5(&mut m, 0xE000, 0x10);
+ assert!(m.cpu_read_unmapped(0x6000), "disabled window must float");
+ m.cpu_write(0x6000, 0x5A); // must be discarded
+
+ // Re-enable: the pre-disable byte survives, proving the write above
+ // was dropped rather than merely hidden by the float.
+ write5(&mut m, 0xE000, 0x00);
+ assert!(!m.cpu_read_unmapped(0x6000));
+ assert_eq!(
+ m.cpu_read(0x6000),
+ 0xA5,
+ "write while disabled must not land"
+ );
+ }
+
+ /// SNROM's second layer: on a CHR-**RAM** board the CHR register's bit 4 is
+ /// wired to the RAM's other enable, so it disables PRG-RAM independently of
+ /// `$E000`. This is what separated Holy Mapperel's `5000` (SNROM) from
+ /// `1000` (SJROM).
+ #[test]
+ fn mmc1_snrom_chr_bit4_is_a_second_prg_ram_enable() {
+ // Empty CHR => chr_is_ram, i.e. an SNROM-class board.
+ let mut m = Mmc1::new(synth_prg(8), Box::new([]), Mirroring::Vertical, 0).unwrap();
+ m.cpu_write(0x6000, 0x3C);
+ assert_eq!(m.cpu_read(0x6000), 0x3C);
+
+ // $E000 stays ENABLED; only the CHR-register layer disables.
+ write5(&mut m, 0xA000, 0x10);
+ assert!(
+ m.cpu_read_unmapped(0x6000),
+ "SNROM: $A000 bit 4 alone must disable PRG-RAM"
+ );
+ m.cpu_write(0x6000, 0x99); // discarded
+
+ write5(&mut m, 0xA000, 0x00);
+ assert_eq!(m.cpu_read(0x6000), 0x3C);
+ }
+
+ /// The same CHR-register bit must NOT touch PRG-RAM on a CHR-ROM board —
+ /// there it is a real CHR bank select. This is the regression that would
+ /// break every SJROM/SUROM title if the layer were applied unconditionally.
+ #[test]
+ fn mmc1_chr_rom_board_ignores_the_snrom_layer() {
+ let mut m = Mmc1::new(synth_prg(8), synth_chr(8), Mirroring::Vertical, 0).unwrap();
+ m.cpu_write(0x6000, 0x77);
+ write5(&mut m, 0xA000, 0x10); // a CHR bank select, not a RAM enable
+ assert!(
+ !m.cpu_read_unmapped(0x6000),
+ "CHR-ROM board: $A000 bit 4 is CHR banking, must not gate PRG-RAM"
+ );
+ assert_eq!(m.cpu_read(0x6000), 0x77);
+ }
}
diff --git a/crates/rustynes-mappers/src/uxrom.rs b/crates/rustynes-mappers/src/m002_uxrom.rs
similarity index 90%
rename from crates/rustynes-mappers/src/uxrom.rs
rename to crates/rustynes-mappers/src/m002_uxrom.rs
index f1d72b00..0558ab64 100644
--- a/crates/rustynes-mappers/src/uxrom.rs
+++ b/crates/rustynes-mappers/src/m002_uxrom.rs
@@ -1,11 +1,25 @@
-//! `UxROM` (iNES mapper 2) implementation.
+//! Nintendo `UxROM` (mappers 2, 71-like) -- 16 KiB PRG banking with a fixed
+//! high bank.
//!
-//! `UxROM` (`UNROM`, `UOROM`, ...) has a switchable 16 KiB PRG bank at
-//! `$8000-$BFFF` and a fixed last-bank window at `$C000-$FFFF`. Standard
-//! `UxROM` ships CHR-RAM only (8 KiB). Mirroring comes from the iNES
-//! header and never changes at runtime. No IRQ.
+//! The archetypal simple discrete board: one write-anywhere register selects a
+//! 16 KiB bank at `$8000-$BFFF` while `$C000-$FFFF` stays pinned to the last
+//! bank, so the interrupt vectors are always reachable. CHR is RAM.
//!
-//! See `docs/mappers.md` §Mapper coverage matrix for the canonical reference.
+//! The `UN1ROM` variant (mapper 94) is in `m094_un1rom.rs`.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines
+)]
use crate::cartridge::Mirroring;
use crate::mapper::{Mapper, MapperCaps, MapperError};
@@ -200,7 +214,6 @@ impl Mapper for UxRom {
}
#[cfg(test)]
-#[allow(clippy::cast_possible_truncation)]
mod tests {
use super::*;
diff --git a/crates/rustynes-mappers/src/cnrom.rs b/crates/rustynes-mappers/src/m003_cnrom.rs
similarity index 100%
rename from crates/rustynes-mappers/src/cnrom.rs
rename to crates/rustynes-mappers/src/m003_cnrom.rs
diff --git a/crates/rustynes-mappers/src/mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs
similarity index 99%
rename from crates/rustynes-mappers/src/mmc3.rs
rename to crates/rustynes-mappers/src/m004_mmc3.rs
index d8e50382..3d95d4b6 100644
--- a/crates/rustynes-mappers/src/mmc3.rs
+++ b/crates/rustynes-mappers/src/m004_mmc3.rs
@@ -1419,7 +1419,7 @@ mod tests {
// deferral, independent of the full ROM run. These prove the plumbing
// actually fires (a qualifying M2-high rise is genuinely deferred one
// `notify_cpu_cycle`, an M2-low rise is not) — the ROM-level probes in
- // `crates/rustynes-test-harness/tests/mmc3.rs` only prove whether the
+ // `crates/rustynes-test-harness/tests/m004_mmc3.rs` only prove whether the
// deferral moves the target bracket, not whether it engaged at all.
#[cfg(feature = "mmc3-m2-phase-irq")]
#[test]
diff --git a/crates/rustynes-mappers/src/mmc5.rs b/crates/rustynes-mappers/src/m005_mmc5.rs
similarity index 99%
rename from crates/rustynes-mappers/src/mmc5.rs
rename to crates/rustynes-mappers/src/m005_mmc5.rs
index cd71de68..b4b9dcae 100644
--- a/crates/rustynes-mappers/src/mmc5.rs
+++ b/crates/rustynes-mappers/src/m005_mmc5.rs
@@ -1450,7 +1450,7 @@ impl Mapper for Mmc5 {
}
#[cfg(feature = "mapper-audio")]
- fn mix_audio(&mut self) -> i16 {
+ fn mix_audio(&mut self) -> i32 {
// Two pulse outputs (each 0..=15) plus one 7-bit PCM level.
//
// PCM is silenced when `$5010` bit 0 = 1 (read-mode) -- the chip
@@ -1478,7 +1478,7 @@ impl Mapper for Mmc5 {
// constants are shared with the NSF path so they can't drift.
let pulse_mix = (p1 + p2) * MMC5_PULSE_SCALE; // 0..=19500
let pcm_mix = pcm * MMC5_PCM_SCALE; // 0..=5080
- (pulse_mix + pcm_mix) - MMC5_MIX_BIAS
+ i32::from((pulse_mix + pcm_mix) - MMC5_MIX_BIAS)
}
fn notify_scanline_start(&mut self) {
diff --git a/crates/rustynes-mappers/src/axrom.rs b/crates/rustynes-mappers/src/m007_axrom.rs
similarity index 100%
rename from crates/rustynes-mappers/src/axrom.rs
rename to crates/rustynes-mappers/src/m007_axrom.rs
diff --git a/crates/rustynes-mappers/src/m009_mmc2.rs b/crates/rustynes-mappers/src/m009_mmc2.rs
new file mode 100644
index 00000000..d0f538d0
--- /dev/null
+++ b/crates/rustynes-mappers/src/m009_mmc2.rs
@@ -0,0 +1,308 @@
+//! Nintendo MMC2 (`PxROM`, mapper 9) -- Punch-Out!!
+//!
+//! The defining feature is a *tile-fetch CHR latch*: the PPU pattern-table
+//! address the cartridge sees during rendering selects which of two banked CHR
+//! windows stays mapped. Fetching tile `$FD` or `$FE` from a pattern half
+//! latches that half to one of two banks, so the mapper switches CHR banks
+//! mid-scanline with no CPU involvement -- the trick Punch-Out!! uses to draw a
+//! large animated opponent out of a small CHR ROM. That requires a hook on PPU
+//! pattern fetches, unlike every other board in this size class.
+//!
+//! PRG is 8 KiB switchable at `$8000` plus three fixed banks. The closely
+//! related MMC4 is in `m010_mmc4.rs` -- same CHR latch, different PRG
+//! granularity.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_4K: usize = 0x1000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// MMC2 (Mapper 9).
+pub struct Mmc2 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_lo_fd: u8,
+ chr_lo_fe: u8,
+ chr_hi_fd: u8,
+ chr_hi_fe: u8,
+ /// `false` -> use the FD bank for window 0 (`$0000-$0FFF`).
+ latch_lo_fe: bool,
+ /// `false` -> use the FD bank for window 1 (`$1000-$1FFF`).
+ latch_hi_fe: bool,
+ mirroring: Mirroring,
+}
+
+impl Mmc2 {
+ /// Construct a new MMC2 mapper.
+ ///
+ /// PRG must be a non-zero multiple of 8 KiB; CHR-ROM is mandatory and
+ /// must be a multiple of 4 KiB.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "MMC2 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "MMC2 CHR-ROM size {} is not a multiple of 4 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_lo_fd: 0,
+ chr_lo_fe: 0,
+ chr_hi_fd: 0,
+ chr_hi_fe: 0,
+ latch_lo_fe: false,
+ latch_hi_fe: false,
+ mirroring,
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = self.prg_rom.len() / PRG_BANK_8K;
+ let last3 = total_8k.saturating_sub(3);
+ let last2 = total_8k.saturating_sub(2);
+ let last1 = total_8k.saturating_sub(1);
+ let bank = match addr & 0xE000 {
+ 0x8000 => (self.prg_bank as usize) % total_8k.max(1),
+ 0xA000 => last3,
+ 0xC000 => last2,
+ _ => last1, // $E000 + the implicit fallback
+ };
+ bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF)
+ }
+
+ fn chr_offset(&mut 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 {
+ let b = if self.latch_lo_fe {
+ self.chr_lo_fe
+ } else {
+ self.chr_lo_fd
+ };
+ (b as usize) % total_4k
+ } else {
+ let b = if self.latch_hi_fe {
+ self.chr_hi_fe
+ } else {
+ self.chr_hi_fd
+ };
+ (b as usize) % total_4k
+ };
+ bank * CHR_BANK_4K + (addr & (CHR_BANK_4K - 1))
+ }
+
+ /// Update the CHR latch based on the fetched pattern address.
+ /// $0FD8-$0FDF -> window 0 latch FD; $0FE8-$0FEF -> window 0 latch FE;
+ /// similarly $1FD8-$1FDF / $1FE8-$1FEF for window 1. Per nesdev wiki.
+ fn update_latch(&mut self, addr: u16) {
+ match addr & 0x3FF8 {
+ 0x0FD8 => self.latch_lo_fe = false,
+ 0x0FE8 => self.latch_lo_fe = true,
+ 0x1FD8 => self.latch_hi_fe = false,
+ 0x1FE8 => self.latch_hi_fe = true,
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Mmc2 {
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xF000 {
+ 0xA000 => self.prg_bank = value & 0x0F,
+ 0xB000 => self.chr_lo_fd = value & 0x1F,
+ 0xC000 => self.chr_lo_fe = value & 0x1F,
+ 0xD000 => self.chr_hi_fd = value & 0x1F,
+ 0xE000 => self.chr_hi_fe = value & 0x1F,
+ 0xF000 => {
+ self.mirroring = if value & 1 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ let v = self.chr_rom[off % self.chr_rom.len()];
+ self.update_latch(addr);
+ v
+ }
+ 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ let len = self.chr_rom.len();
+ self.chr_rom[off % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(16 + self.vram.len());
+ out.push(1u8); // version
+ out.push(self.prg_bank);
+ out.push(self.chr_lo_fd);
+ out.push(self.chr_lo_fe);
+ out.push(self.chr_hi_fd);
+ out.push(self.chr_hi_fe);
+ out.push(u8::from(self.latch_lo_fe));
+ out.push(u8::from(self.latch_hi_fe));
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 9 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_lo_fd = data[2];
+ self.chr_lo_fe = data[3];
+ self.chr_hi_fd = data[4];
+ self.chr_hi_fe = data[5];
+ self.latch_lo_fe = data[6] != 0;
+ self.latch_hi_fe = data[7] != 0;
+ self.mirroring = match data[8] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr_4k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_4K];
+ for b in 0..banks {
+ v[b * CHR_BANK_4K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn mmc2_swap_window_via_latch() {
+ let mut m = Mmc2::new(synth(8), synth_chr_4k(4), Mirroring::Vertical).unwrap();
+ m.chr_lo_fd = 0;
+ m.chr_lo_fe = 1;
+ // Default latch is FD -> bank 0 byte 0 = 0.
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // Reading the FE sentinel switches to FE bank.
+ let _ = m.ppu_read(0x0FE8);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m010_mmc4.rs b/crates/rustynes-mappers/src/m010_mmc4.rs
new file mode 100644
index 00000000..66322f80
--- /dev/null
+++ b/crates/rustynes-mappers/src/m010_mmc4.rs
@@ -0,0 +1,287 @@
+//! Nintendo MMC4 (`FxROM`, mapper 10) -- Fire Emblem, Famicom Wars.
+//!
+//! Carries the same *tile-fetch CHR latch* as the MMC2 in `m009_mmc2.rs`: a
+//! pattern fetch of tile `$FD` or `$FE` latches that pattern half to one of two
+//! CHR banks, switching CHR mid-scanline without CPU involvement.
+//!
+//! It differs from MMC2 in PRG layout -- 16 KiB switchable plus 16 KiB fixed,
+//! rather than 8 KiB plus three fixed -- and in carrying battery-backed
+//! PRG-RAM, which is why the save-bearing Konami titles live on this board.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_4K: usize = 0x1000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// MMC4 (Mapper 10).
+pub struct Mmc4 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_lo_fd: u8,
+ chr_lo_fe: u8,
+ chr_hi_fd: u8,
+ chr_hi_fe: u8,
+ latch_lo_fe: bool,
+ latch_hi_fe: bool,
+ mirroring: Mirroring,
+ /// 8 KiB WRAM at $6000-$7FFF (battery-backed on most MMC4 carts).
+ /// T-60-003c (2026-05-17) — same root cause as the VRC2/4/6 WRAM
+ /// fix in `m022_vrc2.rs` / `m021_vrc4.rs`. Fire Emblem Gaiden was stuck-at-uniform-
+ /// gray for the same reason (read its save magic from WRAM at
+ /// boot, got 0, stalled in save-validation).
+ prg_ram: Box<[u8]>,
+}
+
+impl Mmc4 {
+ /// Construct a new MMC4 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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!(
+ "MMC4 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "MMC4 CHR-ROM size {} is not a multiple of 4 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_lo_fd: 0,
+ chr_lo_fe: 0,
+ chr_hi_fd: 0,
+ chr_hi_fe: 0,
+ latch_lo_fe: false,
+ latch_hi_fe: false,
+ mirroring,
+ // 8 KiB WRAM at $6000-$7FFF (T-60-003c).
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_16k = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let last = total_16k - 1;
+ let bank = if (addr & 0xC000) == 0x8000 {
+ (self.prg_bank as usize) % total_16k
+ } else {
+ last
+ };
+ bank * PRG_BANK_16K + ((addr as usize) & 0x3FFF)
+ }
+
+ fn chr_offset(&mut 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 {
+ let b = if self.latch_lo_fe {
+ self.chr_lo_fe
+ } else {
+ self.chr_lo_fd
+ };
+ (b as usize) % total_4k
+ } else {
+ let b = if self.latch_hi_fe {
+ self.chr_hi_fe
+ } else {
+ self.chr_hi_fd
+ };
+ (b as usize) % total_4k
+ };
+ bank * CHR_BANK_4K + (addr & (CHR_BANK_4K - 1))
+ }
+
+ fn update_latch(&mut self, addr: u16) {
+ match addr & 0x3FF8 {
+ 0x0FD8 => self.latch_lo_fe = false,
+ 0x0FE8 => self.latch_lo_fe = true,
+ 0x1FD8 => self.latch_hi_fe = false,
+ 0x1FE8 => self.latch_hi_fe = true,
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Mmc4 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // T-60-003c (2026-05-17): MMC4 carts (Fire Emblem proper +
+ // Fire Emblem Gaiden + Famicom Wars) include 8 KiB battery-
+ // backed WRAM at $6000-$7FFF. Pre-fix returned 0; FE
+ // Gaiden's save-validation path stalled. Same root cause
+ // as the VRC2/4/6 fix in m022_vrc2.rs / m021_vrc4.rs / m024_vrc6.rs.
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // T-60-003c (2026-05-17): WRAM at $6000-$7FFF (paired with
+ // the read fix above).
+ if (0x6000..=0x7FFF).contains(&addr) {
+ let len = self.prg_ram.len();
+ self.prg_ram[(addr - 0x6000) as usize % len] = value;
+ return;
+ }
+ match addr & 0xF000 {
+ 0xA000 => self.prg_bank = value & 0x0F,
+ 0xB000 => self.chr_lo_fd = value & 0x1F,
+ 0xC000 => self.chr_lo_fe = value & 0x1F,
+ 0xD000 => self.chr_hi_fd = value & 0x1F,
+ 0xE000 => self.chr_hi_fe = value & 0x1F,
+ 0xF000 => {
+ self.mirroring = if value & 1 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ let v = self.chr_rom[off % self.chr_rom.len()];
+ self.update_latch(addr);
+ v
+ }
+ 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ let len = self.chr_rom.len();
+ self.chr_rom[off % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(16 + self.vram.len());
+ out.push(1u8);
+ out.push(self.prg_bank);
+ out.push(self.chr_lo_fd);
+ out.push(self.chr_lo_fe);
+ out.push(self.chr_hi_fd);
+ out.push(self.chr_hi_fe);
+ out.push(u8::from(self.latch_lo_fe));
+ out.push(u8::from(self.latch_hi_fe));
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 9 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_lo_fd = data[2];
+ self.chr_lo_fe = data[3];
+ self.chr_hi_fd = data[4];
+ self.chr_hi_fe = data[5];
+ self.latch_lo_fe = data[6] != 0;
+ self.latch_hi_fe = data[7] != 0;
+ self.mirroring = match data[8] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
+ Ok(())
+ }
+}
diff --git a/crates/rustynes-mappers/src/m011_color_dreams.rs b/crates/rustynes-mappers/src/m011_color_dreams.rs
new file mode 100644
index 00000000..6498a55f
--- /dev/null
+++ b/crates/rustynes-mappers/src/m011_color_dreams.rs
@@ -0,0 +1,188 @@
+//! Color Dreams (mapper 11) -- unlicensed discrete-logic board.
+//!
+//! A single write-anywhere register in `$8000-$FFFF`: the low two bits select
+//! a 32 KiB PRG bank, the high four bits an 8 KiB CHR bank. Because the board
+//! is discrete logic with no write-enable gating, writes are subject to *bus
+//! conflicts* -- the value written is `AND`ed with the byte the PRG ROM is
+//! simultaneously driving onto the data bus at that address.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Color Dreams (Mapper 11).
+pub struct ColorDreams {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl ColorDreams {
+ /// Construct a new Color Dreams mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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(32 * 1024) {
+ return Err(MapperError::Invalid(format!(
+ "Color Dreams PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "Color Dreams CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for ColorDreams {
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if addr < 0x8000 {
+ return 0;
+ }
+ let total_32k = (self.prg_rom.len() / (32 * 1024)).max(1);
+ let bank = (self.prg_bank as usize) % total_32k;
+ let off = bank * 32 * 1024 + (addr as usize - 0x8000);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ // Bus conflict: AND with the current PRG byte at this address.
+ let conflict = self.cpu_read(addr);
+ let v = value & conflict;
+ self.prg_bank = v & 0x03;
+ self.chr_bank = (v >> 4) & 0x0F;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ } else if (0x0000..=0x1FFF).contains(&addr) && self.chr_is_ram {
+ let total_8k = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % total_8k;
+ let off = (bank * CHR_BANK_8K + addr as usize) % self.chr_rom.len();
+ self.chr_rom[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len());
+ out.push(1u8);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn color_dreams_bus_conflict() {
+ let mut prg = vec![0u8; 32 * 1024];
+ // Make ROM byte at $8000 = 0x55 -> AND with 0xFF gives 0x55.
+ prg[0] = 0x55;
+ let m_prg: Box<[u8]> = prg.into_boxed_slice();
+ let chr = vec![0u8; 8 * 1024].into_boxed_slice();
+ let mut m = ColorDreams::new(m_prg, chr, Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0xFF);
+ // Effective value = 0xFF & 0x55 = 0x55. PRG bank = 0x55 & 0x03 = 1.
+ assert_eq!(m.prg_bank, 1);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m013_cprom.rs b/crates/rustynes-mappers/src/m013_cprom.rs
new file mode 100644
index 00000000..07648f1e
--- /dev/null
+++ b/crates/rustynes-mappers/src/m013_cprom.rs
@@ -0,0 +1,168 @@
+//! CPROM (mapper 13) -- Nintendo discrete board with banked CHR-RAM.
+//!
+//! Unusual in that the switchable half is *RAM*, not ROM: PRG is a fixed
+//! 32 KiB with no banking at all, while the upper 4 KiB of the 16 KiB
+//! CHR-RAM is selected by the low two bits of a write-anywhere register.
+//! Used by Videomation.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const CHR_BANK_4K: usize = 0x1000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// CPROM (Mapper 13).
+pub struct Cprom {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>, // 16 KiB total: 4 banks of 4 KiB.
+ vram: Box<[u8]>,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Cprom {
+ /// Construct a new CPROM mapper (NES Time Lord uses this).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(prg_rom: Box<[u8]>, mirroring: Mirroring) -> Result {
+ if prg_rom.len() != 32 * 1024 {
+ return Err(MapperError::Invalid(format!(
+ "CPROM expects 32 KiB PRG, got {} bytes",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; 16 * 1024].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Cprom {
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if addr < 0x8000 {
+ return 0;
+ }
+ self.prg_rom[(addr as usize - 0x8000) % self.prg_rom.len()]
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ self.chr_bank = value & 0x03;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x0FFF => self.chr_ram[addr as usize] = value,
+ 0x1000..=0x1FFF => {
+ let bank = (self.chr_bank as usize) & 0x03;
+ let off = (bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr_ram.len();
+ self.chr_ram[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.chr_ram.len() + self.vram.len());
+ out.push(1u8);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 2 + self.chr_ram.len() + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.chr_bank = data[1];
+ self.chr_ram
+ .copy_from_slice(&data[2..2 + self.chr_ram.len()]);
+ let off = 2 + self.chr_ram.len();
+ self.vram.copy_from_slice(&data[off..off + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cprom_chr_bank_select() {
+ let mut m =
+ Cprom::new(vec![0u8; 32 * 1024].into_boxed_slice(), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x1000, 0xAA); // bank 0
+ m.cpu_write(0x8000, 1);
+ m.ppu_write(0x1000, 0xBB); // bank 1
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.ppu_read(0x1000), 0xAA);
+ m.cpu_write(0x8000, 1);
+ assert_eq!(m.ppu_read(0x1000), 0xBB);
+ }
+}
diff --git a/crates/rustynes-mappers/src/bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs
similarity index 100%
rename from crates/rustynes-mappers/src/bandai_fcg.rs
rename to crates/rustynes-mappers/src/m016_bandai_fcg.rs
diff --git a/crates/rustynes-mappers/src/jaleco_ss88006.rs b/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs
similarity index 100%
rename from crates/rustynes-mappers/src/jaleco_ss88006.rs
rename to crates/rustynes-mappers/src/m018_jaleco_ss88006.rs
diff --git a/crates/rustynes-mappers/src/m019_namco163.rs b/crates/rustynes-mappers/src/m019_namco163.rs
new file mode 100644
index 00000000..2f852845
--- /dev/null
+++ b/crates/rustynes-mappers/src/m019_namco163.rs
@@ -0,0 +1,1220 @@
+//! Namco 163 (mappers 19 and 210) -- banking, the CPU-cycle IRQ counter, and
+//! the on-cart Namco 163 wavetable synthesizer.
+//!
+//! The 163 carries 128 bytes of internal RAM that serve double duty: they
+//! hold the channel register file *and* the wavetable samples themselves,
+//! packed two 4-bit samples per byte. One to eight channels play from that
+//! shared RAM, time-multiplexed -- so enabling more channels does not make
+//! the cart louder, it divides the same output among more voices, which is
+//! why the mix divides by the active channel count.
+//!
+//! Audio is gated behind the `mapper-audio` Cargo feature (default ON); with
+//! it off the register decoders still latch (writes land in the internal RAM
+//! and the address-port auto-increment still advances) so save states remain
+//! portable across feature configurations (ADR 0004). [`Namco163Audio`] is
+//! re-used verbatim by the NSF expansion path (`nsf_expansion.rs`).
+//!
+//! [`NAMCO163_MIX_SCALE`] was recalibrated in v2.1.6 (the previous value was
+//! ~12 dB too quiet). See `docs/apu-2a03.md` §Expansion-audio levels.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Linear scale applied to the channel-count-averaged Namco 163 output (see
+/// [`Namco163::mix_audio`] via the audio struct's `mix`).
+///
+/// Calibrated so a single full-volume (nibble 0↔15, volume 15) N163 square in
+/// 1-channel mode reaches ~6.0x the amplitude of a single full-volume 2A03
+/// pulse — the level Mesen2 (RustyNES's accuracy bar) produces and that no
+/// reference emulator attenuates. Mesen2 `NesSoundMixer::GetOutputVolume`
+/// weights N163 at `output * 20` against the 2A03 pulse DAC of
+/// `95.88*5000/(8128/15+100) ≈ 746.9`; a full 0↔15 square has per-channel
+/// `(sample-8)*volume` swing `225` (from `(0-8)*15 = -120` to `(15-8)*15 =
+/// +105`) which, divided by 1 channel and weighted `*20`, is `4500` — a ratio
+/// of `4500 / 746.9 ≈ 6.03`. Our path is `((sum / n) * scale) / 65536`; for the
+/// same 1-channel full square the normalized swing is `225 * scale / 65536`,
+/// which against the 2A03 pulse's `pulse_table[15] ≈ 0.14882` equals
+/// `225 * 261 / 65536 / 0.14882 ≈ 6.02`. Peak stays representable: a single
+/// full-volume channel reaches `±120 * 261 = ±31320 < i16::MAX`, and the
+/// channel-count division keeps multi-voice sums bounded to the same envelope
+/// (each of `n` voices only drives `1/n` of the output). Before v2.1.6 this was
+/// `64` (≈1.48x — ~12 dB too quiet, an outlier no reference matched). See
+/// `docs/apu-2a03.md` §Expansion-audio levels.
+// Every item below is expansion-audio support: fully implemented and
+// exercised whenever `mapper-audio` is on (the default build is
+// dead-code-warning clean), but unreachable when the feature compiles the
+// audio subsystem out. `allow(dead_code)` ONLY in that configuration —
+// deliberately not `#[cfg]`, so the items still compile and any future
+// non-audio caller keeps working.
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+pub(crate) const NAMCO163_MIX_SCALE: i32 = 261;
+
+/// Namco 163 on-cart wavetable synthesiser.
+///
+/// 1-8 simultaneous channels, each playing a 4-bit wavetable from the
+/// mapper-internal 128-byte sound RAM. Wavetable data shares the same
+/// RAM as the per-channel register file: the wavetable pool conventionally
+/// sits at `$00-$3F` (128 nibble-samples), and channels claim 8-byte
+/// regions at the top of RAM, with channel 8 (the always-enabled channel)
+/// at `$78-$7F` and channel 1 (the lowest priority) at `$40-$47`. When
+/// fewer than 8 channels are enabled, the unused channels' register
+/// regions are reusable as additional wavetable storage.
+///
+/// Register interface (per NESdev wiki, "Namco 163 audio"):
+///
+/// - `$F800-$FFFF` (write): **address port**. Bit 7 = auto-increment
+/// flag; bits 6-0 = 7-bit address into the 128-byte internal RAM.
+/// - `$4800-$4FFF` (read/write): **data port**. Reads/writes the byte
+/// at the latched address. If the auto-increment flag is set, the
+/// latch advances by 1 after each access, *saturating at $7F* (per
+/// the wiki: "stopping at $7F" — does **not** wrap to $00).
+///
+/// Per-channel register layout (8 bytes each; here referenced for the
+/// channel at `$78-$7F` = channel 8, but every channel's 8-byte slot
+/// follows the same offsets):
+///
+/// | Offset | Bits | Field |
+/// |--------|--------|-------------------------------------------------|
+/// | +0 | 7-0 | Frequency low (bits 7-0 of 18-bit freq) |
+/// | +1 | 7-0 | Phase low (bits 7-0 of 24-bit phase accumulator)|
+/// | +2 | 7-0 | Frequency mid (bits 15-8 of freq) |
+/// | +3 | 7-0 | Phase mid (bits 15-8 of phase) |
+/// | +4 | 1-0 | Frequency high (bits 17-16 of freq) |
+/// | +4 | 7-2 | Length encoding: waveform length = `256 - (reg & 0xFC)` 4-bit samples |
+/// | +5 | 7-0 | Phase high (bits 23-16 of phase) |
+/// | +6 | 7-0 | Wave start address, in 4-bit samples (nibbles) |
+/// | +7 | 3-0 | Linear volume (0..=15) |
+/// | +7 | 6-4 | (Channel 8's `$7F` only) `C` field: number of |
+/// | | | enabled channels - 1 (so C=0 → 1 channel, |
+/// | | | C=7 → all 8 channels) |
+///
+/// Update rate: each channel updates every 15 CPU cycles. With `n`
+/// active channels, the chip cycles through them in round-robin, so
+/// per-channel update rate = `CPU_clock / (15 * n)`. We model this as
+/// a 15-cycle prescaler that advances `tick_index` (mod `n`) and
+/// increments only that one channel's phase per tick.
+///
+/// Mixing: per channel, output = `(sample - 8) * volume`, where `sample`
+/// is the 4-bit nibble fetched from RAM at `(wave_addr + (phase >> 16))
+/// mod L`, `L` is the per-channel wave length, and the `-8` bias makes
+/// the output bipolar (range `-120..=+105`). The chip itself does not
+/// mix — channels are output one-at-a-time — but in practice emulators
+/// sum the per-channel outputs and divide by the active channel count
+/// (the convention recommended by the wiki and what Mesen2/FCEUX both
+/// do). The final i16 is scaled to match the headroom VRC6 leaves for
+/// the APU mixer.
+#[cfg(feature = "mapper-audio")]
+#[derive(Clone)]
+pub(crate) struct Namco163Audio {
+ /// 128-byte internal sound RAM. Shared between wavetable samples
+ /// (`$00-$3F` conventionally) and per-channel register file
+ /// (`$40-$7F`).
+ ram: [u8; 128],
+ /// 7-bit address latch (the address the next data-port access
+ /// targets).
+ addr_latch: u8,
+ /// Auto-increment flag from the most recent `$F800-$FFFF` write.
+ /// When set, data-port accesses advance `addr_latch` (saturating at
+ /// `$7F` per the wiki).
+ auto_inc: bool,
+ /// Round-robin tick index: 0..=7. Each 15-cycle tick advances the
+ /// phase of channel `7 - tick_index` (since channel 8, at `$78-$7F`,
+ /// is the *first* channel updated when only one channel is enabled).
+ tick_index: u8,
+ /// 15-cycle prescaler. When it reaches 15, we update the next
+ /// channel and reset.
+ prescaler: u8,
+}
+
+// When the `mapper-audio` feature is OFF, the audio struct still exists
+// (so save-state round-trip and the register-decoder contract stay
+// identical between feature on/off builds) — but reduced to the bare
+// state required for those two paths.
+#[cfg(not(feature = "mapper-audio"))]
+#[derive(Clone)]
+pub(crate) struct Namco163Audio {
+ ram: [u8; 128],
+ addr_latch: u8,
+ auto_inc: bool,
+ tick_index: u8,
+ prescaler: u8,
+}
+
+impl Default for Namco163Audio {
+ fn default() -> Self {
+ Self {
+ ram: [0; 128],
+ addr_latch: 0,
+ auto_inc: false,
+ tick_index: 0,
+ prescaler: 0,
+ }
+ }
+}
+
+impl Namco163Audio {
+ /// Write to the address port (`$F800-$FFFF`). Bit 7 = auto-increment;
+ /// bits 6-0 = 7-bit address into internal RAM.
+ pub(crate) fn write_addr_port(&mut self, value: u8) {
+ self.auto_inc = value & 0x80 != 0;
+ self.addr_latch = value & 0x7F;
+ }
+
+ /// Advance the address latch if auto-increment is enabled. Per the
+ /// wiki, it saturates at `$7F` rather than wrapping back to `$00`.
+ fn step_addr(&mut self) {
+ if self.auto_inc && self.addr_latch < 0x7F {
+ self.addr_latch += 1;
+ }
+ }
+
+ /// Write to the data port (`$4800-$4FFF`). Stores at the latched
+ /// address; advances the latch when auto-increment is set.
+ pub(crate) fn write_data_port(&mut self, value: u8) {
+ let idx = (self.addr_latch & 0x7F) as usize;
+ self.ram[idx] = value;
+ self.step_addr();
+ }
+
+ /// Read from the data port (`$4800-$4FFF`). Returns the byte at the
+ /// latched address; advances the latch when auto-increment is set.
+ pub(crate) fn read_data_port(&mut self) -> u8 {
+ let idx = (self.addr_latch & 0x7F) as usize;
+ let v = self.ram[idx];
+ self.step_addr();
+ v
+ }
+
+ /// Active channel count, derived from bits 6-4 of register `$7F`
+ /// (`C` field): returns `C + 1` in the range `1..=8`.
+ #[cfg(feature = "mapper-audio")]
+ fn channel_count(&self) -> u8 {
+ ((self.ram[0x7F] >> 4) & 0x07) + 1
+ }
+
+ /// Compute the 18-bit frequency value for the channel whose 8-byte
+ /// register slot starts at `base` (i.e. `$78` for channel 8, `$70`
+ /// for channel 7, ..., `$40` for channel 1).
+ #[cfg(feature = "mapper-audio")]
+ fn channel_freq(&self, base: usize) -> u32 {
+ let lo = u32::from(self.ram[base]);
+ let mid = u32::from(self.ram[base + 2]);
+ let hi = u32::from(self.ram[base + 4] & 0x03);
+ lo | (mid << 8) | (hi << 16)
+ }
+
+ /// 24-bit phase accumulator for the channel at `base`.
+ #[cfg(feature = "mapper-audio")]
+ fn channel_phase(&self, base: usize) -> u32 {
+ let lo = u32::from(self.ram[base + 1]);
+ let mid = u32::from(self.ram[base + 3]);
+ let hi = u32::from(self.ram[base + 5]);
+ lo | (mid << 8) | (hi << 16)
+ }
+
+ /// Write back the 24-bit phase to the channel's three phase
+ /// registers. Only bits 23..0 are retained (the value is naturally
+ /// 24-bit; we mask to be safe under wrap-around).
+ #[cfg(feature = "mapper-audio")]
+ fn set_channel_phase(&mut self, base: usize, phase: u32) {
+ let phase = phase & 0x00FF_FFFF;
+ self.ram[base + 1] = (phase & 0xFF) as u8;
+ self.ram[base + 3] = ((phase >> 8) & 0xFF) as u8;
+ self.ram[base + 5] = ((phase >> 16) & 0xFF) as u8;
+ }
+
+ /// Wave length L (in 4-bit samples) for the channel at `base`.
+ /// Per the wiki: `L = 256 - (reg[base+4] & 0xFC)`.
+ #[cfg(feature = "mapper-audio")]
+ fn channel_length(&self, base: usize) -> u32 {
+ 256u32 - u32::from(self.ram[base + 4] & 0xFC)
+ }
+
+ /// Wave start address for the channel at `base` (in nibble units —
+ /// every step of `wave_addr` represents one 4-bit sample, so two
+ /// nibbles per RAM byte).
+ #[cfg(feature = "mapper-audio")]
+ fn channel_wave_addr(&self, base: usize) -> u32 {
+ u32::from(self.ram[base + 6])
+ }
+
+ /// 4-bit linear volume for the channel at `base`.
+ #[cfg(feature = "mapper-audio")]
+ fn channel_volume(&self, base: usize) -> u8 {
+ self.ram[base + 7] & 0x0F
+ }
+
+ /// Resolve the 4-bit nibble at `nibble_addr` in the wavetable pool.
+ /// Bit 0 of the address picks the high or low nibble of the
+ /// corresponding RAM byte: even = low nibble, odd = high nibble.
+ #[cfg(feature = "mapper-audio")]
+ fn fetch_nibble(&self, nibble_addr: u32) -> u8 {
+ let byte = self.ram[((nibble_addr >> 1) & 0x7F) as usize];
+ if nibble_addr & 1 == 0 {
+ byte & 0x0F
+ } else {
+ (byte >> 4) & 0x0F
+ }
+ }
+
+ /// Returns the register-file base address for the i-th enabled
+ /// channel (i = 0 is the always-enabled channel 8 at `$78-$7F`;
+ /// i = 1 is channel 7 at `$70-$77`; ...; i = 7 is channel 1 at
+ /// `$40-$47`).
+ #[cfg(feature = "mapper-audio")]
+ const fn channel_base(i: u8) -> usize {
+ // Channel 8 = $78, channel 7 = $70, ..., channel 1 = $40.
+ // base = 0x78 - i*8.
+ 0x78 - (i as usize) * 8
+ }
+
+ /// Advance one CPU cycle. Every 15 cycles, round-robin to the next
+ /// enabled channel and increment its phase by its 18-bit freq value.
+ /// When the phase exceeds `L * 65536`, wrap around — the integer
+ /// part of `phase >> 16` modulo `L` is the wavetable index.
+ #[cfg(feature = "mapper-audio")]
+ pub(crate) fn clock(&mut self) {
+ self.prescaler = self.prescaler.wrapping_add(1);
+ if self.prescaler < 15 {
+ return;
+ }
+ self.prescaler = 0;
+
+ let n = self.channel_count();
+ // Round-robin within the active set. tick_index counts 0..n.
+ if self.tick_index >= n {
+ self.tick_index = 0;
+ }
+ let ch = self.tick_index;
+ self.tick_index = (self.tick_index + 1) % n;
+
+ let base = Self::channel_base(ch);
+ let freq = self.channel_freq(base);
+ let length = self.channel_length(base);
+ // Phase modulus is L * 2^16 (so that (phase >> 16) mod L stays
+ // in [0, L)). Use 64-bit math to avoid 32-bit overflow when L
+ // is near 256 and freq is near 2^18.
+ let modulus = u64::from(length) << 16;
+ let mut phase = u64::from(self.channel_phase(base));
+ phase = phase.wrapping_add(u64::from(freq));
+ if modulus != 0 {
+ phase %= modulus;
+ }
+ self.set_channel_phase(base, phase as u32);
+ }
+
+ /// Per-channel output sample, bipolar: `(nibble - 8) * volume`,
+ /// range `-120..=+105`.
+ #[cfg(feature = "mapper-audio")]
+ fn channel_output(&self, ch: u8) -> i16 {
+ let base = Self::channel_base(ch);
+ let length = self.channel_length(base);
+ if length == 0 {
+ return 0;
+ }
+ let phase = self.channel_phase(base);
+ let wave_addr = self.channel_wave_addr(base);
+ let index = (phase >> 16) % length;
+ let nibble = self.fetch_nibble(wave_addr + index);
+ // -8 bias makes the output bipolar.
+ let signed = i16::from(nibble) - 8;
+ signed * i16::from(self.channel_volume(base))
+ }
+
+ /// Linear-summed audio output, scaled by [`NAMCO163_MIX_SCALE`] to the
+ /// hardware-accurate level (v2.1.6). Per the wiki, channels are output
+ /// one-at-a-time on hardware; emulators (Mesen2, FCEUX) approximate the
+ /// mix by summing channel outputs and dividing by the number of active
+ /// channels. We do the same, then scale by `261` so a single full-volume
+ /// bipolar channel reaches `±31,320` — just under `i16::MAX` and, through
+ /// the bus's `/65536` external contract, ~6.0x the 2A03 pulse peak (the
+ /// Mesen2 `*20`-weighted `db_n163` level; see [`NAMCO163_MIX_SCALE`]).
+ ///
+ /// NOTE: The channel-count division matches the reference emulators'
+ /// behaviour; the chip's real per-channel time-multiplexed output is
+ /// effectively the same average since each channel only drives the
+ /// output `1/n` of the time. Before v2.1.6 the scale was `64` (~1.48x —
+ /// ~12 dB too quiet).
+ #[cfg(feature = "mapper-audio")]
+ pub(crate) fn mix(&self) -> i16 {
+ let n = self.channel_count();
+ if n == 0 {
+ return 0;
+ }
+ let mut sum: i32 = 0;
+ for ch in 0..n {
+ sum += i32::from(self.channel_output(ch));
+ }
+ // Per-channel range is -120..=+105; the channel-count-averaged sum has
+ // the same envelope. Scale to the Mesen2 `db_n163` level.
+ ((sum / i32::from(n)) * NAMCO163_MIX_SCALE) as i16
+ }
+
+ /// Feature-off shim: the wavetable generator does not advance with
+ /// `mapper-audio` disabled.
+ ///
+ /// Mirrors the gated `clock` above so the shared NSF expansion router
+ /// (`nsf_expansion::NsfExpansion::clock`) can call it unconditionally, the
+ /// same arrangement `Sunsoft5BAudio` and `FdsAudio` already had. Its
+ /// absence broke `--no-default-features` outright: the router clocks every
+ /// present chip with no `cfg` of its own, so with the feature off this was
+ /// a hard `E0599` — the N163 was the one chip in the router missing the
+ /// shim, and `mix` alone was not enough.
+ #[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
+ pub(crate) fn clock(&mut self) {}
+
+ /// `mix_audio` shim for the no-audio build.
+ #[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::unused_self)]
+ pub(crate) fn mix(&self) -> i16 {
+ 0
+ }
+
+ /// Save-state tail layout (kept lock-step with `read_tail`):
+ /// ram[128] : 128
+ /// addr_latch : 1
+ /// auto_inc : 1 (bool)
+ /// tick_index : 1
+ /// prescaler : 1
+ /// -- 132 bytes total --
+ fn write_tail(&self, out: &mut Vec) {
+ out.extend_from_slice(&self.ram);
+ out.push(self.addr_latch & 0x7F);
+ out.push(u8::from(self.auto_inc));
+ out.push(self.tick_index);
+ out.push(self.prescaler);
+ }
+
+ /// Tail size in bytes — see `write_tail`.
+ const TAIL_LEN: usize = 128 + 1 + 1 + 1 + 1;
+
+ fn read_tail(&mut self, src: &[u8]) -> Result<(), MapperError> {
+ if src.len() < Self::TAIL_LEN {
+ return Err(MapperError::Truncated {
+ expected: Self::TAIL_LEN,
+ got: src.len(),
+ });
+ }
+ self.ram.copy_from_slice(&src[0..128]);
+ self.addr_latch = src[128] & 0x7F;
+ self.auto_inc = src[129] != 0;
+ self.tick_index = src[130];
+ self.prescaler = src[131];
+ Ok(())
+ }
+}
+
+/// Namco 163 (Mapper 19). Banking + CPU-cycle IRQ + (gated behind
+/// `mapper-audio`) 1-8 channel wavetable audio.
+pub struct Namco163 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg: [u8; 4], // 8 KiB banks: $8000, $A000, $C000, fixed $E000
+ chr: [u8; 8], // 1 KiB CHR banks
+ nta: [u8; 4], // 1 KiB NTA banks (CIRAM/CHR ROM swappable)
+ mirroring: Mirroring,
+
+ irq_counter: u16,
+ irq_pending: bool,
+
+ /// Audio disable bit (`$E000-$E7FF` bit 6). When set, the
+ /// N163 audio circuitry is silenced — both the per-channel clocks
+ /// stop advancing and `mix_audio` returns 0. Cleared at power-on.
+ sound_disabled: bool,
+ /// Namco 163 on-cart wavetable audio state. Live regardless of the
+ /// `mapper-audio` feature — the register decoders always latch into
+ /// `ram` and the address-port flag/latch (so save states stay
+ /// round-trippable across builds), but `clock()` / `mix()` are only
+ /// driven when the feature is on.
+ audio: Namco163Audio,
+}
+
+impl Namco163 {
+ /// Construct a new Namco 163 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "Namco163 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "Namco163 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ chr_is_ram,
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg: [0, 0, 0, 0],
+ chr: [0; 8],
+ nta: [0; 4],
+ mirroring,
+ irq_counter: 0,
+ irq_pending: false,
+ sound_disabled: false,
+ audio: Namco163Audio::default(),
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last = total_8k - 1;
+ let bank = match addr & 0xE000 {
+ 0x8000 => (self.prg[0] as usize) % total_8k,
+ 0xA000 => (self.prg[1] as usize) % total_8k,
+ 0xC000 => (self.prg[2] as usize) % total_8k,
+ 0xE000 => last,
+ _ => 0,
+ };
+ bank * PRG_BANK_8K + (addr as usize & 0x1FFF)
+ }
+}
+
+impl Mapper for Namco163 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
+ // (the audio hook only exists under the `mapper-audio` feature).
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: true,
+ audio: cfg!(feature = "mapper-audio"),
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // Namco 163 maps `$4800-$4FFF` (sound data port) and
+ // `$5000-$5FFF` (IRQ counter low/high). The `$4020-$47FF`
+ // range is unmapped.
+ (0x4020..=0x47FF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // Audio data port: reads the byte at the latched address in
+ // internal sound RAM, advancing the latch if auto-increment
+ // is set. Decoder runs regardless of `mapper-audio`.
+ 0x4800..=0x4FFF => self.audio.read_data_port(),
+ 0x5000..=0x57FF => {
+ // IRQ counter low.
+ let v = (self.irq_counter & 0xFF) as u8;
+ self.irq_pending = false;
+ v
+ }
+ 0x5800..=0x5FFF => {
+ let v = ((self.irq_counter >> 8) & 0x7F) as u8;
+ self.irq_pending = false;
+ v
+ }
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ // Audio data port: stores at the latched address in internal
+ // sound RAM, advancing the latch if auto-increment is set.
+ // Decoder runs regardless of `mapper-audio`.
+ 0x4800..=0x4FFF => self.audio.write_data_port(value),
+ 0x5000..=0x57FF => {
+ self.irq_counter = (self.irq_counter & 0xFF00) | u16::from(value);
+ self.irq_pending = false;
+ }
+ 0x5800..=0x5FFF => {
+ self.irq_counter =
+ (self.irq_counter & 0x00FF) | ((u16::from(value) & 0x7F) << 8) | 0x8000;
+ self.irq_pending = false;
+ }
+ 0x6000..=0x7FFF => {
+ let off = (addr - 0x6000) as usize % self.prg_ram.len();
+ self.prg_ram[off] = value;
+ }
+ 0x8000..=0xBFFF => {
+ let slot = ((addr - 0x8000) >> 11) as usize; // 4 banks: 8000,8800,9000,9800,A000,...
+ if slot < 8 {
+ self.chr[slot] = value;
+ }
+ }
+ 0xC000..=0xDFFF => {
+ // Additional CHR / NTA bank selects on real hardware.
+ // Not wired up here (the existing Namco163 banking model
+ // pre-dates this audio work — see the comment in
+ // `notify_cpu_cycle`). Audio decoder is unaffected.
+ }
+ // $E000-$E7FF: PRG bank 0 select (bits 0-5) + audio-disable
+ // flag (bit 6). When bit 6 is set, the N163 audio chip is
+ // silenced — see `mix_audio` / `notify_cpu_cycle`.
+ 0xE000..=0xE7FF => {
+ self.prg[0] = value & 0x3F;
+ self.sound_disabled = value & 0x40 != 0;
+ }
+ 0xE800..=0xEFFF => self.prg[1] = value & 0x3F,
+ 0xF000..=0xF7FF => self.prg[2] = value & 0x3F,
+ // $F800-$FFFF: audio address port (bit 7 = auto-increment,
+ // bits 6-0 = 7-bit internal RAM address). On real hardware
+ // this register also gates PRG-RAM writes via the upper
+ // nibble (`0100` enables writes), but no commercially-released
+ // Namco 163 cartridge uses that feature in a way that affects
+ // accuracy, so we model only the audio half here. Decoder
+ // runs regardless of `mapper-audio`.
+ 0xF800..=0xFFFF => self.audio.write_addr_port(value),
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ // N163 audio runs every CPU cycle whenever the chip is not
+ // silenced via the $E000 sound-disable bit. None of the
+ // 8 channel oscillators can be individually halted — only the
+ // active-channel count and per-channel volume gate their effect
+ // on the mix.
+ #[cfg(feature = "mapper-audio")]
+ if !self.sound_disabled {
+ self.audio.clock();
+ }
+
+ if self.irq_counter & 0x8000 != 0 {
+ let low = self.irq_counter & 0x7FFF;
+ if low == 0x7FFF {
+ self.irq_pending = true;
+ } else {
+ self.irq_counter = (self.irq_counter & 0x8000) | (low + 1);
+ }
+ }
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ fn mix_audio(&mut self) -> i32 {
+ if self.sound_disabled {
+ return 0;
+ }
+ i32::from(self.audio.mix())
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
+ let mut info = crate::mapper::MapperDebugInfo {
+ mapper_id: 19,
+ name: "Namco 163".into(),
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ for (i, b) in self.prg.iter().enumerate() {
+ info.prg_banks
+ .push((format!("PRG{i}"), format!("{b:#04x}")));
+ }
+ for (i, b) in self.chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{b:#04x}")));
+ }
+ for (i, b) in self.nta.iter().enumerate() {
+ info.extra.push((format!("NTA{i}"), format!("{b:#04x}")));
+ }
+ info.irq_state
+ .push(("counter".into(), format!("{:#06x}", self.irq_counter)));
+ info.irq_state
+ .push(("pending".into(), format!("{}", self.irq_pending)));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ // v2 (per ADR-0003): strictly additive tail — older v1 readers
+ // tolerate the additional bytes (we encode the audio at the end,
+ // so the core layout is byte-identical to v1).
+ // Audio tail layout:
+ // sound_disabled : 1
+ // audio block : Namco163Audio::TAIL_LEN (132 bytes)
+ // -- 133 bytes total --
+ let mut out = Vec::with_capacity(
+ 32 + self.prg_ram.len() + self.vram.len() + 1 + Namco163Audio::TAIL_LEN,
+ );
+ out.push(2u8); // version
+ out.extend_from_slice(&self.prg);
+ out.extend_from_slice(&self.chr);
+ out.extend_from_slice(&self.nta);
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.irq_counter.to_le_bytes());
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.prg_ram);
+ out.extend_from_slice(&self.vram);
+ // v2 audio tail.
+ out.push(u8::from(self.sound_disabled));
+ self.audio.write_tail(&mut out);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let scalar_len = 1 + 4 + 8 + 4 + 1 + 2 + 1;
+ let core_expected = scalar_len + self.prg_ram.len() + self.vram.len();
+ if data.len() < core_expected {
+ return Err(MapperError::Truncated {
+ expected: core_expected,
+ got: data.len(),
+ });
+ }
+ let version = data[0];
+ if !(1..=2).contains(&version) {
+ return Err(MapperError::UnsupportedVersion(version));
+ }
+ self.prg.copy_from_slice(&data[1..5]);
+ self.chr.copy_from_slice(&data[5..13]);
+ self.nta.copy_from_slice(&data[13..17]);
+ self.mirroring = match data[17] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.irq_counter = u16::from_le_bytes(
+ data[18..20]
+ .try_into()
+ .map_err(|_| MapperError::Invalid("irq_counter".into()))?,
+ );
+ self.irq_pending = data[20] != 0;
+ let mut cur = 21usize;
+ self.prg_ram
+ .copy_from_slice(&data[cur..cur + self.prg_ram.len()]);
+ cur += self.prg_ram.len();
+ self.vram.copy_from_slice(&data[cur..cur + self.vram.len()]);
+ cur += self.vram.len();
+
+ // v2 tail: audio + sound-disable bit. v1 blobs end at the core;
+ // per ADR-0003 we leave the audio at its current state — silent
+ // by default after `new()` — so the older blob loads cleanly
+ // (the caller is responsible for an explicit power-cycle if they
+ // want a fully-clean slate). A v2 blob shorter than the tail is
+ // accepted permissively for the same forward-compat reason VRC6
+ // and FME-7 use.
+ if version == 2 && data.len() >= cur + 1 + Namco163Audio::TAIL_LEN {
+ self.sound_disabled = data[cur] != 0;
+ cur += 1;
+ self.audio
+ .read_tail(&data[cur..cur + Namco163Audio::TAIL_LEN])?;
+ } else if version == 1 {
+ // Reset audio to power-on defaults for clean v1→v2 upgrade.
+ self.sound_disabled = false;
+ self.audio = Namco163Audio::default();
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn namco163_irq_counter() {
+ let mut m = Namco163::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ // Set counter low byte = 0xFFE, then high byte+enable.
+ m.cpu_write(0x5000, 0xFE);
+ m.cpu_write(0x5800, 0xFF); // sets bit 7 & 0x80 of high byte = enable.
+ // Ticks until counter reaches 0x7FFF.
+ for _ in 0..3 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ }
+
+ fn namco163_for_audio() -> Namco163 {
+ Namco163::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap()
+ }
+
+ fn n163_write_ram(m: &mut Namco163, addr: u8, auto_inc: bool, value: u8) {
+ // $F800 = address port (bit 7 = auto-increment, bits 6-0 = addr).
+ let port = (if auto_inc { 0x80 } else { 0x00 }) | (addr & 0x7F);
+ m.cpu_write(0xF800, port);
+ m.cpu_write(0x4800, value);
+ }
+
+ #[test]
+ fn namco163_address_port_latch_and_auto_increment() {
+ let mut m = namco163_for_audio();
+ // Without auto-increment: write 0x05 to addr, then 0x42 to data.
+ // Latch should stay at 0x05.
+ m.cpu_write(0xF800, 0x05);
+ m.cpu_write(0x4800, 0x42);
+ assert_eq!(m.audio.ram[0x05], 0x42);
+ assert_eq!(m.audio.addr_latch, 0x05);
+ assert!(!m.audio.auto_inc);
+
+ // Second write also lands at 0x05 (latch did not advance).
+ m.cpu_write(0x4800, 0x99);
+ assert_eq!(m.audio.ram[0x05], 0x99);
+ assert_eq!(m.audio.addr_latch, 0x05);
+
+ // With auto-increment: write 0x80 | 0x05, then 0x55 → addr 0x05
+ // gets 0x55 and latch advances to 0x06.
+ m.cpu_write(0xF800, 0x80 | 0x05);
+ m.cpu_write(0x4800, 0x55);
+ assert_eq!(m.audio.ram[0x05], 0x55);
+ assert_eq!(m.audio.addr_latch, 0x06);
+ assert!(m.audio.auto_inc);
+
+ // Next data write lands at 0x06.
+ m.cpu_write(0x4800, 0x66);
+ assert_eq!(m.audio.ram[0x06], 0x66);
+ assert_eq!(m.audio.addr_latch, 0x07);
+ }
+
+ #[test]
+ fn namco163_address_port_saturates_at_7f() {
+ // Per the NESdev wiki: the auto-increment "stopping at $7F"
+ // rather than wrapping. Verify by walking the latch up to $7F
+ // and then doing one more data access.
+ let mut m = namco163_for_audio();
+ m.cpu_write(0xF800, 0x80 | 0x7F);
+ m.cpu_write(0x4800, 0xAA); // RAM[0x7F] = 0xAA, latch stays at 0x7F.
+ assert_eq!(m.audio.ram[0x7F], 0xAA);
+ assert_eq!(m.audio.addr_latch, 0x7F);
+ // A second write also lands at 0x7F (saturation, not wrap).
+ m.cpu_write(0x4800, 0xBB);
+ assert_eq!(m.audio.ram[0x7F], 0xBB);
+ assert_eq!(m.audio.addr_latch, 0x7F);
+ assert_eq!(m.audio.ram[0x00], 0x00, "wrap to $00 must not happen");
+ }
+
+ #[test]
+ fn namco163_data_port_read_round_trip() {
+ // Write 0xAB at addr 0x10 with auto-increment, then read it back.
+ // Read also advances the latch.
+ let mut m = namco163_for_audio();
+ m.cpu_write(0xF800, 0x80 | 0x10);
+ m.cpu_write(0x4800, 0xAB);
+ // After the write, latch is at 0x11.
+ // Re-target 0x10 for the read.
+ m.cpu_write(0xF800, 0x80 | 0x10);
+ assert_eq!(m.cpu_read(0x4800), 0xAB);
+ assert_eq!(m.audio.addr_latch, 0x11);
+ }
+
+ #[test]
+ fn namco163_wavetable_nibble_unpacking() {
+ // Byte 0xAB at RAM[0x10] → nibble 0x20 = 0xB (low), nibble 0x21
+ // = 0xA (high). Verifies the wavetable nibble-fetch helper.
+ let mut m = namco163_for_audio();
+ m.cpu_write(0xF800, 0x10);
+ m.cpu_write(0x4800, 0xAB);
+ assert_eq!(m.audio.ram[0x10], 0xAB);
+ #[cfg(feature = "mapper-audio")]
+ {
+ assert_eq!(m.audio.fetch_nibble(0x20), 0x0B);
+ assert_eq!(m.audio.fetch_nibble(0x21), 0x0A);
+ }
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_channel_count_selection() {
+ // Bits 6-4 of register $7F encode "channel count - 1".
+ // C=0 → 1 channel; C=7 → 8 channels.
+ let mut m = namco163_for_audio();
+ for c in 0u8..=7 {
+ n163_write_ram(&mut m, 0x7F, false, c << 4);
+ assert_eq!(
+ m.audio.channel_count(),
+ c + 1,
+ "C={c} should map to {} channels",
+ c + 1
+ );
+ }
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_channel_frequency_assembly() {
+ // Channel 8 lives at $78-$7F. Write freq lo=$78, mid=$7A, hi=$7C.
+ // hi register's bits 7-2 carry the wave length encoding, so we
+ // pack length bits as well to exercise the mask.
+ let mut m = namco163_for_audio();
+ // Lo = 0x34, mid = 0x12, hi-bits = 0x02, length-bits = 0xFC
+ // (length = 256 - 0xFC = 4).
+ n163_write_ram(&mut m, 0x78, false, 0x34);
+ n163_write_ram(&mut m, 0x7A, false, 0x12);
+ n163_write_ram(&mut m, 0x7C, false, 0xFC | 0x02);
+
+ let freq = m.audio.channel_freq(0x78);
+ assert_eq!(freq, 0x02_1234, "freq = hi<<16 | mid<<8 | lo");
+ let length = m.audio.channel_length(0x78);
+ assert_eq!(length, 4);
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_single_channel_constant_output_then_bipolar_swing() {
+ // Channel 0 (the always-enabled channel at $78-$7F) with a
+ // constant wavetable of 0xFF (high nibble 0xF, low nibble 0xF)
+ // and volume 15 should yield output = (15 - 8) * 15 = +105.
+ // Length-1 waveform means the index never moves.
+ let mut m = namco163_for_audio();
+ // Wavetable byte 0x10 = 0xFF → nibble 0x20 = 0xF, 0x21 = 0xF.
+ n163_write_ram(&mut m, 0x10, false, 0xFF);
+ // Channel 8 (the always-enabled, highest-priority channel) regs.
+ // Wave addr = 0x20 (the nibble we filled).
+ // Length encoding: 256 - 0xFC = 4 (chosen to keep the test
+ // robust to phase, since every cycle still reads 0xF).
+ // Volume = 0x0F, channel-count field = 0 (single channel).
+ n163_write_ram(&mut m, 0x7C, false, 0xFC); // length=4, freq-hi=0
+ n163_write_ram(&mut m, 0x7E, false, 0x20); // wave_addr
+ n163_write_ram(&mut m, 0x7F, false, 0x0F); // volume=15, C=0
+
+ let output = m.audio.channel_output(0);
+ assert_eq!(output, (15 - 8) * 15, "+105 expected for nibble=15, vol=15");
+ // Mix returns (sum / 1) * NAMCO163_MIX_SCALE = 105 * 261 = 27405
+ // (v2.1.6 hardware-accurate 6.0x db_n163 level; was 105 * 64).
+ assert_eq!(m.audio.mix(), 105 * NAMCO163_MIX_SCALE as i16);
+
+ // Now swap the wavetable to nibble 0 — output should swing
+ // negative: (0 - 8) * 15 = -120.
+ m.cpu_write(0xF800, 0x10);
+ m.cpu_write(0x4800, 0x00);
+ assert_eq!(m.audio.channel_output(0), (0 - 8) * 15);
+ assert!(m.audio.mix() < 0, "negative samples must yield <0 mix");
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_volume_zero_silences_channel() {
+ // A channel with volume == 0 contributes 0 to the mix
+ // regardless of the wavetable contents.
+ let mut m = namco163_for_audio();
+ n163_write_ram(&mut m, 0x10, false, 0xFF); // wavetable bytes
+ n163_write_ram(&mut m, 0x7C, false, 0xFC); // length=4
+ n163_write_ram(&mut m, 0x7E, false, 0x20); // wave_addr=0x20
+ n163_write_ram(&mut m, 0x7F, false, 0x00); // vol=0, C=0
+ assert_eq!(m.audio.channel_output(0), 0);
+ assert_eq!(m.audio.mix(), 0);
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_longwave_256_sample_wave_phase_wraps_and_reads_full_period() {
+ // The `test_n163_longwave` accuracy criterion: long-period wavetables
+ // (the case several emulators truncate). RustyNES uses the canonical
+ // wave-length formula `L = 256 - (reg[base+4] & 0xFC)` and a 64-bit
+ // phase accumulator wrapped at `L << 16`, so a full 256-sample wave and
+ // a low frequency address the whole period without aliasing.
+ let mut m = namco163_for_audio();
+ // Fill 128 wave-RAM bytes = 256 nibbles with a ramp so every sample
+ // index is distinguishable: nibble[i] = i & 0x0F.
+ for byte in 0u8..0x80 {
+ // low nibble = (2*byte)&0xF, high nibble = (2*byte+1)&0xF.
+ let lo = (2 * byte) & 0x0F;
+ let hi = (2 * byte + 1) & 0x0F;
+ n163_write_ram(&mut m, byte, false, (hi << 4) | lo);
+ }
+ // Channel 8 ($78-$7F). N163 register layout (per Mesen `SoundReg`):
+ // base+0 = freq lo, +2 = freq mid, +4 = freq hi (bits 0-1) + wave
+ // length (bits 2-7), +6 = wave addr, +7 = volume. Set a frequency that
+ // advances the phase by exactly one sample per clock update
+ // (freq = 1<<16, i.e. freq-hi bit set) while keeping the wave length at
+ // the max 256 (`256 - (reg & 0xFC)` with the length bits zero), then
+ // step the wave across its full period and confirm every one of the
+ // 256 sample indices is reached (no early wrap, no aliasing) — the
+ // hallmark long-period behaviour.
+ n163_write_ram(&mut m, 0x78, false, 0x00); // freq lo = 0
+ n163_write_ram(&mut m, 0x7A, false, 0x00); // freq mid = 0
+ n163_write_ram(&mut m, 0x7C, false, 0x01); // freq hi = 1 (-> 0x10000), length bits 0 -> L=256
+ n163_write_ram(&mut m, 0x7E, false, 0x00); // wave_addr = 0
+ n163_write_ram(&mut m, 0x7F, false, 0x0F); // volume=15, channel-count=0
+ assert_eq!(
+ m.audio.channel_length(0x78),
+ 256,
+ "L must be 256, not truncated"
+ );
+ let mut seen = [false; 256];
+ // N163 advances one channel every 15 CPU cycles; 256 samples * 15 = 3840
+ // cycles cover the whole period, plus margin.
+ for _ in 0..(256 * 15 + 15) {
+ let idx = ((m.audio.channel_phase(0x78) >> 16) % 256) as usize;
+ seen[idx] = true;
+ m.audio.clock();
+ }
+ assert!(
+ seen.iter().all(|&s| s),
+ "long-period wave must reach every one of the 256 sample indices"
+ );
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_clock_advances_only_active_channel() {
+ // Two-channel setup: C=1, so channels 8 and 7 (bases $78, $70)
+ // are active. Set freq=0x01_0000 on channel 8 (so each tick
+ // advances phase by 1 << 16) and freq=0 on channel 7. After
+ // 30 CPU cycles (= 2 audio updates), phase[ch=8] should have
+ // advanced exactly once (the round-robin alternates 8/7/8/7...).
+ let mut m = namco163_for_audio();
+ // Channel 8 freq = 0x01_0000 → hi=01, mid=00, lo=00.
+ n163_write_ram(&mut m, 0x78, false, 0x00); // freq lo
+ n163_write_ram(&mut m, 0x7A, false, 0x00); // freq mid
+ // length=4 (256 - 0xFC), freq-hi=01.
+ n163_write_ram(&mut m, 0x7C, false, 0xFC | 0x01);
+ n163_write_ram(&mut m, 0x7F, false, 0x10); // C=1 → 2 channels
+ // Channel 7 freq = 0.
+ n163_write_ram(&mut m, 0x70, false, 0x00);
+ n163_write_ram(&mut m, 0x72, false, 0x00);
+ n163_write_ram(&mut m, 0x74, false, 0xFC);
+
+ // 15 cycles → channel 8 advances by 0x01_0000.
+ for _ in 0..15 {
+ m.notify_cpu_cycle();
+ }
+ let phase_ch8 = m.audio.channel_phase(0x78);
+ // length=4, modulus = 4 << 16 = 0x40000, so 0x10000 stays.
+ assert_eq!(phase_ch8, 0x0001_0000);
+ let phase_ch7 = m.audio.channel_phase(0x70);
+ assert_eq!(phase_ch7, 0, "ch7 must not advance on the first slot");
+
+ // Next 15 cycles → channel 7 advances (by 0, so still 0); ch8
+ // unchanged.
+ for _ in 0..15 {
+ m.notify_cpu_cycle();
+ }
+ assert_eq!(m.audio.channel_phase(0x78), 0x0001_0000);
+ assert_eq!(m.audio.channel_phase(0x70), 0);
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_sound_disable_bit_silences_mix() {
+ // $E000 bit 6 set → audio chip is silenced. Even with a
+ // non-zero wavetable and volume, mix_audio returns 0.
+ let mut m = namco163_for_audio();
+ n163_write_ram(&mut m, 0x10, false, 0xFF);
+ n163_write_ram(&mut m, 0x7C, false, 0xFC);
+ n163_write_ram(&mut m, 0x7E, false, 0x20);
+ n163_write_ram(&mut m, 0x7F, false, 0x0F);
+ assert_ne!(m.mix_audio(), 0);
+ // Set sound-disable: $E000 with bit 6 = 1. Bits 0-5 also write
+ // PRG bank 0; we just need the bit 6.
+ m.cpu_write(0xE000, 0x40);
+ assert!(m.sound_disabled);
+ assert_eq!(m.mix_audio(), 0);
+ // Clearing it re-enables.
+ m.cpu_write(0xE000, 0x00);
+ assert!(!m.sound_disabled);
+ assert_ne!(m.mix_audio(), 0);
+ }
+
+ #[test]
+ fn namco163_save_state_v1_loads_with_audio_defaults() {
+ // A v1 (pre-audio) save-state blob should load on a v2 reader
+ // with audio defaulted to silence (zero RAM, zero phase, zero
+ // latch, sound_disabled=false). Construct a synthetic v1 blob
+ // by hand to exercise the backward-compat path.
+ let mut donor = namco163_for_audio();
+ // Mutate non-audio state so we can verify it round-trips.
+ donor.prg[0] = 0x05;
+ donor.chr[3] = 0x07;
+ donor.nta[1] = 0x02;
+ donor.irq_counter = 0x1234;
+ donor.irq_pending = true;
+ donor.audio.ram[0x40] = 0x99; // would normally serialize in v2
+
+ // Build a v1 blob (no audio tail).
+ let mut blob = Vec::new();
+ blob.push(1u8);
+ blob.extend_from_slice(&donor.prg);
+ blob.extend_from_slice(&donor.chr);
+ blob.extend_from_slice(&donor.nta);
+ blob.push(donor.mirroring as u8);
+ blob.extend_from_slice(&donor.irq_counter.to_le_bytes());
+ blob.push(u8::from(donor.irq_pending));
+ blob.extend_from_slice(&donor.prg_ram);
+ blob.extend_from_slice(&donor.vram);
+
+ let mut target = namco163_for_audio();
+ // Pre-populate target with bogus audio state, then verify it
+ // gets cleared by the v1 load path.
+ target.audio.ram[0x40] = 0xAA;
+ target.audio.addr_latch = 0x55;
+ target.audio.auto_inc = true;
+ target.sound_disabled = true;
+ target.load_state(&blob).unwrap();
+ assert_eq!(target.prg[0], 0x05);
+ assert_eq!(target.chr[3], 0x07);
+ assert_eq!(target.irq_counter, 0x1234);
+ // Audio state should be default (silent).
+ assert_eq!(target.audio.ram, [0u8; 128]);
+ assert_eq!(target.audio.addr_latch, 0);
+ assert!(!target.audio.auto_inc);
+ assert!(!target.sound_disabled);
+ }
+
+ #[test]
+ fn namco163_save_state_v2_round_trip() {
+ // v2 → v2 round-trip preserves the full audio state.
+ let mut donor = namco163_for_audio();
+ n163_write_ram(&mut donor, 0x10, true, 0xAB);
+ n163_write_ram(&mut donor, 0x7F, false, 0x35); // C=3 → 4 channels, vol=5
+ donor.cpu_write(0xE000, 0x40); // sound disable
+ let blob = donor.save_state();
+ assert_eq!(blob[0], 2u8, "v2 tag expected");
+
+ let mut target = namco163_for_audio();
+ target.load_state(&blob).unwrap();
+ assert_eq!(target.audio.ram[0x10], 0xAB);
+ assert_eq!(target.audio.ram[0x7F], 0x35);
+ assert!(target.sound_disabled);
+ // addr_latch after the writes: $7F (we wrote $7F last,
+ // auto_inc=false, so the latch stayed at $7F).
+ assert_eq!(target.audio.addr_latch, 0x7F);
+ }
+
+ #[test]
+ fn namco163_mapper_audio_off_path_latches_state_but_stays_silent() {
+ // Mirrors the Sunsoft 5B feature-off test: the register decoders
+ // run regardless of `mapper-audio`, so writes still land in the
+ // internal RAM and the address-port latch advances. With the
+ // feature off, `notify_cpu_cycle` does not advance any phase
+ // counters and `mix_audio` returns 0.
+ let mut m = namco163_for_audio();
+ // Address-port write + data-port write contract — works with
+ // the feature off, because the decoders are unconditional.
+ m.cpu_write(0xF800, 0x80 | 0x05);
+ m.cpu_write(0x4800, 0x42);
+ assert_eq!(m.audio.ram[0x05], 0x42);
+ assert_eq!(m.audio.addr_latch, 0x06);
+ assert!(m.audio.auto_inc);
+
+ // Phase counters stay at zero whether or not we call clock()
+ // (with the feature off, notify_cpu_cycle skips the clock; with
+ // the feature on, we haven't touched the freq registers so the
+ // phase still doesn't advance from the zero state). Verify the
+ // zero-init invariant directly.
+ for _ in 0..256 {
+ m.notify_cpu_cycle();
+ }
+ // Phase regs are at offsets +1/+3/+5 of each channel slot.
+ for ch_base in (0x40..=0x78).step_by(8) {
+ assert_eq!(m.audio.ram[ch_base + 1], 0, "phase lo @ {ch_base:#x}");
+ assert_eq!(m.audio.ram[ch_base + 3], 0, "phase mid @ {ch_base:#x}");
+ assert_eq!(m.audio.ram[ch_base + 5], 0, "phase hi @ {ch_base:#x}");
+ }
+ }
+}
diff --git a/crates/rustynes-mappers/src/m021_vrc4.rs b/crates/rustynes-mappers/src/m021_vrc4.rs
new file mode 100644
index 00000000..f11a0202
--- /dev/null
+++ b/crates/rustynes-mappers/src/m021_vrc4.rs
@@ -0,0 +1,496 @@
+//! Konami VRC4 (mappers 21, 23, 25).
+//!
+//! Register-compatible with the VRC2 in `m022_vrc2.rs` but *pin-rewired* per
+//! board revision, which is why three mapper numbers describe one chip. That
+//! rewiring is isolated in [`vrc_a_bits`] (duplicated here rather than shared,
+//! matching how the crate treats its other small helpers).
+//!
+//! What VRC4 adds over VRC2 is the VRC IRQ counter -- the scanline/CPU-cycle
+//! counter shared with VRC3, VRC6 and VRC7 -- plus 8 KiB of PRG-RAM on the
+//! save-bearing Konami cartridges. No on-cart audio; see `m024_vrc6.rs` and
+//! `m085_vrc7.rs` for the boards that have it.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Map a VRC2/4 register address to its (a0, a1) register-select pin pair.
+///
+/// Per the nesdev "VRC2 and VRC4" wiki, the iNES mapper number selects
+/// which CPU address lines are wired to the chip's A0/A1 register-select
+/// pins. On real Konami boards the two candidate lines for each pin are
+/// physically tied together, so a write to *either* one drives the pin —
+/// the hardware ORs them. Modelling that OR (rather than picking a single
+/// bit) is what makes submapper-0 iNES-1.0 ROMs decode correctly: e.g.
+/// mapper 23 games write CHR registers at both `$x002/$x003` (A1/A0) and
+/// `$x008/$x00C` (A3/A2), and a single-bit decoder collapses the latter
+/// set onto register 0.
+///
+/// Here `a0` is the chip's *high-nibble* select (register address +1) and
+/// `a1` is the *next-register* select (register address +2), matching how
+/// the callers consume the pair: `slot = a1 ? base+1 : base` and
+/// `low = !a0`. Mapped to CPU address lines per mapper:
+///
+/// | Mapper | a0 (high) driven by | a1 (reg-sel) driven by |
+/// |--------|---------------------|------------------------|
+/// | 21 | A1, A6 | A2, A7 | (VRC4a/c)
+/// | 22 | A1 | A0 | (VRC2a — A0/A1 SWAPPED)
+/// | 23 | A0, A2 | A1, A3 | (VRC4e/f, VRC2b)
+/// | 25 | A1, A3 | A0, A2 | (VRC4b/d, VRC2c — swapped)
+///
+/// VRC2a (mapper 22) and VRC2c (mapper 25) both wire the chip's A0 register
+/// pin to CPU A1 and A1 to CPU A0 (the swap); VRC2b (mapper 23) is straight.
+/// The v2.4.0 fix swapped 25 but left 22 straight, leaving TwinBee 3's BG
+/// tiles scrambled (the sprite slots happened to land right); v2.4.1 swaps 22.
+///
+/// Verified against the per-game register-write traces (Crisis Force /
+/// Akumajou = mapper 23 use offsets $0/$4/$8/$C; Wai Wai World 2 = mapper
+/// 21 use $0/$2/$4/$6; TwinBee 3 = mapper 22 and Goemon Gaiden = mapper 25
+/// use $0/$1/$2/$3). NES 2.0 submappers, when present, pin a single line;
+/// OR-ing the candidate lines is a superset that decodes those correctly
+/// because a given ROM only toggles one of the board-tied lines.
+fn vrc_a_bits(mapper_id: u16, _submapper: u8, addr: u16) -> (bool, bool) {
+ let bit = |n: u16| (addr >> n) & 1 != 0;
+ match mapper_id {
+ 21 => (bit(1) | bit(6), bit(2) | bit(7)),
+ 22 => (bit(1), bit(0)), // VRC2a: A0/A1 SWAPPED (chip A0<-CPU A1)
+ 25 => (bit(1) | bit(3), bit(0) | bit(2)), // VRC2c/VRC4b/d: swapped
+ // Mapper 23 (and any other VRC2/4 fallback).
+ _ => (bit(0) | bit(2), bit(1) | bit(3)),
+ }
+}
+
+/// VRC4 (and treats VRC2 hardware as a no-IRQ subset since the banking
+/// is identical). IRQ counter is 8-bit, clocked per CPU cycle by
+/// default (mode bit selects scanline mode where it ticks every 114
+/// cycles).
+pub struct Vrc4 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_lo: u8,
+ prg_mid: u8,
+ prg_swap: bool, // PRG mode: $9002 bit 1 swaps $8000/$C000.
+ chr: [u8; 8],
+ mirroring: Mirroring,
+ mapper_id: u16,
+ submapper: u8,
+ /// 8 KiB WRAM at $6000-$7FFF. T-60-003b (2026-05-17).
+ prg_ram: Box<[u8]>,
+
+ // IRQ counter state.
+ irq_latch: u8,
+ irq_counter: u8,
+ irq_enabled: bool,
+ irq_enable_after_ack: bool,
+ irq_mode_scanline: bool,
+ /// Sub-cycle prescaler for cycle mode (counts 0..341/3 and bumps
+ /// counter at zero — scanline-equivalent every 113.66 CPU cycles).
+ /// We approximate by counting 341 PPU dots per CPU-cycle group; per
+ /// `notify_cpu_cycle` we increment a CPU-cycle prescaler.
+ irq_prescaler: i32,
+ irq_pending: bool,
+}
+
+impl Vrc4 {
+ /// Construct a new VRC4 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mapper_id: u16,
+ submapper: u8,
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "VRC4 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "VRC4 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_lo: 0,
+ prg_mid: 1,
+ prg_swap: false,
+ chr: [0; 8],
+ mirroring,
+ mapper_id,
+ submapper,
+ // 8 KiB WRAM at $6000-$7FFF (T-60-003b).
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ irq_latch: 0,
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_enable_after_ack: false,
+ irq_mode_scanline: false,
+ irq_prescaler: 341,
+ irq_pending: false,
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last1 = total_8k - 1;
+ let last2 = total_8k.saturating_sub(2);
+ let bank = match (addr & 0xE000, self.prg_swap) {
+ (0x8000, false) => (self.prg_lo as usize) % total_8k,
+ (0x8000, true) => last2,
+ (0xA000, _) => (self.prg_mid as usize) % total_8k,
+ (0xC000, false) => last2,
+ (0xC000, true) => (self.prg_lo as usize) % total_8k,
+ (0xE000, _) => last1,
+ _ => 0,
+ };
+ bank * PRG_BANK_8K + (addr as usize & 0x1FFF)
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let addr = (addr & 0x1FFF) as usize;
+ let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = addr / CHR_BANK_1K;
+ let bank = (self.chr[slot] as usize) % total_1k;
+ bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
+ }
+
+ fn write_chr_reg(&mut self, slot: usize, low: bool, value: u8) {
+ let cur = self.chr[slot];
+ let v = if low {
+ (cur & 0xF0) | (value & 0x0F)
+ } else {
+ (cur & 0x0F) | ((value & 0x1F) << 4)
+ };
+ self.chr[slot] = v;
+ }
+
+ fn clock_irq_counter(&mut self) {
+ if self.irq_counter == 0xFF {
+ self.irq_counter = self.irq_latch;
+ self.irq_pending = true;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_add(1);
+ }
+ }
+}
+
+impl Mapper for Vrc4 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // T-60-003b (2026-05-17): VRC4 carts (Konami's mid-life
+ // mappers — Ganbare Goemon 2, Wai Wai World, etc.) expose
+ // 8KB battery-backed WRAM at $6000-$7FFF. Pre-fix returned
+ // 0; games got stuck-at-uniform-gray validating save data.
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // T-60-003b (2026-05-17): WRAM at $6000-$7FFF (paired with the
+ // read fix above).
+ if (0x6000..=0x7FFF).contains(&addr) {
+ let len = self.prg_ram.len();
+ self.prg_ram[(addr - 0x6000) as usize % len] = value;
+ return;
+ }
+ let (a0, a1) = vrc_a_bits(self.mapper_id, self.submapper, addr);
+ match addr & 0xF000 {
+ 0x8000 => self.prg_lo = value & 0x1F,
+ 0x9000 => match (a0, a1) {
+ (false, false) | (false, true) => {
+ // Mirroring control.
+ self.mirroring = match value & 0x03 {
+ 0 => Mirroring::Vertical,
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::SingleScreenA,
+ _ => Mirroring::SingleScreenB,
+ };
+ }
+ (true, false) | (true, true) => {
+ // PRG mode swap.
+ self.prg_swap = (value & 0x02) != 0;
+ }
+ },
+ 0xA000 => self.prg_mid = value & 0x1F,
+ 0xB000 => self.write_chr_reg(if a1 { 1 } else { 0 }, !a0, value),
+ 0xC000 => self.write_chr_reg(if a1 { 3 } else { 2 }, !a0, value),
+ 0xD000 => self.write_chr_reg(if a1 { 5 } else { 4 }, !a0, value),
+ 0xE000 => self.write_chr_reg(if a1 { 7 } else { 6 }, !a0, value),
+ 0xF000 => match (a0, a1) {
+ (false, false) => {
+ self.irq_latch = (self.irq_latch & 0xF0) | (value & 0x0F);
+ }
+ (true, false) => {
+ self.irq_latch = (self.irq_latch & 0x0F) | ((value & 0x0F) << 4);
+ }
+ (false, true) => {
+ // Control: bit 0 = enable_after_ack, bit 1 = enable now,
+ // bit 2 = mode (1 = scanline mode).
+ self.irq_enable_after_ack = (value & 0x01) != 0;
+ self.irq_enabled = (value & 0x02) != 0;
+ self.irq_mode_scanline = (value & 0x04) != 0;
+ self.irq_pending = false;
+ if self.irq_enabled {
+ self.irq_counter = self.irq_latch;
+ self.irq_prescaler = 341;
+ }
+ }
+ (true, true) => {
+ // Acknowledge.
+ self.irq_pending = false;
+ self.irq_enabled = self.irq_enable_after_ack;
+ }
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ if !self.irq_enabled {
+ return;
+ }
+ if self.irq_mode_scanline {
+ // Tick prescaler at 341/3 PPU cycles per scanline = ~113.66
+ // CPU cycles. Use 341 -= 3 each CPU cycle, reload at 0.
+ self.irq_prescaler -= 3;
+ if self.irq_prescaler <= 0 {
+ self.irq_prescaler += 341;
+ self.clock_irq_counter();
+ }
+ } else {
+ self.clock_irq_counter();
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
+ let mut info = crate::mapper::MapperDebugInfo {
+ mapper_id: self.mapper_id,
+ name: format!("VRC4 (sub {})", self.submapper),
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ info.prg_banks
+ .push(("PRG_lo".into(), format!("{:#04x}", self.prg_lo)));
+ info.prg_banks
+ .push(("PRG_mid".into(), format!("{:#04x}", self.prg_mid)));
+ info.prg_banks
+ .push(("swap".into(), format!("{}", self.prg_swap)));
+ for (i, b) in self.chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{b:#04x}")));
+ }
+ info.irq_state
+ .push(("latch".into(), format!("{:#04x}", self.irq_latch)));
+ info.irq_state
+ .push(("counter".into(), format!("{:#04x}", self.irq_counter)));
+ info.irq_state
+ .push(("enabled".into(), format!("{}", self.irq_enabled)));
+ info.irq_state.push((
+ "scanline_mode".into(),
+ format!("{}", self.irq_mode_scanline),
+ ));
+ info.irq_state
+ .push(("pending".into(), format!("{}", self.irq_pending)));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(40 + self.vram.len());
+ out.push(1u8);
+ out.push(self.prg_lo);
+ out.push(self.prg_mid);
+ out.push(u8::from(self.prg_swap));
+ out.extend_from_slice(&self.chr);
+ out.push(self.mirroring as u8);
+ out.push(self.irq_latch);
+ out.push(self.irq_counter);
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_enable_after_ack));
+ out.push(u8::from(self.irq_mode_scanline));
+ out.extend_from_slice(&self.irq_prescaler.to_le_bytes());
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let scalar_len = 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 4 + 1;
+ let expected = scalar_len + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_lo = data[1];
+ self.prg_mid = data[2];
+ self.prg_swap = data[3] != 0;
+ self.chr.copy_from_slice(&data[4..12]);
+ self.mirroring = match data[12] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.irq_latch = data[13];
+ self.irq_counter = data[14];
+ self.irq_enabled = data[15] != 0;
+ self.irq_enable_after_ack = data[16] != 0;
+ self.irq_mode_scanline = data[17] != 0;
+ self.irq_prescaler = i32::from_le_bytes(
+ data[18..22]
+ .try_into()
+ .map_err(|_| MapperError::Invalid("prescaler".into()))?,
+ );
+ self.irq_pending = data[22] != 0;
+ self.vram.copy_from_slice(&data[23..23 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn vrc4_irq_counter_pending() {
+ let mut m = Vrc4::new(synth(8), synth_chr(8), 21, 1, Mirroring::Vertical).unwrap();
+ // 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);
+ // From counter=latch=0xE, ticks until 0xFF: 0xFF-0xE = 0xF1 ticks
+ // for the wrap, plus one to set pending.
+ for _ in 0..0xF1 + 1 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ }
+}
diff --git a/crates/rustynes-mappers/src/m022_vrc2.rs b/crates/rustynes-mappers/src/m022_vrc2.rs
new file mode 100644
index 00000000..784c39cd
--- /dev/null
+++ b/crates/rustynes-mappers/src/m022_vrc2.rs
@@ -0,0 +1,443 @@
+//! Konami VRC2 (mappers 22, and sub-variants of 23 / 25).
+//!
+//! The VRC2 and VRC4 share a register map that is *identical in decode* but
+//! *rewired at the pins*: each PCB revision ties the two low register-select
+//! address lines to a different pair of CPU address pins, so the same write
+//! reaches a different register depending on the board. That rewiring is the
+//! only real difference between the mapper numbers, and it is isolated in
+//! [`vrc_a_bits`] (duplicated in `m021_vrc4.rs`, as the crate duplicates its
+//! other small shared helpers rather than coupling board modules).
+//!
+//! VRC2 exposes a one-byte CHR latch and, unlike VRC4, has **no IRQ counter**
+//! and no on-cart audio. Its siblings: `m021_vrc4.rs`, `m073_vrc3.rs`,
+//! `m024_vrc6.rs`, `m085_vrc7.rs`, `m075_vrc1.rs`.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Map a VRC2/4 register address to its (a0, a1) register-select pin pair.
+///
+/// Per the nesdev "VRC2 and VRC4" wiki, the iNES mapper number selects
+/// which CPU address lines are wired to the chip's A0/A1 register-select
+/// pins. On real Konami boards the two candidate lines for each pin are
+/// physically tied together, so a write to *either* one drives the pin —
+/// the hardware ORs them. Modelling that OR (rather than picking a single
+/// bit) is what makes submapper-0 iNES-1.0 ROMs decode correctly: e.g.
+/// mapper 23 games write CHR registers at both `$x002/$x003` (A1/A0) and
+/// `$x008/$x00C` (A3/A2), and a single-bit decoder collapses the latter
+/// set onto register 0.
+///
+/// Here `a0` is the chip's *high-nibble* select (register address +1) and
+/// `a1` is the *next-register* select (register address +2), matching how
+/// the callers consume the pair: `slot = a1 ? base+1 : base` and
+/// `low = !a0`. Mapped to CPU address lines per mapper:
+///
+/// | Mapper | a0 (high) driven by | a1 (reg-sel) driven by |
+/// |--------|---------------------|------------------------|
+/// | 21 | A1, A6 | A2, A7 | (VRC4a/c)
+/// | 22 | A1 | A0 | (VRC2a — A0/A1 SWAPPED)
+/// | 23 | A0, A2 | A1, A3 | (VRC4e/f, VRC2b)
+/// | 25 | A1, A3 | A0, A2 | (VRC4b/d, VRC2c — swapped)
+///
+/// VRC2a (mapper 22) and VRC2c (mapper 25) both wire the chip's A0 register
+/// pin to CPU A1 and A1 to CPU A0 (the swap); VRC2b (mapper 23) is straight.
+/// The v2.4.0 fix swapped 25 but left 22 straight, leaving TwinBee 3's BG
+/// tiles scrambled (the sprite slots happened to land right); v2.4.1 swaps 22.
+///
+/// Verified against the per-game register-write traces (Crisis Force /
+/// Akumajou = mapper 23 use offsets $0/$4/$8/$C; Wai Wai World 2 = mapper
+/// 21 use $0/$2/$4/$6; TwinBee 3 = mapper 22 and Goemon Gaiden = mapper 25
+/// use $0/$1/$2/$3). NES 2.0 submappers, when present, pin a single line;
+/// OR-ing the candidate lines is a superset that decodes those correctly
+/// because a given ROM only toggles one of the board-tied lines.
+fn vrc_a_bits(mapper_id: u16, _submapper: u8, addr: u16) -> (bool, bool) {
+ let bit = |n: u16| (addr >> n) & 1 != 0;
+ match mapper_id {
+ 21 => (bit(1) | bit(6), bit(2) | bit(7)),
+ 22 => (bit(1), bit(0)), // VRC2a: A0/A1 SWAPPED (chip A0<-CPU A1)
+ 25 => (bit(1) | bit(3), bit(0) | bit(2)), // VRC2c/VRC4b/d: swapped
+ // Mapper 23 (and any other VRC2/4 fallback).
+ _ => (bit(0) | bit(2), bit(1) | bit(3)),
+ }
+}
+
+/// VRC2 (Mapper 22 + sub-variants of 23/25).
+pub struct Vrc2 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_lo: u8,
+ prg_mid: u8,
+ chr: [u8; 8],
+ mirroring: Mirroring,
+ mapper_id: u16,
+ submapper: u8,
+ /// 8 KiB WRAM at $6000-$7FFF (battery-backed on most Konami carts).
+ /// T-60-003b (2026-05-17).
+ prg_ram: Box<[u8]>,
+}
+
+impl Vrc2 {
+ /// Construct a new VRC2 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mapper_id: u16,
+ submapper: u8,
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "VRC2 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "VRC2 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_lo: 0,
+ prg_mid: 1,
+ chr: [0; 8],
+ mirroring,
+ mapper_id,
+ submapper,
+ // 8 KiB WRAM at $6000-$7FFF (T-60-003b).
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last1 = total_8k - 1;
+ let last2 = total_8k.saturating_sub(2);
+ let bank = match addr & 0xE000 {
+ 0x8000 => (self.prg_lo as usize) % total_8k,
+ 0xA000 => (self.prg_mid as usize) % total_8k,
+ 0xC000 => last2,
+ 0xE000 => last1,
+ _ => 0,
+ };
+ bank * PRG_BANK_8K + (addr as usize & 0x1FFF)
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let addr = (addr & 0x1FFF) as usize;
+ let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = addr / CHR_BANK_1K;
+ // VRC2a (mapper 22) does not connect the low bit of the CHR bank
+ // value: the effective 1 KiB bank is `register >> 1`. Real ROMs
+ // rely on this — e.g. TwinBee 3 writes bank $A8 (168) to a slot of
+ // a 128 KiB (128-bank) CHR-ROM, which is only in range as $54 (84)
+ // after the shift. CHR-RAM carts (chr_is_ram) address linearly,
+ // and only mapper 22 has the dropped-low-bit wiring (mappers 23/25
+ // are routed to the Vrc4 type, but guard on the id regardless).
+ let raw = if self.mapper_id == 22 && !self.chr_is_ram {
+ self.chr[slot] as usize >> 1
+ } else {
+ self.chr[slot] as usize
+ };
+ let bank = raw % total_1k;
+ bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
+ }
+
+ fn write_chr_reg(&mut self, slot: usize, low: bool, value: u8) {
+ let cur = self.chr[slot];
+ let v = if low {
+ (cur & 0xF0) | (value & 0x0F)
+ } else {
+ (cur & 0x0F) | ((value & 0x1F) << 4)
+ };
+ self.chr[slot] = v;
+ }
+}
+
+impl Mapper for Vrc2 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // T-60-003b (2026-05-17): Konami's VRC2 carts include 8KB
+ // battery-backed WRAM at $6000-$7FFF (e.g., Ganbare Goemon 2
+ // reads its save magic from $7E14 area at boot). Pre-fix
+ // returned 0 here; the games' save-validation paths got
+ // stuck-at-uniform-gray as a result. Now reads the
+ // allocated `prg_ram` byte.
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // T-60-003b (2026-05-17): WRAM at $6000-$7FFF (paired with the
+ // read fix above). Without the write path, save data written by
+ // the game is silently dropped on the floor.
+ if (0x6000..=0x7FFF).contains(&addr) {
+ let len = self.prg_ram.len();
+ self.prg_ram[(addr - 0x6000) as usize % len] = value;
+ return;
+ }
+ let (a0, a1) = vrc_a_bits(self.mapper_id, self.submapper, addr);
+ 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,
+ };
+ }
+ 0xA000 => self.prg_mid = value & 0x1F,
+ 0xB000 => {
+ let slot = if a1 { 1 } else { 0 };
+ self.write_chr_reg(slot, !a0, value);
+ }
+ 0xC000 => {
+ let slot = if a1 { 3 } else { 2 };
+ self.write_chr_reg(slot, !a0, value);
+ }
+ 0xD000 => {
+ let slot = if a1 { 5 } else { 4 };
+ self.write_chr_reg(slot, !a0, value);
+ }
+ 0xE000 => {
+ let slot = if a1 { 7 } else { 6 };
+ self.write_chr_reg(slot, !a0, value);
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(20 + self.vram.len());
+ out.push(1u8);
+ out.push(self.prg_lo);
+ out.push(self.prg_mid);
+ out.extend_from_slice(&self.chr);
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 12 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_lo = data[1];
+ self.prg_mid = data[2];
+ self.chr.copy_from_slice(&data[3..11]);
+ self.mirroring = match data[11] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.vram.copy_from_slice(&data[12..12 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn vrc24_a_bits_per_board_pin_rewiring() {
+ // The a0 (high-nibble) and a1 (register-select) pins are wired to
+ // different CPU address lines per mapper number. On real Konami
+ // boards the two candidate lines for each pin are tied together, so
+ // the decode ORs them. Confirmed against per-game register-write
+ // traces (see vrc_a_bits doc comment). Base $8000; only the low
+ // decode bits matter. `(a0, a1)`.
+ //
+ // Mapper 21: a0 = A1|A6, a1 = A2|A7.
+ assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 1)), (true, false));
+ assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 6)), (true, false));
+ assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 2)), (false, true));
+ assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 7)), (false, true));
+ // Mapper 22 (VRC2a): a0 = A1, a1 = A0 (SWAPPED, like VRC2c/m25).
+ assert_eq!(vrc_a_bits(22, 0, 0x8000 | (1 << 1)), (true, false));
+ assert_eq!(vrc_a_bits(22, 0, 0x8000 | (1 << 0)), (false, true));
+ // Mapper 23: a0 = A0|A2, a1 = A1|A3 (Crisis Force uses A2/A3).
+ assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 0)), (true, false));
+ assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 2)), (true, false));
+ assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 1)), (false, true));
+ assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 3)), (false, true));
+ // Mapper 25: a0 = A1|A3, a1 = A0|A2 (swapped).
+ assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 1)), (true, false));
+ assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 3)), (true, false));
+ assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 0)), (false, true));
+ assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 2)), (false, true));
+ }
+
+ #[test]
+ fn vrc2_prg_bank_registers_and_fixed_banks() {
+ // 8 PRG banks (each tagged with its index byte at the bank base).
+ let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
+ // $8000 selects the $8000-$9FFF bank (prg_lo); $A000 selects the
+ // $A000-$BFFF bank (prg_mid). $C000/$E000 are fixed to last-2/last-1.
+ m.cpu_write(0x8000, 3);
+ m.cpu_write(0xA000, 5);
+ assert_eq!(m.cpu_read(0x8000), 3, "prg_lo -> bank 3");
+ assert_eq!(m.cpu_read(0xA000), 5, "prg_mid -> bank 5");
+ assert_eq!(m.cpu_read(0xC000), 6, "fixed -> last-2 (bank 6 of 8)");
+ assert_eq!(m.cpu_read(0xE000), 7, "fixed -> last-1 (bank 7 of 8)");
+ // The 5-bit bank field masks high bits.
+ m.cpu_write(0x8000, 0xE0 | 2);
+ assert_eq!(m.cpu_read(0x8000), 2, "high bits above 5-bit field ignored");
+ }
+
+ #[test]
+ fn vrc2_mirroring_control_register() {
+ let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
+ m.cpu_write(0x9000, 0);
+ assert_eq!(m.mirroring, Mirroring::Vertical);
+ m.cpu_write(0x9000, 1);
+ assert_eq!(m.mirroring, Mirroring::Horizontal);
+ m.cpu_write(0x9000, 2);
+ assert_eq!(m.mirroring, Mirroring::SingleScreenA);
+ m.cpu_write(0x9000, 3);
+ assert_eq!(m.mirroring, Mirroring::SingleScreenB);
+ }
+
+ #[test]
+ fn vrc2_chr_bank_low_high_nibble_split() {
+ // CHR registers are written as low/high nibbles selected by a0, with
+ // the bank slot pair selected by a1. Using VRC2b default wiring
+ // (a0=bit0, a1=bit1), $B000 writes CHR slot 0 (a1=0): low nibble at
+ // a0=0, high nibble at a0=1. Assemble bank 0x12 into slot 0 and read
+ // CHR byte 0 (each CHR bank base is tagged with its index byte).
+ let mut m = Vrc2::new(synth(8), synth_chr(0x20), 23, 3, Mirroring::Vertical).unwrap();
+ // $B000 (a0=0): low nibble = 0x2.
+ m.cpu_write(0xB000, 0x2);
+ // $B001 (a0=1): high nibble = 0x1 -> bank = 0x12.
+ m.cpu_write(0xB001, 0x1);
+ assert_eq!(m.ppu_read(0x0000), 0x12, "CHR slot 0 -> bank 0x12");
+ }
+}
diff --git a/crates/rustynes-mappers/src/m024_vrc6.rs b/crates/rustynes-mappers/src/m024_vrc6.rs
new file mode 100644
index 00000000..d12d31db
--- /dev/null
+++ b/crates/rustynes-mappers/src/m024_vrc6.rs
@@ -0,0 +1,890 @@
+//! Konami VRC6 (mappers 24 and 26) -- banking, the VRC IRQ counter, and the
+//! on-cart VRC6 audio expansion.
+//!
+//! VRC6 is a VRC4-family board plus a three-voice synthesizer: two pulse
+//! channels with a programmable duty threshold (and an "ignore duty" mode
+//! that holds the output at the volume level) and one 8-step sawtooth whose
+//! accumulator produces the ramp. All three run off the CPU clock. The
+//! synthesizer is gated behind the `mapper-audio` Cargo feature (default ON);
+//! with it off the register decoders still latch, so a save state written by
+//! an audio-enabled build still loads (ADR 0004).
+//!
+//! [`VRC6_MIX_SCALE`] is shared with the NSF expansion path
+//! (`nsf_expansion.rs`), which re-uses [`Vrc6Pulse`] / [`Vrc6Saw`] verbatim so
+//! an NSF tune and the cartridge produce bit-identical output.
+//!
+//! See `docs/mappers.md` and `docs/apu-2a03.md` §Expansion-audio levels.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Linear scale applied to the summed VRC6 channel output (see
+/// [`Vrc6::mix_audio`]).
+///
+/// Calibrated so a single full-volume (15) VRC6 pulse reaches ~1.5x the
+/// amplitude of a single full-volume 2A03 pulse — the level the bbbradsmith
+/// `db_vrc6` decibel-comparison ROM and the Mesen2 mixer characterize (Mesen2
+/// `NesSoundMixer::GetOutputVolume` weights VRC6 at `output * 5` against a
+/// 2A03 pulse DAC of `95.88*5000/(8128/15+100) ≈ 746.9`, giving `15*15*5 /
+/// 746.9 ≈ 1.506`). Concretely, one pulse toggling 0↔15 swings the mixer by
+/// `15 * 979 = 14685` raw units; divided by the bus's `/65536` external-audio
+/// normalization that is `0.2241`, versus the 2A03 pulse's `pulse_table[15] ≈
+/// 0.1488` — a ratio of `1.506`. The full three-channel peak stays in range:
+/// `(61 - 30) * 979 = 30349 < i16::MAX`, so a loud Akumajou-Densetsu / Madara
+/// passage never clips. Before v2.1.6 this was `256` (≈0.39x the 2A03 pulse —
+/// ~11.7 dB too quiet). See `docs/apu-2a03.md` §Expansion-audio levels.
+///
+/// `pub(crate)` so the NSF-playback path (`crate::nsf_expansion::Vrc6Exp::mix`)
+/// references the SAME constant as the cartridge path — the two mixers can
+/// never drift apart, guaranteeing an NSF VRC6 tune stays level-matched to a
+/// VRC6 cartridge.
+pub(crate) const VRC6_MIX_SCALE: i16 = 979;
+
+/// VRC6 audio pulse channel state (`$9000-$9002` for pulse 1, `$A000-$A002`
+/// for pulse 2). Period is 12-bit, decrements every CPU cycle. On
+/// underflow, the duty index advances by 1 (mod 16). Output is volume when
+/// duty index <= duty-cycle threshold (or always-on when "ignore duty" mode
+/// is set); zero otherwise.
+#[derive(Clone, Default)]
+pub(crate) struct Vrc6Pulse {
+ /// Bits 0-3: volume (0..=15). Bits 4-6: duty (0..=7, sets the duty-cycle
+ /// threshold). Bit 7: ignore-duty (output always = volume).
+ pub(crate) ctrl: u8,
+ /// 12-bit period reload value.
+ pub(crate) period: u16,
+ /// Channel enable bit (from period-hi bit 7).
+ pub(crate) enabled: bool,
+ /// 12-bit countdown timer.
+ pub(crate) timer: u16,
+ /// 4-bit duty-cycle step (0..=15).
+ pub(crate) step: u8,
+}
+
+impl Vrc6Pulse {
+ /// Clock the timer one CPU cycle. When it underflows, advance the duty
+ /// step and reload from `period`.
+ pub(crate) fn clock(&mut self) {
+ if !self.enabled {
+ return;
+ }
+ if self.timer == 0 {
+ self.timer = self.period;
+ self.step = (self.step + 1) & 0x0F;
+ } else {
+ self.timer -= 1;
+ }
+ }
+
+ /// Current 4-bit unsigned output (0..=15). 0 when disabled.
+ pub(crate) fn output(&self) -> u8 {
+ if !self.enabled {
+ return 0;
+ }
+ let duty = (self.ctrl >> 4) & 0x07;
+ let ignore_duty = (self.ctrl & 0x80) != 0;
+ let volume = self.ctrl & 0x0F;
+ if ignore_duty || self.step <= duty {
+ volume
+ } else {
+ 0
+ }
+ }
+}
+
+/// VRC6 audio sawtooth channel state (`$B000-$B002`). 6-bit accumulator
+/// adds an "accumulator rate" once per CPU cycle. Every 14th underflow,
+/// the high 5 bits of the accumulator are emitted (0..=31) and the
+/// accumulator resets.
+#[derive(Clone, Default)]
+pub(crate) struct Vrc6Saw {
+ /// 6-bit accumulator-rate value (bits 5-0 of `$B000`).
+ pub(crate) rate: u8,
+ /// 12-bit period reload value.
+ pub(crate) period: u16,
+ /// Channel enable bit (from period-hi bit 7).
+ pub(crate) enabled: bool,
+ /// 12-bit countdown timer.
+ pub(crate) timer: u16,
+ /// Internal step counter 0..=13 (every other increment "ticks the
+ /// accumulator"; 7 ticks per cycle = 14 steps).
+ pub(crate) step: u8,
+ /// 8-bit accumulator. Output = accumulator >> 3 (5-bit, 0..=31).
+ pub(crate) acc: u8,
+}
+
+impl Vrc6Saw {
+ pub(crate) fn clock(&mut self) {
+ if !self.enabled {
+ return;
+ }
+ if self.timer == 0 {
+ self.timer = self.period;
+ // Step 0..=13: every 2nd step (1, 3, 5, 7, 9, 11, 13) accumulates.
+ // Step 14 (== reset) zeros the accumulator and rolls step to 0.
+ self.step += 1;
+ if (self.step & 1) == 1 {
+ self.acc = self.acc.wrapping_add(self.rate);
+ }
+ if self.step >= 14 {
+ self.step = 0;
+ self.acc = 0;
+ }
+ } else {
+ self.timer -= 1;
+ }
+ }
+
+ /// 5-bit unsigned output (0..=31).
+ pub(crate) fn output(&self) -> u8 {
+ if !self.enabled {
+ return 0;
+ }
+ self.acc >> 3
+ }
+}
+
+/// VRC6 (Mappers 24 / 26). Audio extension is implemented behind the
+/// `mapper-audio` Cargo feature (default ON).
+pub struct Vrc6 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_16: u8, // 16 KiB bank @ $8000-$BFFF
+ prg_8: u8, // 8 KiB bank @ $C000-$DFFF
+ chr: [u8; 8],
+ mirroring: Mirroring,
+ /// 8 KiB WRAM at $6000-$7FFF (battery-backed on Konami carts).
+ /// T-60-003b (2026-05-17).
+ prg_ram: Box<[u8]>,
+ /// Mapper 24 = VRC6a (a0/a1 = bits 0/1).
+ /// Mapper 26 = VRC6b (a0/a1 = bits 1/0 — swapped).
+ swap_a01: bool,
+
+ irq_latch: u8,
+ irq_counter: u8,
+ irq_enabled: bool,
+ irq_enable_after_ack: bool,
+ irq_mode_scanline: bool,
+ irq_prescaler: i32,
+ irq_pending: bool,
+
+ // Audio extension state.
+ /// `$9003` global audio control. Bit 0 = halt-all; bits 1-2 = freq scale
+ /// shift (0 = ÷1, 1 = ÷16, 2 = ÷256 — implemented by left-shifting the
+ /// effective period). We keep the raw byte and inspect bits at clock time.
+ audio_ctrl: u8,
+ pulse1: Vrc6Pulse,
+ pulse2: Vrc6Pulse,
+ saw: Vrc6Saw,
+}
+
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+impl Vrc6 {
+ /// Construct a new VRC6 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mapper_id: u16,
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "VRC6 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "VRC6 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_16: 0,
+ prg_8: 0,
+ chr: [0; 8],
+ mirroring,
+ // 8 KiB WRAM at $6000-$7FFF (T-60-003b).
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ swap_a01: mapper_id == 26,
+ irq_latch: 0,
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_enable_after_ack: false,
+ irq_mode_scanline: false,
+ irq_prescaler: 341,
+ irq_pending: false,
+ audio_ctrl: 0,
+ pulse1: Vrc6Pulse::default(),
+ pulse2: Vrc6Pulse::default(),
+ saw: Vrc6Saw::default(),
+ })
+ }
+
+ /// Effective period for a pulse/saw channel, taking the global
+ /// `$9003` halt + frequency-scale bits into account.
+ fn effective_period_p(&self, p: &Vrc6Pulse) -> u16 {
+ let shift = match (self.audio_ctrl >> 1) & 0x03 {
+ 0 => 0,
+ 1 => 4,
+ _ => 8,
+ };
+ p.period >> shift
+ }
+
+ fn effective_period_s(&self) -> u16 {
+ let shift = match (self.audio_ctrl >> 1) & 0x03 {
+ 0 => 0,
+ 1 => 4,
+ _ => 8,
+ };
+ self.saw.period >> shift
+ }
+
+ /// Clock all three audio channels one CPU cycle. Called from
+ /// `notify_cpu_cycle` when the `mapper-audio` feature is on.
+ #[cfg(feature = "mapper-audio")]
+ fn clock_audio(&mut self) {
+ // $9003 bit 0 = halt-all. When set, channels do not advance.
+ if (self.audio_ctrl & 0x01) != 0 {
+ return;
+ }
+ // Apply the frequency-scale shift transiently by temporarily
+ // narrowing `period` for the channel clock. We don't mutate the
+ // stored period -- the shift is purely a read-time scaling.
+ let p1_period = self.effective_period_p(&self.pulse1);
+ let p2_period = self.effective_period_p(&self.pulse2);
+ let saw_period = self.effective_period_s();
+ let saved_p1 = self.pulse1.period;
+ let saved_p2 = self.pulse2.period;
+ let saved_saw = self.saw.period;
+ self.pulse1.period = p1_period;
+ self.pulse2.period = p2_period;
+ self.saw.period = saw_period;
+ self.pulse1.clock();
+ self.pulse2.clock();
+ self.saw.clock();
+ self.pulse1.period = saved_p1;
+ self.pulse2.period = saved_p2;
+ self.saw.period = saved_saw;
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last1 = total_8k - 1;
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank16 = (self.prg_16 as usize) & 0x0F;
+ let bank8 = (bank16 << 1) | (((addr & 0x2000) >> 13) as usize);
+ (bank8 % total_8k) * PRG_BANK_8K + (addr as usize & 0x1FFF)
+ }
+ 0xC000..=0xDFFF => {
+ let bank8 = (self.prg_8 as usize) & 0x1F;
+ (bank8 % total_8k) * PRG_BANK_8K + (addr as usize & 0x1FFF)
+ }
+ 0xE000..=0xFFFF => last1 * PRG_BANK_8K + (addr as usize & 0x1FFF),
+ _ => 0,
+ }
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let addr = (addr & 0x1FFF) as usize;
+ let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = addr / CHR_BANK_1K;
+ let bank = (self.chr[slot] as usize) % total_1k;
+ bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
+ }
+
+ fn clock_irq_counter(&mut self) {
+ if self.irq_counter == 0xFF {
+ self.irq_counter = self.irq_latch;
+ self.irq_pending = true;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_add(1);
+ }
+ }
+
+ fn decode_a(&self, addr: u16) -> u8 {
+ let a0 = (addr & 1) != 0;
+ let a1 = (addr & 2) != 0;
+ let (a0, a1) = if self.swap_a01 { (a1, a0) } else { (a0, a1) };
+ u8::from(a0) | (u8::from(a1) << 1)
+ }
+}
+
+impl Mapper for Vrc6 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
+ // (the audio hook only exists under the `mapper-audio` feature).
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: true,
+ audio: cfg!(feature = "mapper-audio"),
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // T-60-003b (2026-05-17): VRC6 carts (Akumajou Densetsu /
+ // Esper Dream 2 / Mouryou Senki Madara) include 8KB
+ // battery-backed WRAM at $6000-$7FFF. Pre-fix returned 0;
+ // Esper Dream 2 + Madara got stuck-at-uniform-gray
+ // validating save data, both bit-identical hash
+ // 89ee4c476c97a325 (the smoking-gun signal that pointed
+ // here per the recovery-session diagnostic at
+ // docs/audit/v1-closeout-progress-2026-05-17.md).
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // T-60-003b (2026-05-17): WRAM at $6000-$7FFF (paired with the
+ // read fix above).
+ if (0x6000..=0x7FFF).contains(&addr) {
+ let len = self.prg_ram.len();
+ self.prg_ram[(addr - 0x6000) as usize % len] = value;
+ return;
+ }
+ let a = self.decode_a(addr);
+ match addr & 0xF000 {
+ 0x8000 => self.prg_16 = value & 0x0F,
+ 0x9000 => match a {
+ // $9000: Pulse 1 control (volume/duty/mode).
+ 0 => self.pulse1.ctrl = value,
+ // $9001: Pulse 1 period low.
+ 1 => {
+ self.pulse1.period = (self.pulse1.period & 0x0F00) | u16::from(value);
+ }
+ // $9002: Pulse 1 period high + enable.
+ 2 => {
+ self.pulse1.period =
+ (self.pulse1.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ self.pulse1.enabled = (value & 0x80) != 0;
+ if !self.pulse1.enabled {
+ self.pulse1.step = 0;
+ }
+ }
+ // $9003: Global audio control (halt + freq scale).
+ _ => self.audio_ctrl = value,
+ },
+ 0xA000 => match a {
+ // $A000: Pulse 2 control.
+ 0 => self.pulse2.ctrl = value,
+ // $A001: Pulse 2 period low.
+ 1 => {
+ self.pulse2.period = (self.pulse2.period & 0x0F00) | u16::from(value);
+ }
+ // $A002: Pulse 2 period high + enable.
+ 2 => {
+ self.pulse2.period =
+ (self.pulse2.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ self.pulse2.enabled = (value & 0x80) != 0;
+ if !self.pulse2.enabled {
+ self.pulse2.step = 0;
+ }
+ }
+ _ => {}
+ },
+ 0xB000 => match a {
+ // $B000: Sawtooth accumulator rate (6-bit).
+ 0 => self.saw.rate = value & 0x3F,
+ // $B001: Sawtooth period low.
+ 1 => {
+ self.saw.period = (self.saw.period & 0x0F00) | u16::from(value);
+ }
+ // $B002: Sawtooth period high + enable.
+ 2 => {
+ self.saw.period = (self.saw.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ self.saw.enabled = (value & 0x80) != 0;
+ if !self.saw.enabled {
+ self.saw.step = 0;
+ self.saw.acc = 0;
+ }
+ }
+ _ => {
+ // $B003: Mirroring + PPU/CPU mode.
+ self.mirroring = match (value >> 2) & 0x03 {
+ 0 => Mirroring::Vertical,
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::SingleScreenA,
+ _ => Mirroring::SingleScreenB,
+ };
+ }
+ },
+ 0xC000 => self.prg_8 = value & 0x1F,
+ 0xD000 => self.chr[a as usize] = value,
+ 0xE000 => self.chr[(a + 4) as usize] = value,
+ 0xF000 => match a {
+ 0 => self.irq_latch = value,
+ 1 => {
+ self.irq_enable_after_ack = (value & 0x01) != 0;
+ self.irq_enabled = (value & 0x02) != 0;
+ self.irq_mode_scanline = (value & 0x04) == 0;
+ if self.irq_enabled {
+ self.irq_counter = self.irq_latch;
+ self.irq_prescaler = 341;
+ }
+ self.irq_pending = false;
+ }
+ 2 => {
+ self.irq_pending = false;
+ self.irq_enabled = self.irq_enable_after_ack;
+ }
+ _ => {}
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ // Audio runs every CPU cycle regardless of IRQ state.
+ #[cfg(feature = "mapper-audio")]
+ self.clock_audio();
+
+ if !self.irq_enabled {
+ return;
+ }
+ if self.irq_mode_scanline {
+ self.irq_prescaler -= 3;
+ if self.irq_prescaler <= 0 {
+ self.irq_prescaler += 341;
+ self.clock_irq_counter();
+ }
+ } else {
+ self.clock_irq_counter();
+ }
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ fn mix_audio(&mut self) -> i32 {
+ // Three channels: pulse1 (4-bit, 0..=15), pulse2 (4-bit, 0..=15),
+ // sawtooth (5-bit, 0..=31). Sum is in 0..=61.
+ //
+ // Per nesdev "VRC6 audio": the three channels are summed digitally,
+ // so a linear sum is the canonical mix. The [`VRC6_MIX_SCALE`] = 979
+ // factor makes a single full-volume pulse ~1.5x the 2A03 pulse (the
+ // hardware/Mesen2/`db_vrc6` level); the full three-channel peak
+ // `(61 - 30) * 979 = 30349` stays below `i16::MAX`.
+ let p1 = i16::from(self.pulse1.output());
+ let p2 = i16::from(self.pulse2.output());
+ let saw = i16::from(self.saw.output());
+ // Center at zero: subtract approx half the peak (~30), then scale by
+ // [`VRC6_MIX_SCALE`] for a hardware-accurate level vs the 2A03 pulse.
+ i32::from(((p1 + p2 + saw) - 30) * VRC6_MIX_SCALE)
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
+ let mapper_id = if self.swap_a01 { 26 } else { 24 };
+ let mut info = crate::mapper::MapperDebugInfo {
+ mapper_id,
+ name: "VRC6".into(),
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ info.prg_banks
+ .push(("PRG16".into(), format!("{:#04x}", self.prg_16)));
+ info.prg_banks
+ .push(("PRG8".into(), format!("{:#04x}", self.prg_8)));
+ for (i, b) in self.chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{b:#04x}")));
+ }
+ info.irq_state
+ .push(("latch".into(), format!("{:#04x}", self.irq_latch)));
+ info.irq_state
+ .push(("counter".into(), format!("{:#04x}", self.irq_counter)));
+ info.irq_state
+ .push(("enabled".into(), format!("{}", self.irq_enabled)));
+ info.irq_state
+ .push(("pending".into(), format!("{}", self.irq_pending)));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ // v2: appends audio state (audio_ctrl + 3 channels) at the end.
+ // Per ADR-0003: strictly additive; older readers ignore the tail.
+ // Channel layout per channel: ctrl(1) + period_lo(1) + period_hi(1)
+ // + enabled(1) + timer_lo(1) + timer_hi(1) + step(1)
+ // = 7 bytes for a pulse channel.
+ // Saw: rate(1) + period_lo(1) + period_hi(1) + enabled(1)
+ // + timer_lo(1) + timer_hi(1) + step(1) + acc(1) = 8 bytes.
+ // Header: audio_ctrl(1).
+ // Total audio tail = 1 + 7 + 7 + 8 = 23 bytes.
+ let mut out = Vec::with_capacity(48 + self.vram.len() + 23);
+ out.push(2u8); // version
+ out.push(self.prg_16);
+ out.push(self.prg_8);
+ out.extend_from_slice(&self.chr);
+ out.push(self.mirroring as u8);
+ out.push(u8::from(self.swap_a01));
+ out.push(self.irq_latch);
+ out.push(self.irq_counter);
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_enable_after_ack));
+ out.push(u8::from(self.irq_mode_scanline));
+ out.extend_from_slice(&self.irq_prescaler.to_le_bytes());
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.vram);
+ // Audio tail (v2).
+ out.push(self.audio_ctrl);
+ Self::write_pulse(&mut out, &self.pulse1);
+ Self::write_pulse(&mut out, &self.pulse2);
+ Self::write_saw(&mut out, &self.saw);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let scalar_len = 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 4 + 1;
+ let core_expected = scalar_len + self.vram.len();
+ if data.len() < core_expected {
+ return Err(MapperError::Truncated {
+ expected: core_expected,
+ got: data.len(),
+ });
+ }
+ let version = data[0];
+ if !(1..=2).contains(&version) {
+ return Err(MapperError::UnsupportedVersion(version));
+ }
+ self.prg_16 = data[1];
+ self.prg_8 = data[2];
+ self.chr.copy_from_slice(&data[3..11]);
+ self.mirroring = match data[11] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.swap_a01 = data[12] != 0;
+ self.irq_latch = data[13];
+ self.irq_counter = data[14];
+ self.irq_enabled = data[15] != 0;
+ self.irq_enable_after_ack = data[16] != 0;
+ self.irq_mode_scanline = data[17] != 0;
+ self.irq_prescaler = i32::from_le_bytes(
+ data[18..22]
+ .try_into()
+ .map_err(|_| MapperError::Invalid("prescaler".into()))?,
+ );
+ self.irq_pending = data[22] != 0;
+ self.vram.copy_from_slice(&data[23..23 + self.vram.len()]);
+
+ // v2 tail (optional even when version == 2, in case the writer is
+ // shorter than expected): audio state. v1 blobs end here; the audio
+ // state stays at defaults.
+ if version == 2 {
+ let tail_off = 23 + self.vram.len();
+ if data.len() < tail_off + 23 {
+ // Not strict: a v2 blob shorter than 23 audio bytes is
+ // accepted; remaining fields default-initialize. This keeps
+ // forward-compat consistent with ADR-0003.
+ return Ok(());
+ }
+ self.audio_ctrl = data[tail_off];
+ Self::read_pulse(&data[tail_off + 1..tail_off + 8], &mut self.pulse1);
+ Self::read_pulse(&data[tail_off + 8..tail_off + 15], &mut self.pulse2);
+ Self::read_saw(&data[tail_off + 15..tail_off + 23], &mut self.saw);
+ }
+ Ok(())
+ }
+}
+
+impl Vrc6 {
+ fn write_pulse(out: &mut Vec, p: &Vrc6Pulse) {
+ out.push(p.ctrl);
+ out.extend_from_slice(&p.period.to_le_bytes());
+ out.push(u8::from(p.enabled));
+ out.extend_from_slice(&p.timer.to_le_bytes());
+ out.push(p.step);
+ }
+
+ fn write_saw(out: &mut Vec, s: &Vrc6Saw) {
+ out.push(s.rate);
+ out.extend_from_slice(&s.period.to_le_bytes());
+ out.push(u8::from(s.enabled));
+ out.extend_from_slice(&s.timer.to_le_bytes());
+ out.push(s.step);
+ out.push(s.acc);
+ }
+
+ fn read_pulse(src: &[u8], p: &mut Vrc6Pulse) {
+ p.ctrl = src[0];
+ p.period = u16::from_le_bytes([src[1], src[2]]);
+ p.enabled = src[3] != 0;
+ p.timer = u16::from_le_bytes([src[4], src[5]]);
+ p.step = src[6] & 0x0F;
+ }
+
+ fn read_saw(src: &[u8], s: &mut Vrc6Saw) {
+ s.rate = src[0] & 0x3F;
+ s.period = u16::from_le_bytes([src[1], src[2]]);
+ s.enabled = src[3] != 0;
+ s.timer = u16::from_le_bytes([src[4], src[5]]);
+ s.step = src[6];
+ s.acc = src[7];
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn vrc6_audio_register_decoders_latch_state() {
+ let mut m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ // Pulse 1 ctrl = 0x8F (ignore-duty + volume 0xF).
+ m.cpu_write(0x9000, 0x8F);
+ // Pulse 1 period = 0x123 with enable bit.
+ m.cpu_write(0x9001, 0x23);
+ m.cpu_write(0x9002, 0x81); // bit 7 = enable, high nibble = 1.
+ assert!(m.pulse1.enabled);
+ assert_eq!(m.pulse1.period, 0x123);
+ assert_eq!(m.pulse1.ctrl, 0x8F);
+
+ // Pulse 2 similar.
+ m.cpu_write(0xA000, 0x07); // duty 0 -> threshold 0; volume 7.
+ m.cpu_write(0xA001, 0x40);
+ m.cpu_write(0xA002, 0x80); // enable, period high nibble 0.
+ assert!(m.pulse2.enabled);
+ assert_eq!(m.pulse2.period, 0x040);
+
+ // Sawtooth.
+ m.cpu_write(0xB000, 0x05); // rate = 5.
+ m.cpu_write(0xB001, 0x20);
+ m.cpu_write(0xB002, 0x80); // enable.
+ assert!(m.saw.enabled);
+ assert_eq!(m.saw.rate, 5);
+ assert_eq!(m.saw.period, 0x020);
+
+ // $B003 still drives mirroring.
+ m.cpu_write(0xB003, 0b0000_0100); // bits 3:2 = 01 -> Horizontal.
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn vrc6_pulse_oscillator_steps_through_duty() {
+ let mut p = Vrc6Pulse {
+ ctrl: 0x4F, // duty = 0b100 (4) so output high while step <= 4.
+ period: 4, // small, ticks fast.
+ enabled: true,
+ timer: 0,
+ step: 0,
+ };
+ // First clock: timer == 0 so we reload and bump step to 1.
+ let mut outputs = Vec::new();
+ for _ in 0..32 {
+ p.clock();
+ outputs.push(p.output());
+ }
+ // We expect a roughly 5/16 duty cycle pattern of volume(15) intervals
+ // separated by zero intervals. Sanity-check both poles appear.
+ assert!(outputs.contains(&0x0F));
+ assert!(outputs.contains(&0));
+ }
+
+ #[test]
+ fn vrc6_sawtooth_emits_ramp() {
+ let mut s = Vrc6Saw {
+ rate: 0x10,
+ period: 2,
+ enabled: true,
+ timer: 0,
+ step: 0,
+ acc: 0,
+ };
+ // Drive long enough to see at least one full 14-step ramp.
+ let mut sampled = Vec::new();
+ for _ in 0..60 {
+ s.clock();
+ sampled.push(s.output());
+ }
+ // Ramp should reach a peak greater than zero and eventually reset.
+ let peak = sampled.iter().copied().max().unwrap();
+ assert!(peak > 0, "saw must emit a non-zero peak");
+ // And it should hit zero (after step >= 14 reset).
+ assert!(sampled.contains(&0));
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ #[test]
+ fn vrc6_mix_audio_is_nonzero_when_active() {
+ let mut m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ // Enable pulse 1 with max volume + ignore-duty mode -> output = 15.
+ m.cpu_write(0x9000, 0x8F);
+ m.cpu_write(0x9001, 0x10);
+ m.cpu_write(0x9002, 0x81);
+ // Tick once so the oscillator advances past the timer == 0 reload.
+ m.clock_audio();
+ let s = m.mix_audio();
+ // Centering subtracts ~30 from a 0..=61 sum, scales by 979 (v2.1.6).
+ // With only p1 = 15 contributing, s = (15 - 30) * 979 = -14685.
+ assert!(s < 0, "mix_audio with only p1 must be below center");
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ #[test]
+ fn vrc6_mix_audio_silent_when_disabled() {
+ let m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ // All channels disabled -> outputs 0 -> sum 0 -> mix = (0 - 30) * 979.
+ // Confirm we land at the documented "center - offset" position.
+ let mut m = m;
+ let s = m.mix_audio();
+ assert_eq!(s, -29370);
+ }
+
+ #[test]
+ fn vrc6_save_state_v2_round_trips_audio() {
+ let mut m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ m.cpu_write(0x9000, 0x8F);
+ m.cpu_write(0x9001, 0x12);
+ m.cpu_write(0x9002, 0x83);
+ m.cpu_write(0xB000, 0x07);
+ let blob = m.save_state();
+ assert_eq!(blob[0], 2, "save_state must bump to version 2");
+
+ let mut m2 = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).expect("v2 round-trip");
+ assert_eq!(m2.pulse1.ctrl, 0x8F);
+ assert_eq!(m2.pulse1.period, 0x312);
+ assert!(m2.pulse1.enabled);
+ assert_eq!(m2.saw.rate, 0x07);
+ }
+
+ #[test]
+ fn vrc6_save_state_loads_v1_blob_with_default_audio() {
+ // ADR-0003 invariant: v2 reader must accept a v1 blob; audio state
+ // defaults to silence (channels disabled, ctrl/period zero).
+ let m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ let mut blob = m.save_state();
+ // Synthesize a "v1 blob" by truncating the audio tail (last 23 bytes)
+ // and rewriting the version byte from 2 -> 1.
+ let tail = 23;
+ blob.truncate(blob.len() - tail);
+ blob[0] = 1;
+ let mut m2 = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
+ m2.cpu_write(0x9000, 0xFF); // perturb pre-load
+ m2.load_state(&blob)
+ .expect("v1 blob must load on v2 reader");
+ // Audio state is unchanged from before load (no v2 tail).
+ // pulse1.ctrl was perturbed and NOT reset, since v1 doesn't carry
+ // audio state. This matches ADR-0003: older blobs don't reset
+ // newer-section state, the caller is responsible for an explicit
+ // reset/power-cycle if they want a clean slate.
+ assert_eq!(m2.pulse1.ctrl, 0xFF);
+ }
+}
diff --git a/crates/rustynes-mappers/src/irem_g101.rs b/crates/rustynes-mappers/src/m032_irem_g101.rs
similarity index 94%
rename from crates/rustynes-mappers/src/irem_g101.rs
rename to crates/rustynes-mappers/src/m032_irem_g101.rs
index 811e02b2..6a30a7a6 100644
--- a/crates/rustynes-mappers/src/irem_g101.rs
+++ b/crates/rustynes-mappers/src/m032_irem_g101.rs
@@ -124,11 +124,18 @@ impl IremG101 {
fn read_prg(&self, addr: u16) -> u8 {
let bank_count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ // `saturating_sub`, not `-`: the constructor admits any non-zero 8 KiB
+ // multiple, so `bank_count == 1` is reachable from a crafted iNES image
+ // and a bare `bank_count - 2` underflows. Release builds survive only
+ // because the wrap happens to land back in range after `% bank_count`;
+ // overflow-checked builds panic outright on an untrusted-ROM read path.
+ // Same fixed-tail idiom as `m088_namco118.rs` / `m004_mmc3.rs`.
+ let fixed_second_last = bank_count.saturating_sub(2);
let slot = (addr >> 13) & 0x03; // 0=$8000,1=$A000,2=$C000,3=$E000
let bank = match slot {
0 => {
if self.prg_swap_mode {
- bank_count - 2 // mode 1: $8000 fixed {-2}
+ fixed_second_last // mode 1: $8000 fixed {-2}
} else {
self.prg_bank[0] as usize // mode 0: $8000 swappable
}
@@ -138,10 +145,10 @@ impl IremG101 {
if self.prg_swap_mode {
self.prg_bank[0] as usize // mode 1: $C000 swappable
} else {
- bank_count - 2 // mode 0: $C000 fixed {-2}
+ fixed_second_last // mode 0: $C000 fixed {-2}
}
}
- _ => bank_count - 1, // $E000 always fixed {-1}
+ _ => bank_count - 1, // $E000 always fixed {-1}: `.max(1)` makes this safe
} % bank_count;
let off = (addr as usize) & (PRG_BANK_8K - 1);
self.prg_rom[bank * PRG_BANK_8K + off]
diff --git a/crates/rustynes-mappers/src/taito_tc0190.rs b/crates/rustynes-mappers/src/m033_taito_tc0190.rs
similarity index 96%
rename from crates/rustynes-mappers/src/taito_tc0190.rs
rename to crates/rustynes-mappers/src/m033_taito_tc0190.rs
index b590fad0..28b60b2e 100644
--- a/crates/rustynes-mappers/src/taito_tc0190.rs
+++ b/crates/rustynes-mappers/src/m033_taito_tc0190.rs
@@ -109,8 +109,12 @@ impl TaitoTc0190 {
let bank = match slot {
0 => self.prg_bank[0] as usize,
1 => self.prg_bank[1] as usize,
- 2 => bank_count - 2, // fixed second-last
- _ => bank_count - 1, // fixed last
+ // `saturating_sub`: a single-bank (8 KiB) PRG image is accepted by
+ // the constructor, and a bare `- 2` underflows on that untrusted-ROM
+ // path (panic under overflow checks; only the later `% bank_count`
+ // saves release builds).
+ 2 => bank_count.saturating_sub(2), // fixed second-last
+ _ => bank_count - 1, // fixed last: `.max(1)` makes this safe
} % bank_count;
let off = (addr as usize) & (PRG_BANK_8K - 1);
self.prg_rom[bank * PRG_BANK_8K + off]
diff --git a/crates/rustynes-mappers/src/m034_bnrom_nina001.rs b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs
new file mode 100644
index 00000000..67242511
--- /dev/null
+++ b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs
@@ -0,0 +1,296 @@
+//! BNROM and NINA-001 (mapper 34) -- two incompatible boards sharing one
+//! mapper number.
+//!
+//! iNES mapper 34 is overloaded. **BNROM** (Nintendo/Irem) has a single
+//! write-anywhere `$8000-$FFFF` register selecting a 32 KiB PRG bank, with
+//! CHR-RAM and no CHR banking. **NINA-001** (AVE) instead decodes three
+//! registers in the PRG-RAM window at `$7FFD-$7FFF` -- one 32 KiB PRG select
+//! and two 4 KiB CHR selects -- and carries CHR-ROM.
+//!
+//! The two are told apart by CHR-ROM presence (a NINA-001 board has it; a
+//! BNROM board cannot), captured in [`M34Variant`].
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const CHR_BANK_4K: usize = 0x1000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 34 variant.
+#[derive(Debug, Clone, Copy)]
+pub enum M34Variant {
+ /// BNROM: PRG-bank-only, no CHR banking.
+ Bnrom,
+ /// NINA-001: PRG bank @ $7FFD, CHR banks @ $7FFE / $7FFF.
+ Nina001,
+}
+
+/// Mapper 34 (BNROM / NINA-001).
+pub struct M34 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ prg_ram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank_lo: u8,
+ chr_bank_hi: u8,
+ variant: M34Variant,
+ mirroring: Mirroring,
+}
+
+impl M34 {
+ /// Construct a new M34 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ variant: M34Variant,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(32 * 1024) {
+ return Err(MapperError::Invalid(format!(
+ "Mapper 34 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "Mapper 34 CHR-ROM size {} is not a multiple of 4 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank_lo: 0,
+ chr_bank_hi: 0,
+ variant,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for M34 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
+ 0x8000..=0xFFFF => {
+ let total_32k = (self.prg_rom.len() / (32 * 1024)).max(1);
+ let bank = (self.prg_bank as usize) % total_32k;
+ self.prg_rom[(bank * 32 * 1024 + (addr as usize - 0x8000)) % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match (self.variant, addr) {
+ (M34Variant::Nina001, 0x7FFD) => self.prg_bank = value & 0x01,
+ (M34Variant::Nina001, 0x7FFE) => self.chr_bank_lo = value & 0x0F,
+ (M34Variant::Nina001, 0x7FFF) => self.chr_bank_hi = value & 0x0F,
+ (_, 0x6000..=0x7FFF) => {
+ let off = (addr - 0x6000) as usize % self.prg_ram.len();
+ self.prg_ram[off] = value;
+ }
+ (M34Variant::Bnrom, 0x8000..=0xFFFF) => self.prg_bank = value,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr.len();
+ self.chr[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(8 + self.prg_ram.len() + self.vram.len());
+ out.push(1u8);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank_lo);
+ out.push(self.chr_bank_hi);
+ out.push(match self.variant {
+ M34Variant::Bnrom => 0,
+ M34Variant::Nina001 => 1,
+ });
+ out.extend_from_slice(&self.prg_ram);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 5 + self.prg_ram.len() + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank_lo = data[2];
+ self.chr_bank_hi = data[3];
+ self.variant = match data[4] {
+ 0 => M34Variant::Bnrom,
+ 1 => M34Variant::Nina001,
+ other => return Err(MapperError::Invalid(format!("variant {other}"))),
+ };
+ let mut cur = 5usize;
+ self.prg_ram
+ .copy_from_slice(&data[cur..cur + self.prg_ram.len()]);
+ cur += self.prg_ram.len();
+ self.vram.copy_from_slice(&data[cur..cur + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PRG_BANK_8K: usize = 0x2000;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr_4k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_4K];
+ for b in 0..banks {
+ v[b * CHR_BANK_4K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m34_bnrom_swap() {
+ let mut m = M34::new(
+ synth(8),
+ Box::new([]),
+ Mirroring::Vertical,
+ M34Variant::Bnrom,
+ )
+ .unwrap();
+ // Default bank 0; $8000 -> 0.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ // Test write with conflict; 32K banks here means bank index 1 -> byte at offset 32K = bank 4 of 8K banks.
+ m.cpu_write(0x8000, 1);
+ // Bank 1 in 32K terms = offset 32K. PRG[32768] = byte 4 of synth(8) = 4.
+ assert_eq!(m.cpu_read(0x8000), 4);
+ }
+
+ #[test]
+ fn m34_nina001_variant_register_layout() {
+ // T-74-001 (Phase 7): NINA-001 (mapper 34 submapper 1) uses a distinct
+ // register layout from BNROM — PRG bank at $7FFD, CHR lo/hi at
+ // $7FFE/$7FFF — and must NOT respond to BNROM's $8000 PRG-bank write.
+ let mut m = M34::new(
+ synth(8),
+ synth_chr_4k(8),
+ Mirroring::Vertical,
+ M34Variant::Nina001,
+ )
+ .unwrap();
+ // PRG bank via $7FFD (1-bit). Bank 1 = 32K offset = 8K-bank 4 = byte 4.
+ m.cpu_write(0x7FFD, 1);
+ assert_eq!(m.cpu_read(0x8000), 4, "NINA-001 PRG bank selects via $7FFD");
+ // A BNROM-style $8000 write must be ignored on NINA-001.
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.cpu_read(0x8000), 4, "$8000 write is ignored on NINA-001");
+ // CHR lo/hi banks via $7FFE / $7FFF (each tagged with its index byte).
+ m.cpu_write(0x7FFE, 2);
+ assert_eq!(m.ppu_read(0x0000), 2, "NINA-001 CHR lo bank via $7FFE");
+ m.cpu_write(0x7FFF, 3);
+ assert_eq!(m.ppu_read(0x1000), 3, "NINA-001 CHR hi bank via $7FFF");
+ }
+}
diff --git a/crates/rustynes-mappers/src/jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs
similarity index 100%
rename from crates/rustynes-mappers/src/jy_asic.rs
rename to crates/rustynes-mappers/src/m035_jy_asic.rs
diff --git a/crates/rustynes-mappers/src/m036_txc_policeman.rs b/crates/rustynes-mappers/src/m036_txc_policeman.rs
new file mode 100644
index 00000000..bedd5679
--- /dev/null
+++ b/crates/rustynes-mappers/src/m036_txc_policeman.rs
@@ -0,0 +1,207 @@
+//! TXC 01-22000 / Policeman (mapper 36).
+//!
+//! A bank-select register reached through the `$4100-$5FFF` expansion window
+//! on address line A8. Unlike the mapper 132 board in
+//! `m132_txc_22211.rs`, it drives its banking registers directly rather than
+//! through the TXC accumulator chip.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 36 (TXC 01-22000 / Policeman).
+pub struct Txc36 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Txc36 {
+ /// Construct a new mapper 36 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 36 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 36 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Txc36 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // Register window $4100-$5FFF is write-only; reads there fall through to
+ // open bus, so the default `cpu_read_unmapped` is correct.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4100..=0x5FFF).contains(&addr) && (addr & 0x0100) != 0 {
+ self.prg_bank = (value >> 4) & 0x0F;
+ self.chr_bank = value & 0x0F;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m36_register_decodes_on_a8() {
+ let mut m = Txc36::new(synth_prg_32k(4), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // $4100 has A8 set: value PPPP_CCCC. 0b0011_1010 -> PRG 3, CHR 10.
+ m.cpu_write(0x4100, 0b0011_1010);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.ppu_read(0x0000), 10);
+ // In-window address with A8 clear ($4200) must not latch.
+ m.cpu_write(0x4200, 0b0000_0001);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m038_bitcorp38.rs b/crates/rustynes-mappers/src/m038_bitcorp38.rs
new file mode 100644
index 00000000..1053556d
--- /dev/null
+++ b/crates/rustynes-mappers/src/m038_bitcorp38.rs
@@ -0,0 +1,205 @@
+//! Bit Corp `UNL-PCI556` (mapper 38) -- Crime Busters.
+//!
+//! A single PRG/CHR latch register at `$7000-$7FFF`, deliberately placed in
+//! the PRG-RAM window so the board needs no `$8000` write decode at all.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+// ===========================================================================
+// Mapper 38 — Bit Corp UNL-PCI556.
+//
+// Single 8-bit latch at $7000-$7FFF. Low 2 bits select a 32 KiB PRG bank;
+// bits 3-2 select an 8 KiB CHR bank. No bus conflicts (the register lives in
+// the $6000-$7FFF window, not in PRG-ROM). Mirroring is header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 38 (Bit Corp `UNL-PCI556`).
+pub struct Bitcorp38 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Bitcorp38 {
+ /// Construct a new mapper 38 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 38 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 38 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Bitcorp38 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x7000..=0x7FFF).contains(&addr) {
+ self.prg_bank = value & 0x03;
+ self.chr_bank = (value >> 2) & 0x03;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m38_latch_selects_prg_and_chr() {
+ let mut m = Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // value 0b0000_1110: PRG = 0b10 = 2, CHR = 0b11 = 3.
+ m.cpu_write(0x7123, 0b0000_1110);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ // Writes outside $7000-$7FFF are ignored.
+ m.cpu_write(0x6000, 0xFF);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ }
+
+ #[test]
+ fn m38_save_state_round_trip() {
+ let mut m = Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x7000, 0b0000_1101); // PRG 1, CHR 3
+ let blob = m.save_state();
+ let mut m2 =
+ Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 1);
+ assert_eq!(m2.ppu_read(0x0000), 3);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m039_subor39.rs b/crates/rustynes-mappers/src/m039_subor39.rs
new file mode 100644
index 00000000..b53c538f
--- /dev/null
+++ b/crates/rustynes-mappers/src/m039_subor39.rs
@@ -0,0 +1,200 @@
+//! Subor / Study & Game 32-in-1 (mapper 39).
+//!
+//! About as simple as a board gets: the whole written byte selects a 32 KiB
+//! PRG bank, with CHR-RAM and hardwired mirroring. No masking, no bus
+//! conflict, no IRQ.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 39 (Subor `BNROM`-like, 32 KiB PRG).
+pub struct Subor39 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Subor39 {
+ /// Construct a new mapper 39 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 39 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 39 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Subor39 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = value;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ self.chr[addr as usize] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(2 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 2 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ let mut cursor = 2;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m39_full_byte_selects_32k() {
+ let mut m = Subor39::new(synth_prg_32k(4), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 2);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // No bus conflict: value sticks regardless of underlying byte.
+ m.cpu_write(0xFFFF, 1);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m041_caltron41.rs b/crates/rustynes-mappers/src/m041_caltron41.rs
new file mode 100644
index 00000000..284e379a
--- /dev/null
+++ b/crates/rustynes-mappers/src/m041_caltron41.rs
@@ -0,0 +1,267 @@
+//! Caltron 6-in-1 (mapper 41).
+//!
+//! Two-stage banking: an outer register at `$6000-$67FF` selects the game and
+//! carries both the mirroring bit and an enable for the inner register, which
+//! is written through `$8000-$FFFF` and supplies the low CHR bits. The inner
+//! write is subject to a bus conflict, and is ignored entirely unless the
+//! outer register has enabled it.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 41 (Caltron 6-in-1).
+pub struct Caltron41 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ outer_chr: u8,
+ inner_chr: u8,
+ inner_enable: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Caltron41 {
+ /// Construct a new Caltron 6-in-1 (mapper 41) board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 41 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 41 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ // The board's mirroring is runtime-controlled; seed from the header's
+ // arrangement so the power-on state matches a sensible default.
+ let horizontal_mirroring = mirroring == Mirroring::Horizontal;
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ outer_chr: 0,
+ inner_chr: 0,
+ inner_enable: false,
+ horizontal_mirroring,
+ })
+ }
+
+ const fn chr_bank(&self) -> u8 {
+ (self.outer_chr << 2) | self.inner_chr
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank() as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+}
+
+impl Mapper for Caltron41 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The outer register sits in $6000-$67FF, which is "mapped" by the default
+ // `cpu_read_unmapped` (>= $6000), so no override is needed there.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.read_prg(addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x6000..=0x67FF => {
+ // Outer register: decoded from address bits, data ignored.
+ let e = ((addr >> 2) & 0x01) as u8;
+ let pp = (addr & 0x03) as u8;
+ self.prg_bank = (e << 2) | pp;
+ self.inner_enable = e != 0;
+ self.outer_chr = ((addr >> 3) & 0x03) as u8;
+ self.horizontal_mirroring = ((addr >> 5) & 0x01) != 0;
+ }
+ 0x8000..=0xFFFF if self.inner_enable => {
+ // Inner CHR register has bus conflicts.
+ let effective = value & self.read_prg(addr);
+ self.inner_chr = effective & 0x03;
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(6 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.outer_chr);
+ out.push(self.inner_chr);
+ out.push(u8::from(self.inner_enable));
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 6 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.outer_chr = data[2];
+ self.inner_chr = data[3];
+ self.inner_enable = data[4] != 0;
+ self.horizontal_mirroring = data[5] != 0;
+ self.vram.copy_from_slice(&data[6..6 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m41_outer_register_decodes_from_address() {
+ let mut m =
+ Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // addr layout 0110 0xxx xxMC CEPP.
+ // Pick $6000 | (M=1<<5) | (CC=0b11<<3) | (E=1<<2) | (PP=0b10).
+ // => A5=1 (horizontal), A4..3 = 0b11 (outer CHR 3), A2 = 1 (E set),
+ // A1..0 = 0b10. PRG = (E<<2)|PP = 0b110 = 6.
+ let addr = 0x6000 | (1 << 5) | (0b11 << 3) | (1 << 2) | 0b10;
+ m.cpu_write(addr, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 6);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // Inner CHR write (E set, so honoured). PRG bytes are 0xFF except at
+ // offset 0; write to $8001 (byte 0xFF) -> no conflict masking.
+ m.cpu_write(0x8001, 0b01); // inner CHR low = 1
+ // CHR bank = (outer 3 << 2) | inner 1 = 13.
+ assert_eq!(m.ppu_read(0x0000), 13);
+ }
+
+ #[test]
+ fn m41_inner_write_gated_by_enable() {
+ let mut m =
+ Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // Outer write with E clear (A2 = 0): PP = 0, outer CHR = 1, E = 0.
+ let addr = 0x6000 | (0b01 << 3); // CC = 1, E = 0, PP = 0
+ m.cpu_write(addr, 0x00);
+ // Inner write must be ignored while disabled.
+ m.cpu_write(0x8001, 0b11);
+ // CHR bank = (outer 1 << 2) | inner 0 = 4.
+ assert_eq!(m.ppu_read(0x0000), 4);
+ }
+
+ #[test]
+ fn m41_inner_chr_has_bus_conflict() {
+ // PRG byte at offset 0 of bank 0 is the bank index (0). Writing the
+ // inner register at $8000 ANDs with that 0 -> inner CHR forced to 0.
+ let mut m =
+ Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // Enable inner (E set), outer CHR 0, PRG bank 0 wraps so offset-0 byte
+ // is 0 (the bank index marker).
+ let addr = 0x6000 | (1 << 2); // E = 1, everything else 0 -> PRG bank 4
+ m.cpu_write(addr, 0x00);
+ // PRG bank is now 4 (E<<2). Offset 0 of bank 4 holds value 4.
+ // Write inner at $8000: data 0b11 AND prg_byte(4 = 0b100) = 0b00.
+ m.cpu_write(0x8000, 0b11);
+ assert_eq!(m.ppu_read(0x0000), 0); // outer 0, inner masked to 0
+ }
+}
diff --git a/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs
new file mode 100644
index 00000000..81ca729d
--- /dev/null
+++ b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs
@@ -0,0 +1,364 @@
+//! Mapper 42 -- Famicom Disk System to cartridge conversion (Bio Miracle
+//! Bokutte Upa, Mario Baby).
+//!
+//! Running an FDS title from a cartridge is harder than it sounds: the game
+//! expects RAM where a cartridge has ROM, and it expects the disk BIOS's timer
+//! IRQ to be ticking. So this board does two unusual things at once -- it maps
+//! an 8 KiB PRG window a plain multicart would not need, and it carries a
+//! free-running CPU-cycle IRQ counter purely to stand in for the BIOS timer the
+//! game is polling.
+//!
+//! The other conversion board is mapper 50, in
+//! `m050_fds_conv_smb2j.rs`. The real FDS is emulated in `fds.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+// ===========================================================================
+// Mmc3Clone — reusable MMC3-style core for the clone boards.
+//
+// The MMC3 register protocol (NES 2.0 mapper 4):
+// $8000 even : bank-select (low 3 bits = R index, bit 6 = PRG mode,
+// bit 7 = CHR mode).
+// $8001 odd : bank-data (the value loaded into the selected R register).
+// $A000 even : mirroring (bit 0: 0 = vertical, 1 = horizontal).
+// $C000 even : IRQ latch (reload value).
+// $C001 odd : IRQ reload (force a reload on the next A12 rise).
+// $E000 even : IRQ disable + acknowledge.
+// $E001 odd : IRQ enable.
+//
+// The A12 IRQ counter clocks on every PPU A12 rising edge: if the counter is 0
+// or a reload is pending, it reloads from the latch; otherwise it decrements.
+// After the update, if the counter is 0 and IRQs are enabled, the IRQ asserts.
+// ===========================================================================
+
+/// Mapper 42 (FDS-to-cart conversion: *Mario Baby* / *Ai Senshi Nicol*).
+pub struct Mapper42 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ prg_ram_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+ irq_counter: u16,
+ irq_enabled: bool,
+ irq_pending: bool,
+}
+
+impl Mapper42 {
+ /// Construct a mapper 42 board.
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG size.
+ 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 42 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_ram_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_pending: false,
+ })
+ }
+
+ fn prg_8k(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+}
+
+impl Mapper for Mapper42 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ match addr {
+ 0x6000..=0x7FFF => self.prg_8k(self.prg_ram_bank as usize, addr),
+ 0x8000..=0x9FFF => self.prg_8k(count.saturating_sub(4), addr),
+ 0xA000..=0xBFFF => self.prg_8k(count.saturating_sub(3), addr),
+ 0xC000..=0xDFFF => self.prg_8k(count.saturating_sub(2), addr),
+ 0xE000..=0xFFFF => self.prg_8k(count.saturating_sub(1), addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xE003 {
+ 0x8000 => self.chr_bank = value & 0x0F,
+ 0xE000 => self.prg_ram_bank = value & 0x0F,
+ 0xE001 => {
+ self.mirroring = if value & 0x08 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ 0xE002 => {
+ self.irq_enabled = value & 0x02 != 0;
+ if !self.irq_enabled {
+ self.irq_pending = false;
+ self.irq_counter = 0;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ let off = addr as usize & (self.chr.len() - 1);
+ self.chr[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ if !self.irq_enabled {
+ return;
+ }
+ self.irq_counter += 1;
+ if self.irq_counter >= 0x8000 {
+ self.irq_counter -= 0x8000;
+ }
+ self.irq_pending = self.irq_counter >= 0x6000;
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(8 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_ram_bank);
+ out.push(self.chr_bank);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.push((self.irq_counter & 0xFF) as u8);
+ out.push((self.irq_counter >> 8) as u8);
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 8 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_ram_bank = data[1];
+ self.chr_bank = data[2];
+ self.mirroring = byte_to_mirroring(data[3], self.mirroring);
+ self.irq_counter = u16::from(data[4]) | (u16::from(data[5]) << 8);
+ self.irq_enabled = data[6] != 0;
+ self.irq_pending = data[7] != 0;
+ let mut cursor = 8;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m42_fixed_tail_and_switchable_window() {
+ let mut m = Mapper42::new(synth_prg_8k(8), synth_chr_8k(2), Mirroring::Vertical).unwrap();
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xA000), 5);
+ assert_eq!(m.cpu_read(0xC000), 6);
+ assert_eq!(m.cpu_read(0xE000), 7);
+ m.cpu_write(0xE000, 3);
+ assert_eq!(m.cpu_read(0x6000), 3);
+ m.cpu_write(0x8000, 1);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn m42_irq_window() {
+ let mut m = Mapper42::new(synth_prg_8k(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xE002, 0x02); // enable
+ let mut fired = false;
+ for _ in 0..0x8000 {
+ m.notify_cpu_cycle();
+ if m.irq_pending() {
+ fired = true;
+ break;
+ }
+ }
+ assert!(fired);
+ m.cpu_write(0xE002, 0x00); // disable + clear
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn m42_save_state_round_trip() {
+ let mut m = Mapper42::new(synth_prg_8k(8), synth_chr_8k(2), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xE000, 2);
+ m.cpu_write(0x8000, 1);
+ m.cpu_write(0xE002, 0x02);
+ m.notify_cpu_cycle();
+ let blob = m.save_state();
+ let mut m2 = Mapper42::new(synth_prg_8k(8), synth_chr_8k(2), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x6000), 2);
+ assert_eq!(m2.ppu_read(0x0000), 1);
+ }
+}
diff --git a/crates/rustynes-mappers/src/taito_tc0690.rs b/crates/rustynes-mappers/src/m048_taito_tc0690.rs
similarity index 97%
rename from crates/rustynes-mappers/src/taito_tc0690.rs
rename to crates/rustynes-mappers/src/m048_taito_tc0690.rs
index c0f80ea0..a2a3953c 100644
--- a/crates/rustynes-mappers/src/taito_tc0690.rs
+++ b/crates/rustynes-mappers/src/m048_taito_tc0690.rs
@@ -135,8 +135,12 @@ impl TaitoTc0690 {
let bank = match slot {
0 => self.prg_bank[0] as usize,
1 => self.prg_bank[1] as usize,
- 2 => bank_count - 2,
- _ => bank_count - 1,
+ // `saturating_sub`: a single-bank (8 KiB) PRG image is accepted by
+ // the constructor, and a bare `- 2` underflows on that untrusted-ROM
+ // path (panic under overflow checks; only the later `% bank_count`
+ // saves release builds).
+ 2 => bank_count.saturating_sub(2),
+ _ => bank_count - 1, // `.max(1)` makes this safe
} % bank_count;
let off = (addr as usize) & (PRG_BANK_8K - 1);
self.prg_rom[bank * PRG_BANK_8K + off]
diff --git a/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs
new file mode 100644
index 00000000..25ee77b1
--- /dev/null
+++ b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs
@@ -0,0 +1,295 @@
+//! Mapper 50 -- Famicom Disk System to cartridge conversion (Super Mario
+//! Bros. 2 / SMB2j pirate).
+//!
+//! Like mapper 42 (`m042_fds_conv_bio_miracle.rs`) it substitutes a
+//! free-running CPU-cycle IRQ counter for the disk BIOS timer the game expects.
+//! It additionally *scrambles* the bank bits of its `$8000` window -- the
+//! written value's bits are permuted before becoming a bank number, a copy
+//! protection measure rather than a technical necessity, and the detail a naive
+//! port gets wrong.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+// ===========================================================================
+// Mmc3Clone — reusable MMC3-style core for the clone boards.
+//
+// The MMC3 register protocol (NES 2.0 mapper 4):
+// $8000 even : bank-select (low 3 bits = R index, bit 6 = PRG mode,
+// bit 7 = CHR mode).
+// $8001 odd : bank-data (the value loaded into the selected R register).
+// $A000 even : mirroring (bit 0: 0 = vertical, 1 = horizontal).
+// $C000 even : IRQ latch (reload value).
+// $C001 odd : IRQ reload (force a reload on the next A12 rise).
+// $E000 even : IRQ disable + acknowledge.
+// $E001 odd : IRQ enable.
+//
+// The A12 IRQ counter clocks on every PPU A12 rising edge: if the counter is 0
+// or a reload is pending, it reloads from the latch; otherwise it decrements.
+// After the update, if the counter is 0 and IRQs are enabled, the IRQ asserts.
+// ===========================================================================
+
+/// Mapper 50 (Alibaba / *SMB2J* alternate FDS-to-cart conversion).
+pub struct Mapper50 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ switch_bank: u8,
+ irq_enabled: bool,
+ irq_counter: u16,
+ irq_pending: bool,
+ mirroring: Mirroring,
+}
+
+impl Mapper50 {
+ /// Construct a mapper 50 board.
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 50 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ switch_bank: 0,
+ irq_enabled: false,
+ irq_counter: 0,
+ irq_pending: false,
+ mirroring,
+ })
+ }
+
+ fn prg_8k(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+}
+
+impl Mapper for Mapper50 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => self.prg_8k(15, addr),
+ 0x8000..=0x9FFF => self.prg_8k(8, addr),
+ 0xA000..=0xBFFF => self.prg_8k(9, addr),
+ 0xC000..=0xDFFF => self.prg_8k(self.switch_bank as usize, addr),
+ 0xE000..=0xFFFF => self.prg_8k(11, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0x4120 {
+ 0x4020 => {
+ self.switch_bank = (value & 0x08) | ((value & 0x01) << 2) | ((value & 0x06) >> 1);
+ }
+ 0x4120 => {
+ // Both enable and disable (re)start the counter from 0 and
+ // clear any pending line; only the enable flag itself differs.
+ // On IRQ-enable this means a fresh enable after a prior fire
+ // counts a full period rather than tripping on a stale counter
+ // / latched IRQ.
+ self.irq_enabled = value & 0x01 != 0;
+ self.irq_pending = false;
+ self.irq_counter = 0;
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ if !self.irq_enabled {
+ return;
+ }
+ self.irq_counter += 1;
+ if self.irq_counter == 0x1000 {
+ self.irq_pending = true;
+ self.irq_enabled = false;
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(6 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.switch_bank);
+ out.push(u8::from(self.irq_enabled));
+ out.push((self.irq_counter & 0xFF) as u8);
+ out.push((self.irq_counter >> 8) as u8);
+ out.push(u8::from(self.irq_pending));
+ 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 = 6 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.switch_bank = data[1];
+ self.irq_enabled = data[2] != 0;
+ self.irq_counter = u16::from(data[3]) | (u16::from(data[4]) << 8);
+ self.irq_pending = data[5] != 0;
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// DiscreteMapper — small hook-free single/dual-register multicart boards
+// (46/51/57/104/120/290/301). 32/16 KiB PRG window + 8 KiB CHR-ROM/RAM.
+// ===========================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m50_fixed_layout_and_scrambled_switch() {
+ let mut m = Mapper50::new(synth_prg_8k(16), &[], Mirroring::Vertical).unwrap();
+ assert_eq!(m.cpu_read(0x6000), 15);
+ assert_eq!(m.cpu_read(0x8000), 8);
+ assert_eq!(m.cpu_read(0xA000), 9);
+ assert_eq!(m.cpu_read(0xE000), 11);
+ // value 0x05 -> (0x05&8)|((0x05&1)<<2)|((0x05&6)>>1) = 0 | 4 | 2 = 6.
+ m.cpu_write(0x4020, 0x05);
+ assert_eq!(m.cpu_read(0xC000), 6);
+ }
+
+ #[test]
+ fn m50_irq_fires_once_then_disables() {
+ let mut m = Mapper50::new(synth_prg_8k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4120, 0x01); // enable
+ for _ in 0..0x1000 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ m.cpu_write(0x4120, 0x00); // disable + ack
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn m50_save_state_round_trip() {
+ let mut m = Mapper50::new(synth_prg_8k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4020, 0x05);
+ m.cpu_write(0x4120, 0x01);
+ m.notify_cpu_cycle();
+ m.ppu_write(0x0007, 0x33);
+ let blob = m.save_state();
+ let mut m2 = Mapper50::new(synth_prg_8k(16), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xC000), 6);
+ assert_eq!(m2.ppu_read(0x0007), 0x33);
+ }
+}
diff --git a/crates/rustynes-mappers/src/rambo1.rs b/crates/rustynes-mappers/src/m064_rambo1.rs
similarity index 100%
rename from crates/rustynes-mappers/src/rambo1.rs
rename to crates/rustynes-mappers/src/m064_rambo1.rs
diff --git a/crates/rustynes-mappers/src/irem_h3001.rs b/crates/rustynes-mappers/src/m065_irem_h3001.rs
similarity index 99%
rename from crates/rustynes-mappers/src/irem_h3001.rs
rename to crates/rustynes-mappers/src/m065_irem_h3001.rs
index 7d8a3e1a..7b9c9f72 100644
--- a/crates/rustynes-mappers/src/irem_h3001.rs
+++ b/crates/rustynes-mappers/src/m065_irem_h3001.rs
@@ -32,7 +32,7 @@
//! the 16-bit reload value into the counter. `$9005`/`$9006` set the reload
//! value only (note `$9005` is the HIGH byte).
//!
-//! Reuses the CPU-cycle IRQ family pattern (`sprint3.rs`, `vrc3.rs`).
+//! Reuses the CPU-cycle IRQ family pattern (`m021_vrc4.rs`, `m073_vrc3.rs`).
#![allow(
clippy::cast_possible_truncation,
diff --git a/crates/rustynes-mappers/src/gxrom.rs b/crates/rustynes-mappers/src/m066_gxrom.rs
similarity index 100%
rename from crates/rustynes-mappers/src/gxrom.rs
rename to crates/rustynes-mappers/src/m066_gxrom.rs
diff --git a/crates/rustynes-mappers/src/sunsoft3.rs b/crates/rustynes-mappers/src/m067_sunsoft3.rs
similarity index 99%
rename from crates/rustynes-mappers/src/sunsoft3.rs
rename to crates/rustynes-mappers/src/m067_sunsoft3.rs
index cab959e2..c0bd2910 100644
--- a/crates/rustynes-mappers/src/sunsoft3.rs
+++ b/crates/rustynes-mappers/src/m067_sunsoft3.rs
@@ -31,7 +31,7 @@
//! `$C800` write toggle so the next `$C800` write is the high byte. Writes to
//! `$D800` do NOT acknowledge the IRQ; only `$8000` (and its mirrors) ack.
//!
-//! Reuses the CPU-cycle IRQ family pattern (`sprint3.rs`, `vrc3.rs`).
+//! Reuses the CPU-cycle IRQ family pattern (`m021_vrc4.rs`, `m073_vrc3.rs`).
#![allow(
clippy::cast_possible_truncation,
diff --git a/crates/rustynes-mappers/src/sunsoft4.rs b/crates/rustynes-mappers/src/m068_sunsoft4.rs
similarity index 100%
rename from crates/rustynes-mappers/src/sunsoft4.rs
rename to crates/rustynes-mappers/src/m068_sunsoft4.rs
diff --git a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs
new file mode 100644
index 00000000..5063ef10
--- /dev/null
+++ b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs
@@ -0,0 +1,1270 @@
+//! Sunsoft FME-7 (mapper 69) -- banking, the CPU-cycle IRQ counter, and the
+//! on-cart Sunsoft 5B audio chip.
+//!
+//! The FME-7 is the mapper ASIC; the 5B is the AY-3-8910-derivative sound
+//! chip packaged with it on the Japanese Gimmick! cartridge. This module owns
+//! both, because the 5B is addressed through the same `$C000`/`$E000`
+//! command/parameter port pair the mapper uses.
+//!
+//! The 5B is three square-wave tone channels, a shared 5-bit LFSR noise
+//! generator, and a shared envelope generator, mixed through a *logarithmic*
+//! DAC ([`SUNSOFT5B_LOG_VOL`]) rather than the linear one a naive port would
+//! use. Shape and absolute level are separately pinned: the step law by a
+//! unit test, the level by [`SUNSOFT5B_MIX_SCALE_NUM`] /
+//! [`SUNSOFT5B_MIX_SCALE_DEN`] against the `db_5b` oracle ROM.
+//!
+//! Audio is gated behind the `mapper-audio` Cargo feature (default ON); with
+//! it off the register decoders still latch and the oscillators freeze, so a
+//! save state written by an audio-enabled build still loads (ADR 0004).
+//! [`Sunsoft5BAudio`] is re-used verbatim by the NSF expansion path
+//! (`nsf_expansion.rs`).
+//!
+//! See `docs/mappers.md` and `docs/apu-2a03.md` §Expansion-audio levels.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// 16-entry logarithmic volume DAC, ~3 dB per 4-bit step (= 1.5 dB per
+/// 5-bit step in the underlying chip). Peak chosen so that three channels
+/// summed at maximum volume stay comfortably inside the `i16` headroom the
+/// APU mixer expects.
+///
+/// This table is the DAC **shape** only — each step is `1.1885^2 ≈ 1.4126x`,
+/// the +1.5 dB×2 logarithmic law; `LUT[12] = 668`, `LUT[15] = 1882`,
+/// cross-checked against Mesen2's `Sunsoft5bAudio::_volumeLut` `[63, 177]` and
+/// tetanes. The absolute mixer **level** lives in
+/// [`SUNSOFT5B_MIX_SCALE_NUM`], deliberately separate so each can be pinned by
+/// its own oracle: the shape by
+/// `sunsoft5b_volume_dac_follows_logarithmic_step_law` (a unit test on these
+/// ratios), the level by `level_db_5b` (the `db_5b` comparison ROM).
+///
+/// Our entries are a finer scaling of the same law than Mesen2's `uint8_t`
+/// table, which truncates hard at the bottom (its `LUT[1]` is `1`). Keeping the
+/// finer table preserves the step ratios that the unit test asserts.
+///
+/// HISTORY (v2.1.6 → v2.2.3): the absolute level used to be an explicit,
+/// documented gap — not because the value was unknown but because
+/// `Mapper::mix_audio` returned `i16` and the correct value does not fit. A1
+/// widened that return to `i32` and calibrated the level; see
+/// [`SUNSOFT5B_MIX_SCALE_NUM`] and `docs/accuracy-ledger.md`.
+///
+/// Per the NESdev "Sunsoft 5B audio" page, the chip's DAC has a 1.5 dB
+/// step on the 5-bit signal. Because the wiki specifies that envelope
+/// level `e` is equivalent to 4-bit volume `e >> 1` (with both `e=0` and
+/// `e=1` mapping to silence), a 16-entry table indexed by the 4-bit
+/// equivalent is sufficient — equivalent to a 32-entry table where each
+/// even/odd pair shares the same amplitude.
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+const SUNSOFT5B_LOG_VOL: [i32; 16] = [
+ 0, 15, 21, 30, 42, 59, 84, 119, 168, 237, 335, 473, 668, 944, 1333, 1882,
+];
+
+/// Mixed centering bias: subtracted from the scaled linear sum before emitting
+/// the i32 sample. We use a *constant zero* — the APU mixer's chained
+/// high-pass filters (90 Hz / 440 Hz, see `rustynes-apu::mixer::OnePole`)
+/// remove any steady DC component downstream, and the 5B's linear sum
+/// can swing from 0 (all channels muted) up to ~104 k (three channels at
+/// peak volume + tone high, post-[`SUNSOFT5B_MIX_SCALE_NUM`]). Keeping the
+/// constant named here makes a future numerical bias easy to add if
+/// AccuracyCoin's mixed-output tests ever ask for it.
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+const SUNSOFT5B_DC_BIAS: i32 = 0;
+
+/// v2.2.3 (A1) — absolute mixer level for the 5B, as a rational
+/// `NUM / DEN = 2549 / 138 ≈ 18.471`.
+///
+/// [`SUNSOFT5B_LOG_VOL`] carries the DAC *shape* (the +1.5 dB x2 law); this
+/// carries the *level*, the same split `VRC6_MIX_SCALE` /
+/// `NAMCO163_MIX_SCALE` / the MMC5 `650/40` pair use. Separating them is what
+/// lets the shape stay pinned by its own unit test while the level is pinned
+/// by a ROM oracle.
+///
+/// **Target, derived from Mesen2 (the project's accuracy bar) rather than from
+/// our own prior numbers.** In `NesSoundMixer::GetOutputVolume` a full-volume
+/// 2A03 square is `(95.88 * 5000) / (8128/15 + 100) = 746.9` units, and the 5B
+/// is summed with weight `* 15` over `Sunsoft5bAudio::_volumeLut`
+/// (`= (uint8_t)1.1885^(2i)`, so `LUT[12] = 63`, `LUT[15] = 177`). The
+/// `db_5b` ROM compares a **volume-12** 5B square against that square:
+///
+/// ```text
+/// volume 12: 63 * 15 / 746.9 = 1.265x <- the db_5b oracle target
+/// volume 15: 177 * 15 / 746.9 = 3.554x <- full-scale, the i16 blocker
+/// ```
+///
+/// This independently reproduces the ~1.27x / ~3.56x figures the accuracy
+/// ledger recorded when the calibration was deferred. The NESdev wiki and the
+/// in-repo technical references describe the chip but pin no absolute level —
+/// expansion-audio levels are a mixer convention, not a hardware spec, which
+/// is why the reference emulator is the oracle here.
+///
+/// The scale itself is measured, not computed: with the shape table above and
+/// the bus's `/65536` contract, `db_5b` measured `0.0685x` before this change,
+/// so `1.2652 / 0.0685 = 18.471`. That is the same measure-then-fix method
+/// `NAMCO163_MIX_SCALE` used for its ~12 dB correction.
+///
+/// **This is why `Mapper::mix_audio` had to widen to `i32` first.** A
+/// volume-15 tone now reaches `1882 * 18.471 = 34,761` — already past
+/// `i16::MAX` for ONE channel — and three simultaneous full-volume tones
+/// (Gimmick!, Hebereke) reach ~104 k, 3.2x over. The level could not be
+/// corrected while the return type was `i16`; that, not the arithmetic, was
+/// the actual blocker.
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+pub(crate) const SUNSOFT5B_MIX_SCALE_NUM: i32 = 2549;
+/// Denominator of [`SUNSOFT5B_MIX_SCALE_NUM`].
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+pub(crate) const SUNSOFT5B_MIX_SCALE_DEN: i32 = 138;
+
+/// One of the 5B's three square-wave tone channels.
+///
+/// The chip toggles the output level every `16 * TP` CPU cycles (TP = the
+/// 12-bit period from registers `$00/$01` for channel A, etc.). Per wiki,
+/// a `TP` of 0 behaves identically to `TP` of 1, so the divide path uses
+/// `max(TP, 1)` to avoid both a divide-by-zero and a degenerate "always
+/// toggling" case. None of the 5B's generators can be halted — disabling
+/// a channel in the mixer only mutes its output, the internal counters
+/// keep running.
+#[derive(Clone, Default)]
+struct Sunsoft5BTone {
+ /// 12-bit reload period.
+ period: u16,
+ /// Internal half-period countdown in CPU clocks (counts down from
+ /// `16 * period`; on hitting 0 the level toggles and the counter
+ /// reloads).
+ counter: u32,
+ /// Current square-wave output level (0 or 1).
+ level: u8,
+}
+
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+impl Sunsoft5BTone {
+ /// Effective half-period, in CPU clocks (`max(period, 1) * 16`).
+ fn half_period(&self) -> u32 {
+ u32::from(self.period.max(1)) * 16
+ }
+
+ /// One CPU cycle. Counters always run, even when the channel is
+ /// muted by the mixer register.
+ fn clock(&mut self) {
+ if self.counter == 0 {
+ self.counter = self.half_period();
+ self.level ^= 1;
+ } else {
+ self.counter -= 1;
+ }
+ }
+}
+
+/// 17-bit LFSR noise generator with taps at bits 16 and 13 (per the AY-
+/// 3-8910 datasheet, as cited on the NESdev wiki).
+#[derive(Clone)]
+struct Sunsoft5BNoise {
+ /// 5-bit period reload (`$06`).
+ period: u8,
+ /// Half-period countdown in CPU clocks.
+ counter: u32,
+ /// 17-bit LFSR state; output is bit 0.
+ lfsr: u32,
+}
+
+impl Default for Sunsoft5BNoise {
+ fn default() -> Self {
+ // The AY's LFSR powers up with all bits set; if it ever reached 0
+ // it would lock up (no taps could ever flip a bit back in).
+ Self {
+ period: 0,
+ counter: 0,
+ lfsr: 0x1FFFF,
+ }
+ }
+}
+
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+impl Sunsoft5BNoise {
+ fn half_period(&self) -> u32 {
+ u32::from(self.period.max(1)) * 16
+ }
+
+ fn clock(&mut self) {
+ if self.counter == 0 {
+ self.counter = self.half_period();
+ // 17-bit LFSR, taps at bits 16 and 13 (XOR). Shift right,
+ // feed the XOR back into bit 16.
+ let fb = ((self.lfsr >> 16) ^ (self.lfsr >> 13)) & 1;
+ self.lfsr = (self.lfsr >> 1) | (fb << 16);
+ self.lfsr &= 0x1FFFF;
+ } else {
+ self.counter -= 1;
+ }
+ }
+
+ /// Current noise output bit (0 or 1).
+ fn level(&self) -> u8 {
+ (self.lfsr & 1) as u8
+ }
+}
+
+/// Envelope generator: 16-bit period, 32-step output, 10 distinct shapes.
+///
+/// Writing the shape register (`$0D`) **restarts** the envelope from its
+/// shape-determined starting position. The wiki gives the shapes in
+/// terms of four bits `CAaH` (continue/attack/alternate/hold); we
+/// implement them as a small state machine — `attack` chooses the
+/// starting direction, `alternate` flips it after each ramp, `continue`
+/// gates whether to keep going past the first ramp, and `hold` freezes
+/// (with `attack XOR alternate` deciding the held value).
+#[derive(Clone, Default)]
+struct Sunsoft5BEnvelope {
+ /// 16-bit reload period.
+ period: u16,
+ /// Half-step countdown in CPU clocks (the wiki gives step frequency
+ /// `clock / (16 * period)`).
+ counter: u32,
+ /// Shape register value (`$0D`). Only the low 4 bits matter.
+ shape: u8,
+ /// Current 5-bit envelope level (0..=31).
+ level: u8,
+ /// Internal direction: +1 for rising, -1 for falling.
+ rising: bool,
+ /// Set once the envelope has completed its first ramp and decided to
+ /// hold (per `continue=0` or `hold=1` after the first ramp/alternate).
+ holding: bool,
+}
+
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+impl Sunsoft5BEnvelope {
+ /// Effective step interval in CPU clocks.
+ fn step_period(&self) -> u32 {
+ u32::from(self.period.max(1)) * 16
+ }
+
+ /// Write `$0D` — latches the shape AND restarts the envelope.
+ fn write_shape(&mut self, value: u8) {
+ self.shape = value & 0x0F;
+ // Attack bit (bit 2) sets the initial direction. When attack=1,
+ // start at 0 going up; when attack=0, start at 31 going down.
+ let attack = (self.shape & 0x04) != 0;
+ self.rising = attack;
+ self.level = if attack { 0 } else { 31 };
+ self.counter = self.step_period();
+ self.holding = false;
+ }
+
+ /// One CPU cycle. Runs forever (cannot be halted) but emits silence
+ /// while `holding == true` and `continue == 0`.
+ fn clock(&mut self) {
+ if self.counter == 0 {
+ self.counter = self.step_period();
+ self.step();
+ } else {
+ self.counter -= 1;
+ }
+ }
+
+ fn step(&mut self) {
+ if self.holding {
+ return;
+ }
+ if self.rising {
+ if self.level < 31 {
+ self.level += 1;
+ return;
+ }
+ } else if self.level > 0 {
+ self.level -= 1;
+ return;
+ }
+ // We reached the end of a ramp. Decide what to do based on the
+ // four shape bits. Per the wiki:
+ // continue=0 (bit 3): the envelope holds at 0 regardless of the
+ // other bits after one ramp.
+ // hold=1 (bit 0): hold at the current value (possibly flipped
+ // by alternate).
+ // alternate=1 (bit 1): reverse direction every ramp.
+ let cont = (self.shape & 0x08) != 0;
+ let alternate = (self.shape & 0x02) != 0;
+ let hold = (self.shape & 0x01) != 0;
+ if !cont {
+ self.level = 0;
+ self.holding = true;
+ return;
+ }
+ if hold {
+ if alternate {
+ // /\___ etc.: flip the final level once.
+ self.level = if self.rising { 0 } else { 31 };
+ }
+ self.holding = true;
+ return;
+ }
+ if alternate {
+ self.rising = !self.rising;
+ } else {
+ // Pure sawtooth: snap back to the starting level.
+ self.level = if self.rising { 0 } else { 31 };
+ }
+ }
+
+ /// Current 5-bit envelope output (0..=31).
+ const fn output(&self) -> u8 {
+ self.level
+ }
+}
+
+/// 5B audio chip state: 16-byte register file, 3 tone channels, noise
+/// generator, envelope generator, plus the address-latch byte that the
+/// `$C000-$DFFF` writes use to select the next `$E000-$FFFF` data target.
+#[derive(Clone, Default)]
+pub(crate) struct Sunsoft5BAudio {
+ /// Latched 4-bit register index from the most recent `$C000-$DFFF`
+ /// write. Bits 7-4 of the high-byte are silently ignored (per the
+ /// NESdev wiki: writes with bits 7-4 nonzero are inhibited; we model
+ /// only the inhibit-on-high-bits case by masking to 4 bits, since no
+ /// known software relies on the high bits).
+ addr_latch: u8,
+ /// Raw 16-byte register file (mostly for save-state round-trip and
+ /// debug inspection — the live state lives in the channel structs).
+ regs: [u8; 16],
+ tone_a: Sunsoft5BTone,
+ tone_b: Sunsoft5BTone,
+ tone_c: Sunsoft5BTone,
+ noise: Sunsoft5BNoise,
+ envelope: Sunsoft5BEnvelope,
+}
+
+#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
+impl Sunsoft5BAudio {
+ /// Raw value of one of the 16 PSG registers (`$00-$0F`), for the debug
+ /// window. Read-only — no side effects, unlike a real `$E000` access.
+ pub(crate) fn reg(&self, idx: usize) -> u8 {
+ self.regs[idx & 0x0F]
+ }
+
+ /// Current 16-bit envelope period (`$0B` low | `$0C` high), for debug.
+ pub(crate) const fn envelope_period(&self) -> u16 {
+ self.envelope.period
+ }
+
+ /// Current 5-bit envelope output level (0..=31), for debug.
+ pub(crate) fn envelope_output(&self) -> u8 {
+ self.envelope.output()
+ }
+
+ pub(crate) fn write_addr(&mut self, value: u8) {
+ // Per the wiki, writes with the high nibble nonzero are inhibited.
+ // The simplest faithful model is to mask the latch to 4 bits and
+ // accept the next data write unconditionally — no known software
+ // depends on the inhibit path.
+ self.addr_latch = value & 0x0F;
+ }
+
+ pub(crate) fn write_data(&mut self, value: u8) {
+ let idx = self.addr_latch as usize;
+ self.regs[idx] = value;
+ match idx {
+ 0x00 => self.tone_a.period = (self.tone_a.period & 0x0F00) | u16::from(value),
+ 0x01 => {
+ self.tone_a.period = (self.tone_a.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ }
+ 0x02 => self.tone_b.period = (self.tone_b.period & 0x0F00) | u16::from(value),
+ 0x03 => {
+ self.tone_b.period = (self.tone_b.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ }
+ 0x04 => self.tone_c.period = (self.tone_c.period & 0x0F00) | u16::from(value),
+ 0x05 => {
+ self.tone_c.period = (self.tone_c.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
+ }
+ 0x06 => self.noise.period = value & 0x1F,
+ 0x07 => { /* mixer; consulted live in `mix_audio`. */ }
+ 0x08 | 0x09 | 0x0A => { /* per-channel volume; consulted live. */ }
+ 0x0B => {
+ self.envelope.period = (self.envelope.period & 0xFF00) | u16::from(value);
+ }
+ 0x0C => {
+ self.envelope.period = (self.envelope.period & 0x00FF) | (u16::from(value) << 8);
+ }
+ 0x0D => self.envelope.write_shape(value),
+ // $0E/$0F = I/O ports A/B. Unused on the NES (the cart never
+ // wires them out). We latch the byte for save-state round-trip
+ // and otherwise ignore.
+ _ => {}
+ }
+ }
+
+ /// Mixer register: bits are `--CBAcca`, 0 = enable / 1 = disable.
+ /// Bits 5/3/1 are noise enables for channels C/B/A respectively;
+ /// bits 4/2/0 are tone enables for channels c/b/a (same lettering).
+ const fn tone_enabled(&self, ch: u8) -> bool {
+ let mixer = self.regs[0x07];
+ // 0 = enable, 1 = disable. Tone bits = 0, 2, 4 for A/B/C.
+ (mixer >> (ch * 2)) & 1 == 0
+ }
+
+ const fn noise_enabled(&self, ch: u8) -> bool {
+ let mixer = self.regs[0x07];
+ // Noise bits = 1, 3, 5 for A/B/C.
+ (mixer >> (ch * 2 + 1)) & 1 == 0
+ }
+
+ /// Resolve the 4-bit equivalent volume for channel `ch` (0/1/2 for
+ /// A/B/C), honoring the per-channel envelope-mode bit.
+ fn volume(&self, ch: u8) -> u8 {
+ let reg = self.regs[0x08 + ch as usize];
+ if reg & 0x10 != 0 {
+ // Envelope mode: 5-bit env mapped to 4-bit equivalent via `>>1`
+ // per the NESdev table (env=0/1 both -> silent; env=2 -> vol 1;
+ // env=31 -> vol 15).
+ self.envelope.output() >> 1
+ } else {
+ reg & 0x0F
+ }
+ }
+
+ /// Advance every internal generator by one CPU cycle. Per the wiki,
+ /// "none of the various generators can be halted" — they run whenever
+ /// the chip is clocked, regardless of mixer/enable state.
+ #[cfg(feature = "mapper-audio")]
+ pub(crate) fn clock(&mut self) {
+ self.tone_a.clock();
+ self.tone_b.clock();
+ self.tone_c.clock();
+ self.noise.clock();
+ self.envelope.clock();
+ }
+
+ /// Linear-summed audio output, scaled to ~i16 with the same headroom
+ /// VRC6 leaves for the APU mixer.
+ #[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 {
+ let v = self.volume(ch) as usize & 0x0F;
+ sum += SUNSOFT5B_LOG_VOL[v];
+ }
+ }
+ // Scale the shape table to the hardware-relative level (see
+ // `SUNSOFT5B_MIX_SCALE_NUM`), then centre on zero so the BLEP buffer
+ // doesn't see a steady DC offset for an idle
+ // (all-channels-on-with-fixed-volume) cartridge. No cast: v2.2.3
+ // widened `Mapper::mix_audio` to i32 precisely so the 5B's full
+ // three-channel swing (~104 k) is representable rather than clamped.
+ // The multiply precedes the divide so the integer division loses at
+ // most 1 part in ~12,000 on a volume-12 tone.
+ sum * SUNSOFT5B_MIX_SCALE_NUM / SUNSOFT5B_MIX_SCALE_DEN - SUNSOFT5B_DC_BIAS
+ }
+
+ /// Feature-off shim: the generators do not advance with `mapper-audio`
+ /// disabled (mirrors the gated path so the shared NSF expansion router
+ /// can clock unconditionally).
+ #[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
+ pub(crate) fn clock(&mut self) {}
+
+ /// Feature-off shim: silence when `mapper-audio` is disabled.
+ #[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::unused_self)]
+ pub(crate) fn mix(&self) -> i32 {
+ 0
+ }
+
+ /// Serialize the live audio state. 21-byte tail:
+ /// addr_latch(1) + regs[16](16) + tone_a/b/c counter+level(3*5=15) +
+ /// noise counter+lfsr(4+1+... wait that's bigger).
+ ///
+ /// Tail layout (kept in lock-step with `read_tail`):
+ /// addr_latch : 1
+ /// regs : 16
+ /// tone_a.counter : 4 (u32 LE)
+ /// tone_a.level : 1
+ /// tone_b.counter : 4
+ /// tone_b.level : 1
+ /// tone_c.counter : 4
+ /// tone_c.level : 1
+ /// noise.counter : 4
+ /// noise.lfsr : 4 (u32 LE, only low 17 bits used)
+ /// envelope.counter : 4
+ /// envelope.level : 1
+ /// envelope.rising : 1 (bool)
+ /// envelope.holding : 1 (bool)
+ /// -- 51 bytes total --
+ /// (Channel period/shape state is reconstructible from `regs`; we
+ /// don't serialize the period/shape fields separately.)
+ fn write_tail(&self, out: &mut Vec) {
+ out.push(self.addr_latch);
+ out.extend_from_slice(&self.regs);
+ for t in [&self.tone_a, &self.tone_b, &self.tone_c] {
+ out.extend_from_slice(&t.counter.to_le_bytes());
+ out.push(t.level);
+ }
+ out.extend_from_slice(&self.noise.counter.to_le_bytes());
+ out.extend_from_slice(&self.noise.lfsr.to_le_bytes());
+ out.extend_from_slice(&self.envelope.counter.to_le_bytes());
+ out.push(self.envelope.level);
+ out.push(u8::from(self.envelope.rising));
+ out.push(u8::from(self.envelope.holding));
+ }
+
+ /// Tail size in bytes — see `write_tail`.
+ const TAIL_LEN: usize = 1 + 16 + 3 * 5 + 4 + 4 + 4 + 1 + 1 + 1;
+
+ fn read_tail(&mut self, src: &[u8]) -> Result<(), MapperError> {
+ if src.len() < Self::TAIL_LEN {
+ return Err(MapperError::Truncated {
+ expected: Self::TAIL_LEN,
+ got: src.len(),
+ });
+ }
+ self.addr_latch = src[0] & 0x0F;
+ self.regs.copy_from_slice(&src[1..17]);
+ let mut cur = 17usize;
+ for t in [&mut self.tone_a, &mut self.tone_b, &mut self.tone_c] {
+ t.counter = u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
+ t.level = src[cur + 4] & 1;
+ cur += 5;
+ }
+ self.noise.counter =
+ u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
+ cur += 4;
+ self.noise.lfsr =
+ u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]) & 0x1FFFF;
+ if self.noise.lfsr == 0 {
+ // Guard against a lock-up (LFSR with all zeros has no way out).
+ self.noise.lfsr = 0x1FFFF;
+ }
+ cur += 4;
+ self.envelope.counter =
+ u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
+ cur += 4;
+ self.envelope.level = src[cur] & 0x1F;
+ self.envelope.rising = src[cur + 1] != 0;
+ self.envelope.holding = src[cur + 2] != 0;
+ // Reconstruct live period/shape state from the register file.
+ self.tone_a.period = u16::from(self.regs[0x00]) | (u16::from(self.regs[0x01] & 0x0F) << 8);
+ self.tone_b.period = u16::from(self.regs[0x02]) | (u16::from(self.regs[0x03] & 0x0F) << 8);
+ self.tone_c.period = u16::from(self.regs[0x04]) | (u16::from(self.regs[0x05] & 0x0F) << 8);
+ self.noise.period = self.regs[0x06] & 0x1F;
+ self.envelope.period = u16::from(self.regs[0x0B]) | (u16::from(self.regs[0x0C]) << 8);
+ self.envelope.shape = self.regs[0x0D] & 0x0F;
+ Ok(())
+ }
+}
+
+/// Sunsoft FME-7 (Mapper 69). Bank-switching, CPU-cycle IRQ, and (gated
+/// behind `mapper-audio`) the on-cart Sunsoft 5B audio chip.
+pub struct Fme7 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ prg_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ cmd: u8,
+ chr: [u8; 8],
+ prg_banks: [u8; 4], // $6000, $8000, $A000, $C000 (E000 fixed)
+ prg_ram_enabled: bool,
+ prg_ram_select: bool,
+ mirroring: Mirroring,
+
+ irq_counter: u16,
+ irq_enabled: bool,
+ irq_counter_enabled: bool,
+ irq_pending: bool,
+
+ /// Sunsoft 5B audio extension state. Live regardless of the
+ /// `mapper-audio` feature — the register decoders always latch into
+ /// `regs` (so save states stay round-trippable across builds), but
+ /// `clock()` / `mix()` are only called when the feature is on.
+ audio: Sunsoft5BAudio,
+}
+
+impl Fme7 {
+ /// Construct a new FME-7 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "FME-7 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "FME-7 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ cmd: 0,
+ chr: [0; 8],
+ prg_banks: [0; 4],
+ prg_ram_enabled: false,
+ prg_ram_select: true,
+ mirroring,
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_counter_enabled: false,
+ irq_pending: false,
+ audio: Sunsoft5BAudio::default(),
+ })
+ }
+
+ fn prg_8k(&self, idx: usize) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ (self.prg_banks[idx] as usize) % total_8k
+ }
+}
+
+impl Mapper for Fme7 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
+ // (the audio hook only exists under the `mapper-audio` feature).
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: true,
+ audio: cfg!(feature = "mapper-audio"),
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ /// A2: the RAM-selected-but-DISABLED `$6000-$7FFF` window floats.
+ ///
+ /// Command `$8` carries RAM-enable in bit 7 and RAM-select in bit 6. Three
+ /// of the four states already worked: both set maps PRG-RAM, and bit 6
+ /// clear maps a PRG-ROM bank (regardless of bit 7). The fourth --
+ /// **selected but not enabled** (bit 6 = 1, bit 7 = 0) -- drives *neither*
+ /// chip on real hardware: the RAM is addressed but its enable is deasserted,
+ /// and the ROM is deselected. The CPU databus is left floating, so the read
+ /// returns the open-bus latch.
+ ///
+ /// Previously this state fell through to the PRG-ROM bank, returning that
+ /// bank's tag byte. Holy Mapperel's `M69_*` WRAM sub-check requires the
+ /// floating read to be `>= 3` (a `$7F`-class open-bus byte); it read the
+ /// bank tag `1` instead and set `MAPTEST_WRAMEN`, which is the entire
+ /// `1000` WRAM nibble those two ROMs reported (`docs/accuracy-ledger.md`).
+ ///
+ /// Routed through `cpu_read_unmapped` rather than by returning a guessed
+ /// byte from `cpu_read`: that is the trait's existing contract for "not
+ /// wired to mapper-resident memory", and it makes the bus preserve the real
+ /// latch instead of clobbering it -- which is what open bus actually is.
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ if matches!(addr, 0x6000..=0x7FFF) {
+ return self.prg_ram_select && !self.prg_ram_enabled;
+ }
+ // Everything else keeps the stock behaviour: `$4020-$5FFF` unmapped
+ // (the FME-7 has no registers down there -- its IRQ control lives at
+ // `$8000`/`$A000`), `$8000-$FFFF` always mapped PRG-ROM.
+ addr < 0x6000
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => {
+ if self.prg_ram_select && self.prg_ram_enabled {
+ return self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()];
+ }
+ let bank = self.prg_8k(0);
+ self.prg_rom[(bank * PRG_BANK_8K + (addr as usize - 0x6000)) % self.prg_rom.len()]
+ }
+ 0x8000..=0x9FFF => {
+ let off = self.prg_8k(1) * PRG_BANK_8K + (addr as usize - 0x8000);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ 0xA000..=0xBFFF => {
+ let off = self.prg_8k(2) * PRG_BANK_8K + (addr as usize - 0xA000);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ 0xC000..=0xDFFF => {
+ let off = self.prg_8k(3) * PRG_BANK_8K + (addr as usize - 0xC000);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ 0xE000..=0xFFFF => {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last = total_8k - 1;
+ self.prg_rom[(last * PRG_BANK_8K + (addr as usize - 0xE000)) % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x6000..=0x7FFF => {
+ if self.prg_ram_select && self.prg_ram_enabled {
+ let off = (addr - 0x6000) as usize % self.prg_ram.len();
+ self.prg_ram[off] = value;
+ }
+ }
+ 0x8000..=0x9FFF => self.cmd = value & 0x0F,
+ 0xA000..=0xBFFF => match self.cmd {
+ 0..=7 => self.chr[self.cmd as usize] = value,
+ 8 => {
+ self.prg_ram_enabled = (value & 0x80) != 0;
+ self.prg_ram_select = (value & 0x40) != 0;
+ self.prg_banks[0] = value & 0x3F;
+ }
+ 9..=11 => self.prg_banks[(self.cmd - 8) as usize] = value & 0x3F,
+ 12 => {
+ self.mirroring = match value & 0x03 {
+ 0 => Mirroring::Vertical,
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::SingleScreenA,
+ _ => Mirroring::SingleScreenB,
+ };
+ }
+ 13 => {
+ self.irq_enabled = (value & 0x01) != 0;
+ self.irq_counter_enabled = (value & 0x80) != 0;
+ self.irq_pending = false;
+ }
+ 14 => self.irq_counter = (self.irq_counter & 0xFF00) | u16::from(value),
+ 15 => self.irq_counter = (self.irq_counter & 0x00FF) | (u16::from(value) << 8),
+ _ => {}
+ },
+ // Sunsoft 5B audio: $C000-$DFFF latches the register address;
+ // $E000-$FFFF writes data to the latched register. Mapper-audio
+ // OFF builds still latch state (so the save-state path is
+ // round-trippable) but never advance the oscillators.
+ 0xC000..=0xDFFF => self.audio.write_addr(value),
+ 0xE000..=0xFFFF => self.audio.write_data(value),
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ // Sunsoft 5B audio runs every CPU cycle, regardless of IRQ state.
+ // None of the 5B's internal generators can be halted, so we always
+ // tick when the feature is on.
+ #[cfg(feature = "mapper-audio")]
+ self.audio.clock();
+
+ if self.irq_counter_enabled {
+ self.irq_counter = self.irq_counter.wrapping_sub(1);
+ if self.irq_counter == 0xFFFF && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ fn mix_audio(&mut self) -> i32 {
+ self.audio.mix()
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
+ let mut info = crate::mapper::MapperDebugInfo {
+ mapper_id: 69,
+ name: "Sunsoft FME-7".into(),
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ for (i, b) in self.prg_banks.iter().enumerate() {
+ info.prg_banks
+ .push((format!("PRG{i}"), format!("{b:#04x}")));
+ }
+ for (i, b) in self.chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{b:#04x}")));
+ }
+ 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(("counting".into(), format!("{}", self.irq_counter_enabled)));
+ info.irq_state
+ .push(("pending".into(), format!("{}", self.irq_pending)));
+ info.extra
+ .push(("cmd".into(), format!("{:#04x}", self.cmd)));
+ info.extra.push((
+ "prg_ram".into(),
+ format!("en={} sel={}", self.prg_ram_enabled, self.prg_ram_select),
+ ));
+ // v2.2.3 — surface the Sunsoft 5B audio register file. The 5B is the
+ // only part of this board with no other debug window, and its state is
+ // exactly what you need to answer "why is this cart silent?" — the
+ // mixer/enable byte ($07) and the three volume bytes ($08-$0A, bit 4 =
+ // envelope mode) decide whether anything sounds at all.
+ #[cfg(feature = "mapper-audio")]
+ {
+ let a = &self.audio;
+ info.extra
+ .push(("5b_mixer($07)".into(), format!("{:#04x}", a.reg(0x07))));
+ info.extra.push((
+ "5b_vol(A,B,C)".into(),
+ format!(
+ "{:#04x} {:#04x} {:#04x}",
+ a.reg(0x08),
+ a.reg(0x09),
+ a.reg(0x0A)
+ ),
+ ));
+ info.extra.push((
+ "5b_env".into(),
+ format!(
+ "period={:#06x} shape={:#04x} out={}",
+ a.envelope_period(),
+ a.reg(0x0D),
+ a.envelope_output()
+ ),
+ ));
+ info.extra.push(("5b_mix".into(), format!("{}", a.mix())));
+ }
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ // v2: appends the Sunsoft 5B audio state at the end. Per ADR-0003:
+ // strictly additive, so v1 readers tolerate the tail (older builds
+ // skip-on-read since the tag is consumed at the section length).
+ // Tail size = Sunsoft5BAudio::TAIL_LEN (51 bytes).
+ let mut out = Vec::with_capacity(
+ 40 + self.prg_ram.len() + self.vram.len() + Sunsoft5BAudio::TAIL_LEN,
+ );
+ out.push(2u8); // version
+ out.push(self.cmd);
+ out.extend_from_slice(&self.chr);
+ out.extend_from_slice(&self.prg_banks);
+ out.push(u8::from(self.prg_ram_enabled));
+ out.push(u8::from(self.prg_ram_select));
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.irq_counter.to_le_bytes());
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_counter_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.prg_ram);
+ out.extend_from_slice(&self.vram);
+ // v2 audio tail.
+ self.audio.write_tail(&mut out);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let scalar_len = 1 + 1 + 8 + 4 + 1 + 1 + 1 + 2 + 1 + 1 + 1;
+ let core_expected = scalar_len + self.prg_ram.len() + self.vram.len();
+ if data.len() < core_expected {
+ return Err(MapperError::Truncated {
+ expected: core_expected,
+ got: data.len(),
+ });
+ }
+ let version = data[0];
+ if !(1..=2).contains(&version) {
+ return Err(MapperError::UnsupportedVersion(version));
+ }
+ self.cmd = data[1];
+ self.chr.copy_from_slice(&data[2..10]);
+ self.prg_banks.copy_from_slice(&data[10..14]);
+ self.prg_ram_enabled = data[14] != 0;
+ self.prg_ram_select = data[15] != 0;
+ self.mirroring = match data[16] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.irq_counter = u16::from_le_bytes(
+ data[17..19]
+ .try_into()
+ .map_err(|_| MapperError::Invalid("irq_counter".into()))?,
+ );
+ self.irq_enabled = data[19] != 0;
+ self.irq_counter_enabled = data[20] != 0;
+ self.irq_pending = data[21] != 0;
+ let mut cur = 22usize;
+ self.prg_ram
+ .copy_from_slice(&data[cur..cur + self.prg_ram.len()]);
+ cur += self.prg_ram.len();
+ self.vram.copy_from_slice(&data[cur..cur + self.vram.len()]);
+ cur += self.vram.len();
+
+ // v2 tail: audio state. v1 blobs end at the core; per ADR-0003,
+ // we leave audio at its current state (the caller is responsible
+ // for an explicit power-cycle if they want a clean slate). A v2
+ // blob shorter than TAIL_LEN bytes is accepted permissively for
+ // the same forward-compat reason VRC6 uses.
+ if version == 2 && data.len() >= cur + Sunsoft5BAudio::TAIL_LEN {
+ self.audio
+ .read_tail(&data[cur..cur + Sunsoft5BAudio::TAIL_LEN])?;
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn fme7_basic_banking() {
+ let mut m = Fme7::new(synth(16), synth_chr(8), Mirroring::Vertical).unwrap();
+ // cmd=9 -> writes prg_banks[1] (the $8000-$9FFF window).
+ m.cpu_write(0x8000, 9);
+ m.cpu_write(0xA000, 5);
+ // Read at $8000 should now be bank 5 (offset 0 == bank index byte).
+ assert_eq!(m.cpu_read(0x8000), 5);
+ // cmd=10 -> prg_banks[2] ($A000-$BFFF).
+ m.cpu_write(0x8000, 10);
+ m.cpu_write(0xA000, 7);
+ assert_eq!(m.cpu_read(0xA000), 7);
+ }
+
+ fn fme7_audio_write(m: &mut Fme7, reg: u8, value: u8) {
+ m.cpu_write(0xC000, reg);
+ m.cpu_write(0xE000, value);
+ }
+
+ #[test]
+ fn sunsoft5b_register_address_latch_round_trip() {
+ // The address latch is the gateway for every audio write; it must
+ // round-trip distinctly from the data path. After latching $0B
+ // (envelope period low), a subsequent data write should target
+ // $0B specifically.
+ let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xC000, 0x0B);
+ assert_eq!(m.audio.addr_latch, 0x0B);
+ // Bits 7-4 of the address byte are ignored (masked to 4 bits).
+ m.cpu_write(0xC100, 0xF7);
+ assert_eq!(m.audio.addr_latch, 0x07);
+ // A data write at $E000-$FFFF goes to the latched register.
+ m.cpu_write(0xE800, 0xAB);
+ assert_eq!(m.audio.regs[0x07], 0xAB);
+ }
+
+ #[test]
+ fn sunsoft5b_channel_period_decodes_into_internal_state() {
+ // Channel A period: TP = ($01 & 0x0F) << 8 | $00. Confirm the
+ // 12-bit period composes correctly from the two writes, and that
+ // bits 7-4 of $01 are masked off.
+ let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ fme7_audio_write(&mut m, 0x00, 0x34);
+ fme7_audio_write(&mut m, 0x01, 0xF7); // upper nibble (7) used; F is ignored.
+ assert_eq!(m.audio.tone_a.period, 0x0734);
+
+ // Channel B / C similarly.
+ fme7_audio_write(&mut m, 0x02, 0x12);
+ fme7_audio_write(&mut m, 0x03, 0x03);
+ assert_eq!(m.audio.tone_b.period, 0x0312);
+ fme7_audio_write(&mut m, 0x04, 0xFF);
+ fme7_audio_write(&mut m, 0x05, 0x0F);
+ assert_eq!(m.audio.tone_c.period, 0x0FFF);
+ }
+
+ #[test]
+ fn sunsoft5b_tone_toggles_every_16_times_period_cycles() {
+ // Per NESdev wiki: the square wave toggles every 16 CPU clocks per
+ // period count. With TP = 5, we expect a toggle every 80 cycles.
+ // Drive the chip through clock() directly to isolate the tone path
+ // from the rest of the mapper.
+ let mut t = Sunsoft5BTone {
+ period: 5,
+ ..Sunsoft5BTone::default()
+ };
+ // First clock fires immediately (counter starts at 0) and reloads.
+ // Count toggles across 800 cycles.
+ let mut toggles = 0u32;
+ let mut last = t.level;
+ for _ in 0..800 {
+ t.clock();
+ if t.level != last {
+ toggles += 1;
+ last = t.level;
+ }
+ }
+ // 800 cycles / 80 per toggle = 10 toggles. Allow ±1 for the
+ // counter-starts-at-zero start-up edge.
+ assert!(
+ (9..=11).contains(&toggles),
+ "tone toggle count {toggles} not in 9..=11"
+ );
+ }
+
+ #[test]
+ fn sunsoft5b_volume_scale_zero_silent_max_peak() {
+ // Volume 0 must produce silence; volume 15 must produce the peak
+ // entry of the log-DAC table. These bracket the per-channel
+ // contribution range.
+ assert_eq!(SUNSOFT5B_LOG_VOL[0], 0);
+ assert!(SUNSOFT5B_LOG_VOL[15] > SUNSOFT5B_LOG_VOL[14]);
+ // The volume() helper applies the envelope-mode select bit.
+ let mut a = Sunsoft5BAudio::default();
+ a.regs[0x08] = 0x0F; // fixed volume = 15.
+ assert_eq!(a.volume(0), 15);
+ a.regs[0x08] = 0x00; // fixed volume = 0.
+ assert_eq!(a.volume(0), 0);
+ }
+
+ #[test]
+ fn sunsoft5b_envelope_mode_routes_envelope_into_channel() {
+ // Setting bit 4 of $08/$09/$0A switches that channel from fixed
+ // volume to envelope mode. In envelope mode the 4-bit volume
+ // equivalent is env >> 1 (per the NESdev table).
+ let mut a = Sunsoft5BAudio::default();
+ a.regs[0x08] = 0x10; // envelope mode, fixed-volume bits ignored.
+ a.envelope.level = 30; // 4-bit equivalent = 15.
+ assert_eq!(a.volume(0), 15);
+ a.envelope.level = 6;
+ assert_eq!(a.volume(0), 3);
+ a.envelope.level = 1;
+ assert_eq!(a.volume(0), 0); // env 0 and 1 both -> 0.
+ // Switching back to fixed mode honors $08 bits 3-0 again.
+ a.regs[0x08] = 0x07;
+ assert_eq!(a.volume(0), 7);
+ }
+
+ #[cfg(feature = "mapper-audio")]
+ #[test]
+ fn sunsoft5b_mix_output_sign_silent_vs_active() {
+ // With every channel muted (mixer = 0xFF disables both tone and
+ // noise on A/B/C; volumes don't matter), the linear sum is 0 and
+ // the mix output sits at -DC_BIAS (centered). With one channel
+ // unmuted at max volume and the square wave high, the sum exceeds
+ // the bias and the mix is positive.
+ let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ fme7_audio_write(&mut m, 0x07, 0x3F); // bits 0..=5 all set => all disabled.
+ // Volumes irrelevant when channels are muted.
+ let silent = m.mix_audio();
+ assert_eq!(silent, -SUNSOFT5B_DC_BIAS);
+
+ // Enable tone A only at max volume, then force the square level high
+ // by ticking once with period = 0 (the chip wraps period=0 to 1).
+ fme7_audio_write(&mut m, 0x07, 0b0011_1110); // tone A enabled (bit 0 = 0).
+ fme7_audio_write(&mut m, 0x08, 0x0F); // channel A volume = 15.
+ // Manually toggle the tone level so we hit the "high" half-cycle.
+ m.audio.tone_a.level = 1;
+ let active = m.mix_audio();
+ assert!(
+ active > 0,
+ "active mix output should be positive, got {active}"
+ );
+ }
+
+ #[test]
+ fn sunsoft5b_save_state_v2_round_trips_audio() {
+ // Round-trip an FME-7 with a non-trivial audio register file. The
+ // load_state path reconstructs the live period/shape state from the
+ // serialized register file, so verifying via `audio.tone_a.period`
+ // exercises both the regs blob and the reconstruction path.
+ let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ fme7_audio_write(&mut m, 0x00, 0x55);
+ fme7_audio_write(&mut m, 0x01, 0x06);
+ fme7_audio_write(&mut m, 0x08, 0x0F);
+ fme7_audio_write(&mut m, 0x07, 0x36); // a few tone/noise enables.
+ fme7_audio_write(&mut m, 0x0D, 0x0E); // envelope shape -> restart.
+ let blob = m.save_state();
+ assert_eq!(blob[0], 2, "save_state must bump FME-7 to version 2");
+
+ let mut m2 = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).expect("v2 round-trip");
+ assert_eq!(m2.audio.tone_a.period, 0x0655);
+ assert_eq!(m2.audio.regs[0x07], 0x36);
+ assert_eq!(m2.audio.regs[0x08], 0x0F);
+ assert_eq!(m2.audio.envelope.shape, 0x0E);
+ }
+
+ #[test]
+ fn sunsoft5b_save_state_loads_v1_blob_with_default_audio() {
+ // ADR-0003 invariant: v2 reader must accept a v1 blob; audio state
+ // stays at whatever the freshly-constructed mapper has (silence).
+ // We synthesize a v1 blob by truncating the audio tail and resetting
+ // the version byte.
+ let m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ let mut blob = m.save_state();
+ let tail = Sunsoft5BAudio::TAIL_LEN;
+ blob.truncate(blob.len() - tail);
+ blob[0] = 1;
+
+ let mut m2 = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ // Perturb audio state pre-load; a v1 blob must not touch it.
+ fme7_audio_write(&mut m2, 0x07, 0xAA);
+ m2.load_state(&blob)
+ .expect("v1 blob must load on v2 reader");
+ // Per ADR-0003: older blobs do not reset newer-section state.
+ assert_eq!(m2.audio.regs[0x07], 0xAA);
+ }
+
+ #[test]
+ fn sunsoft5b_mapper_audio_off_path_latches_state_but_stays_silent() {
+ // When the `mapper-audio` feature is OFF, the register decoder still
+ // latches every write (so save-state round-trip stays correct) but
+ // the oscillators never advance and `mix_audio` returns 0.
+ //
+ // We can't toggle the cargo feature from inside a test, but we CAN
+ // assert the two halves of this contract directly:
+ // 1. The register latch path is unconditional (this test runs
+ // regardless of the feature flag).
+ // 2. The oscillator clock path is gated — verified by the absence
+ // of `audio.clock()` calls in `notify_cpu_cycle` when the
+ // feature is off (compile-time `#[cfg(...)]`).
+ // To exercise (1), write to every register and confirm `regs` and
+ // the derived period fields are populated. To exercise (2)'s
+ // observable effect, freeze the counters by NOT calling notify and
+ // confirm the level state stays at zero.
+ let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
+ for r in 0u8..=0x0F {
+ fme7_audio_write(&mut m, r, r.wrapping_mul(0x11));
+ }
+ assert_eq!(m.audio.regs[0x00], 0x00);
+ assert_eq!(m.audio.regs[0x0F], 0xFF);
+ // Without any clock() calls, the tone level remains at default 0.
+ assert_eq!(m.audio.tone_a.level, 0);
+ assert_eq!(m.audio.tone_b.level, 0);
+ assert_eq!(m.audio.tone_c.level, 0);
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn sunsoft5b_volume_dac_follows_logarithmic_step_law() {
+ // The DAC SHAPE criterion. (The absolute LEVEL is a separate concern
+ // with its own oracle — `level_db_5b` against the `db_5b` ROM, wired in
+ // v2.2.3 A1; it used to be an i16-headroom deferral.) The 5B volume DAC
+ // is logarithmic, ~+3 dB
+ // (×1.1885² ≈ ×1.4125) per 4-bit step, matching Mesen2's
+ // `Sunsoft5bAudio` `_volumeLut` (LUT[12]=63, LUT[15]=177) and tetanes.
+ assert_eq!(SUNSOFT5B_LOG_VOL[0], 0, "silence at volume 0");
+ // Shape parity with Mesen2's table (floor(10^(0.15*i))) at the two
+ // survey-relevant points.
+ assert_eq!(SUNSOFT5B_LOG_VOL[12], 668);
+ assert_eq!(SUNSOFT5B_LOG_VOL[15], 1882);
+ // Each non-zero step multiplies by ~1.4125 (the +1.5 dB × 2 law).
+ for v in 2..16usize {
+ let ratio = f64::from(SUNSOFT5B_LOG_VOL[v]) / f64::from(SUNSOFT5B_LOG_VOL[v - 1]);
+ assert!(
+ (ratio - 1.4125).abs() < 0.06,
+ "5B DAC step {v}: ratio {ratio:.4} not ~1.4125 (logarithmic law violated)"
+ );
+ }
+ // vol-15 is ~2.82× vol-12 (three +3 dB steps = ×1.4125^3 ≈ 2.818),
+ // the ~9 dB the `db_5b` ROM's vol-12 choice sits below full volume.
+ let v15_v12 = f64::from(SUNSOFT5B_LOG_VOL[15]) / f64::from(SUNSOFT5B_LOG_VOL[12]);
+ assert!(
+ (v15_v12 - 2.818).abs() < 0.05,
+ "vol-15/vol-12 ratio {v15_v12:.4} not ~2.818"
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/bandai74.rs b/crates/rustynes-mappers/src/m070_bandai74.rs
similarity index 100%
rename from crates/rustynes-mappers/src/bandai74.rs
rename to crates/rustynes-mappers/src/m070_bandai74.rs
diff --git a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs
new file mode 100644
index 00000000..6ddbb0d2
--- /dev/null
+++ b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs
@@ -0,0 +1,206 @@
+//! Camerica / Codemasters BF9093 and relatives (mapper 71).
+//!
+//! A UxROM-shaped board: a 16 KiB PRG bank selected at `$C000-$FFFF` with the
+//! last bank fixed, CHR-RAM, and no IRQ. The Fire Hawk variant additionally
+//! decodes a single-screen mirroring bit at `$9000-$9FFF`, which is why the
+//! mirroring write window is separated from the bank-select window rather
+//! than sharing one write-anywhere decode.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Camerica / Codemasters BF9093 (Mapper 71).
+pub struct Camerica {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ mirroring: Mirroring,
+ has_single_screen: bool,
+}
+
+impl Camerica {
+ /// Construct a new Camerica mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ has_single_screen: bool,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "Camerica PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ mirroring,
+ has_single_screen,
+ })
+ }
+}
+
+impl Mapper for Camerica {
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let total_16k = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let last = total_16k - 1;
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank = (self.prg_bank as usize) % total_16k;
+ self.prg_rom[(bank * PRG_BANK_16K + (addr as usize - 0x8000)) % self.prg_rom.len()]
+ }
+ 0xC000..=0xFFFF => {
+ self.prg_rom[(last * PRG_BANK_16K + (addr as usize - 0xC000)) % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x9000..=0x9FFF if self.has_single_screen => {
+ self.mirroring = if value & 0x10 == 0 {
+ Mirroring::SingleScreenA
+ } else {
+ Mirroring::SingleScreenB
+ };
+ }
+ 0xC000..=0xFFFF | 0x8000..=0xBFFF => self.prg_bank = value & 0x0F,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ let len = self.chr_ram.len();
+ self.chr_ram[addr as usize % len] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len() + self.chr_ram.len());
+ out.push(1u8);
+ out.push(self.prg_bank);
+ out.push(self.mirroring as u8);
+ out.push(u8::from(self.has_single_screen));
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 4 + self.chr_ram.len() + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.mirroring = match data[2] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.has_single_screen = data[3] != 0;
+ let mut cur = 4usize;
+ self.chr_ram
+ .copy_from_slice(&data[cur..cur + self.chr_ram.len()]);
+ cur += self.chr_ram.len();
+ self.vram.copy_from_slice(&data[cur..cur + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PRG_BANK_8K: usize = 0x2000;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ #[test]
+ fn camerica_bank_swap() {
+ let mut m = Camerica::new(synth(8 * 2), Mirroring::Vertical, false).unwrap();
+ // Default: bank 0 at $8000.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ m.cpu_write(0xC000, 5);
+ // Bank 5 (16K bank index, but we have 16K chunks — total_16k = 16).
+ // bank 5 at 16K offset. Let's just check it swaps from 0.
+ assert_ne!(m.cpu_read(0x8000), 0);
+ }
+}
diff --git a/crates/rustynes-mappers/src/vrc3.rs b/crates/rustynes-mappers/src/m073_vrc3.rs
similarity index 99%
rename from crates/rustynes-mappers/src/vrc3.rs
rename to crates/rustynes-mappers/src/m073_vrc3.rs
index 200d5ec2..2090b64d 100644
--- a/crates/rustynes-mappers/src/vrc3.rs
+++ b/crates/rustynes-mappers/src/m073_vrc3.rs
@@ -22,7 +22,7 @@
//! reloaded from the latch (8-bit mode reloads only the low 8 bits). Writing
//! `$C000` with E set reloads all 16 bits regardless of mode.
//!
-//! Reuses the VRC CPU-cycle IRQ family pattern (`sprint3.rs`).
+//! Reuses the VRC CPU-cycle IRQ family pattern (`m021_vrc4.rs`).
#![allow(
clippy::cast_possible_truncation,
diff --git a/crates/rustynes-mappers/src/m075_vrc1.rs b/crates/rustynes-mappers/src/m075_vrc1.rs
new file mode 100644
index 00000000..bf24ee83
--- /dev/null
+++ b/crates/rustynes-mappers/src/m075_vrc1.rs
@@ -0,0 +1,250 @@
+//! Konami VRC1 (mapper 75) -- the first and simplest VRC ASIC.
+//!
+//! Three 8 KiB PRG banks plus a fixed last bank, two 4 KiB CHR banks, and
+//! mirroring control. The quirk worth knowing: each CHR bank register is
+//! only four bits wide, and its *fifth* bit lives in the mirroring register
+//! at `$9000` -- so a CHR bank select above 15 requires writing two
+//! different registers.
+//!
+//! Unlike VRC2/VRC4/VRC6/VRC7 there is no IRQ counter and no on-cart audio;
+//! see `m022_vrc2.rs`, `m021_vrc4.rs`, `m073_vrc3.rs`, `m024_vrc6.rs`, `m085_vrc7.rs` for those.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::too_many_arguments
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_4K: usize = 0x1000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// VRC1 (Mapper 75).
+pub struct Vrc1 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_banks: [u8; 3], // $8000, $A000, $C000
+ chr_lo: u8,
+ chr_hi: u8,
+ chr_lo_msb: u8,
+ chr_hi_msb: u8,
+ mirroring: Mirroring,
+}
+
+impl Vrc1 {
+ /// Construct a new VRC1 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] on size mismatch.
+ 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "VRC1 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "VRC1 CHR-ROM size {} is not a multiple of 4 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_banks: [0, 1, 2],
+ chr_lo: 0,
+ chr_hi: 0,
+ chr_lo_msb: 0,
+ chr_hi_msb: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Vrc1 {
+ // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
+ // skips all four per-CPU-cycle dispatches for this board.
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last = total_8k - 1;
+ let bank = match addr & 0xE000 {
+ 0x8000 => (self.prg_banks[0] as usize) % total_8k,
+ 0xA000 => (self.prg_banks[1] as usize) % total_8k,
+ 0xC000 => (self.prg_banks[2] as usize) % total_8k,
+ 0xE000 => last,
+ _ => return 0,
+ };
+ self.prg_rom[(bank * PRG_BANK_8K + (addr as usize & 0x1FFF)) % self.prg_rom.len()]
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xF000 {
+ 0x8000 => self.prg_banks[0] = value & 0x0F,
+ 0x9000 => {
+ // Mirroring (bit 0) + CHR MSB bits.
+ self.mirroring = if value & 1 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ self.chr_lo_msb = (value >> 1) & 1;
+ self.chr_hi_msb = (value >> 2) & 1;
+ }
+ 0xA000 => self.prg_banks[1] = value & 0x0F,
+ 0xC000 => self.prg_banks[2] = value & 0x0F,
+ 0xE000 => self.chr_lo = value & 0x0F,
+ 0xF000 => self.chr_hi = value & 0x0F,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let len = self.chr_rom.len();
+ self.chr_rom[addr as usize % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(16 + self.vram.len());
+ out.push(1u8);
+ out.extend_from_slice(&self.prg_banks);
+ out.push(self.chr_lo);
+ out.push(self.chr_hi);
+ out.push(self.chr_lo_msb);
+ out.push(self.chr_hi_msb);
+ out.push(self.mirroring as u8);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 9 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != 1 {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_banks.copy_from_slice(&data[1..4]);
+ self.chr_lo = data[4];
+ self.chr_hi = data[5];
+ self.chr_lo_msb = data[6];
+ self.chr_hi_msb = data[7];
+ self.mirroring = match data[8] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr_4k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_4K];
+ for b in 0..banks {
+ v[b * CHR_BANK_4K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn vrc1_basic_banking() {
+ let mut m = Vrc1::new(synth(8), synth_chr_4k(2), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // $E000 is fixed last bank.
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m076_namcot3446.rs b/crates/rustynes-mappers/src/m076_namcot3446.rs
new file mode 100644
index 00000000..dcda08f0
--- /dev/null
+++ b/crates/rustynes-mappers/src/m076_namcot3446.rs
@@ -0,0 +1,247 @@
+//! Namcot 3446 (mapper 76).
+//!
+//! A cut-down relative of the Namco 118 (`m088_namco118.rs`): the same register-pair
+//! protocol -- write a register index to the even address, then its value to
+//! the odd one -- but with a reduced bank layout and no IRQ. It is the shape
+//! Namco used for cheaper cartridges before the MMC3-class boards took over.
+//!
+//! Its sibling 3425 is in `m095_namcot3425.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_2K: usize = 0x0800;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 76 (`NAMCOT-3446`).
+pub struct Namcot3446M76 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg_index: u8,
+ prg_banks: [u8; 2],
+ chr_banks: [u8; 4],
+ mirroring: Mirroring,
+}
+
+impl Namcot3446M76 {
+ /// Construct a new mapper 76 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB or CHR-ROM is empty / not a multiple of 2 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 76 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_2K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 76 CHR-ROM size {} is not a non-zero multiple of 2 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg_index: 0,
+ prg_banks: [0, 1],
+ chr_banks: [0, 1, 2, 3],
+ mirroring,
+ })
+ }
+
+ fn prg_offset(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+}
+
+impl Mapper for Namcot3446M76 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
+ match addr {
+ 0x8000..=0x9FFF => self.prg_offset(self.prg_banks[0] as usize, addr),
+ 0xA000..=0xBFFF => self.prg_offset(self.prg_banks[1] as usize, addr),
+ // `last - 1` would underflow on a single-8 KiB-bank ROM (`last == 0`);
+ // `prg_offset`'s modulo makes both forms identical for multi-bank ROMs.
+ 0xC000..=0xDFFF => self.prg_offset(last.saturating_sub(1), addr),
+ 0xE000..=0xFFFF => self.prg_offset(last, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x8000..=0x9FFF if (addr & 0x01) == 0 => self.reg_index = value & 0x07,
+ 0x8000..=0x9FFF => match self.reg_index {
+ 2 => self.chr_banks[0] = value,
+ 3 => self.chr_banks[1] = value,
+ 4 => self.chr_banks[2] = value,
+ 5 => self.chr_banks[3] = value,
+ 6 => self.prg_banks[0] = value,
+ 7 => self.prg_banks[1] = value,
+ _ => {}
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + 1 + 2 + 4 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg_index);
+ out.extend_from_slice(&self.prg_banks);
+ out.extend_from_slice(&self.chr_banks);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 1 + 1 + 2 + 4 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg_index = data[1];
+ self.prg_banks.copy_from_slice(&data[2..4]);
+ self.chr_banks.copy_from_slice(&data[4..8]);
+ self.vram.copy_from_slice(&data[8..8 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_2k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_2K];
+ for b in 0..banks {
+ v[b * CHR_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m76_register_pairs_select_banks() {
+ let mut m =
+ Namcot3446M76::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ // index 6 -> PRG $8000 = bank 3.
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // index 2 -> CHR slot 0 = bank 5.
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 5);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // $C000/$E000 fixed to last two banks (6, 7).
+ assert_eq!(m.cpu_read(0xC000), 6);
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+
+ #[test]
+ fn m76_save_state_round_trip() {
+ let mut m =
+ Namcot3446M76::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 7);
+ m.cpu_write(0x8001, 4); // PRG $A000 = bank 4
+ let blob = m.save_state();
+ let mut m2 =
+ Namcot3446M76::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xA000), 4);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m077_irem_napoleon.rs b/crates/rustynes-mappers/src/m077_irem_napoleon.rs
new file mode 100644
index 00000000..1371b586
--- /dev/null
+++ b/crates/rustynes-mappers/src/m077_irem_napoleon.rs
@@ -0,0 +1,245 @@
+//! Irem, Napoleon Senki (mapper 77).
+//!
+//! A one-register board whose interest is its memory layout rather than its
+//! logic: CHR banks at 2 KiB granularity, and four nametables of *real* VRAM
+//! wired on the cartridge. That makes its nametable handling four-screen
+//! rather than the usual mirrored pair -- the console's own 2 KiB of CIRAM is
+//! bypassed for the upper half.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_2K: usize = 0x0800;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+/// Mapper 77 (Irem, Napoleon Senki).
+pub struct Irem77 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ /// CHR RAM for $0800-$1FFF (6 KiB).
+ chr_ram: Box<[u8]>,
+ /// 4 KiB on-cart nametable RAM (four 1 KiB screens).
+ nt_ram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+}
+
+impl Irem77 {
+ /// Construct a new mapper 77 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 2 KiB.
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 77 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_2K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 77 CHR-ROM size {} is not a non-zero multiple of 2 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ chr_ram: vec![0u8; 0x1800].into_boxed_slice(), // $0800-$1FFF = 6 KiB
+ nt_ram: vec![0u8; 4 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ })
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+
+ /// Map a $2000-$3EFF nametable address to a 4 KiB on-cart RAM offset.
+ const fn nt_offset(addr: u16) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as usize;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ table * NAMETABLE_SIZE + local
+ }
+}
+
+impl Mapper for Irem77 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.read_prg(addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict: AND with the underlying PRG byte.
+ let effective = value & self.read_prg(addr);
+ self.prg_bank = effective & 0x0F;
+ self.chr_bank = (effective >> 4) & 0x0F;
+ }
+ }
+
+ fn ppu_read(&mut 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.
+ match addr {
+ 0x0800..=0x1FFF => self.chr_ram[addr as usize - 0x0800] = value,
+ 0x2000..=0x3EFF => self.nt_ram[Self::nt_offset(addr)] = value,
+ _ => {}
+ }
+ }
+
+ fn nametable_fetch(&mut self, addr: u16) -> Option {
+ // Consume the nametable read from on-cart 4-screen RAM.
+ Some(self.nt_ram[Self::nt_offset(addr)])
+ }
+
+ fn nametable_write(&mut self, addr: u16, value: u8) -> bool {
+ self.nt_ram[Self::nt_offset(addr)] = value;
+ true
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ Mirroring::FourScreen
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.chr_ram.len() + self.nt_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&self.nt_ram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.chr_ram.len() + self.nt_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ cursor += self.chr_ram.len();
+ self.nt_ram
+ .copy_from_slice(&data[cursor..cursor + self.nt_ram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_2k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_2K];
+ for b in 0..banks {
+ v[b * CHR_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m77_prg_and_2k_chr_with_bus_conflict() {
+ let mut m = Irem77::new(synth_prg_32k(4), synth_chr_2k(16)).unwrap();
+ // Write to $8001 (byte 0xFF, transparent). [CCCC PPPP] = 0b0011_0010.
+ // PRG = 2, CHR (2 KiB at $0000) = 3.
+ m.cpu_write(0x8001, 0b0011_0010);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::FourScreen);
+ }
+
+ #[test]
+ fn m77_chr_ram_and_four_screen_nt() {
+ let mut m = Irem77::new(synth_prg_32k(2), synth_chr_2k(8)).unwrap();
+ // $0800-$1FFF is CHR-RAM.
+ m.ppu_write(0x0800, 0xAB);
+ assert_eq!(m.ppu_read(0x0800), 0xAB);
+ // Four independent nametables in on-cart RAM via the hooks.
+ assert!(m.nametable_write(0x2000, 0x11));
+ assert!(m.nametable_write(0x2400, 0x22));
+ assert!(m.nametable_write(0x2800, 0x33));
+ assert!(m.nametable_write(0x2C00, 0x44));
+ assert_eq!(m.nametable_fetch(0x2000), Some(0x11));
+ assert_eq!(m.nametable_fetch(0x2400), Some(0x22));
+ assert_eq!(m.nametable_fetch(0x2800), Some(0x33));
+ assert_eq!(m.nametable_fetch(0x2C00), Some(0x44));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m78.rs b/crates/rustynes-mappers/src/m078_irem_jaleco78.rs
similarity index 100%
rename from crates/rustynes-mappers/src/m78.rs
rename to crates/rustynes-mappers/src/m078_irem_jaleco78.rs
diff --git a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs
new file mode 100644
index 00000000..c1b4dc18
--- /dev/null
+++ b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs
@@ -0,0 +1,247 @@
+//! AVE `NINA-03` / `NINA-06` (mapper 79).
+//!
+//! Decodes its bank-select register in the `$4100-$5FFF` expansion window
+//! rather than the usual `$8000-$FFFF` -- specifically on address line A8, so
+//! the write only lands at addresses with that bit set. Decoding down there
+//! avoids bus conflicts without any gating logic, since no ROM drives the bus
+//! in that range.
+//!
+//! `NINA-006` (mapper 113) is the multicart relative, in
+//! `m113_ave_nina006.rs`; `NINA-001` is an unrelated board on mapper 34, in
+//! `m034_bnrom_nina001.rs`.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 79 (AVE `NINA-03`/`NINA-06`).
+pub struct Nina0379 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Nina0379 {
+ /// Construct a new mapper 79 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 79 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 79 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Nina0379 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The register window lives in $4100-$5FFF; reads there are open bus
+ // (write-only registers), so the default `cpu_read_unmapped` is correct.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // $4100-$5FFF, decoded on A8.
+ if (0x4100..=0x5FFF).contains(&addr) && (addr & 0x0100) != 0 {
+ self.chr_bank = value & 0x07;
+ self.prg_bank = (value >> 3) & 0x01;
+ }
+ }
+
+ 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[nametable_offset(addr, self.mirroring)],
+ _ => 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 = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m79_register_decodes_on_a8() {
+ let mut m =
+ Nina0379::new(synth_prg_32k(2), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ // $4100 has A8 set: value 0b0000_1101 -> CHR = 0b101 = 5, PRG = bit3 = 1.
+ m.cpu_write(0x4100, 0b0000_1101);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ }
+
+ #[test]
+ fn m79_no_decode_without_a8() {
+ let mut m =
+ Nina0379::new(synth_prg_32k(2), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ // $4000-class addresses are not in $4100-$5FFF; and an in-range addr
+ // with A8 clear ($4200) must NOT latch.
+ m.cpu_write(0x4200, 0b0000_1111);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ }
+}
diff --git a/crates/rustynes-mappers/src/taito_x1_005.rs b/crates/rustynes-mappers/src/m080_taito_x1_005.rs
similarity index 100%
rename from crates/rustynes-mappers/src/taito_x1_005.rs
rename to crates/rustynes-mappers/src/m080_taito_x1_005.rs
diff --git a/crates/rustynes-mappers/src/taito_x1_017.rs b/crates/rustynes-mappers/src/m082_taito_x1_017.rs
similarity index 100%
rename from crates/rustynes-mappers/src/taito_x1_017.rs
rename to crates/rustynes-mappers/src/m082_taito_x1_017.rs
diff --git a/crates/rustynes-mappers/src/m085_vrc7.rs b/crates/rustynes-mappers/src/m085_vrc7.rs
new file mode 100644
index 00000000..2582c03a
--- /dev/null
+++ b/crates/rustynes-mappers/src/m085_vrc7.rs
@@ -0,0 +1,1000 @@
+//! Konami VRC7 (mapper 85) -- banking, the VRC IRQ counter, and the on-cart
+//! YM2413-derivative OPLL FM synthesizer.
+//!
+//! The banking and IRQ halves are ordinary VRC4-family behaviour. The audio
+//! half is a cut-down OPLL: six FM channels driven from a fixed internal
+//! patch ROM plus one user-programmable patch, clocked once every 36 CPU
+//! cycles. The synthesizer itself lives in the shared OPLL core; this module
+//! owns the mapper-side register file ([`Vrc7AudioRegs`]), the `$9010`/`$9030`
+//! address/data port pair, and the `$E000` bit-7 audio-mute line.
+//!
+//! Audio is gated behind the `mapper-audio` Cargo feature (default ON); with
+//! it off the register decoders still latch so save states remain portable
+//! across feature configurations (ADR 0004). See ADR 0006 for the decision
+//! record on landing VRC7 audio.
+//!
+//! See `docs/mappers.md` and `docs/apu-2a03.md` §Expansion-audio levels.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::needless_pass_by_ref_mut,
+ clippy::manual_range_patterns,
+ clippy::match_same_arms,
+ clippy::struct_excessive_bools,
+ clippy::doc_markdown,
+ clippy::range_plus_one,
+ clippy::single_match_else,
+ clippy::bool_to_int_with_if,
+ clippy::unnested_or_patterns,
+ clippy::single_match,
+ clippy::doc_lazy_continuation,
+ clippy::too_long_first_doc_paragraph
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// VRC7 audio register snapshot.
+///
+/// Two latches: the OPLL register address (set by writes to `$9010`)
+/// and the data byte (set by writes to `$9030` after `$9010`). Per
+/// ADR-0004, this is **decoded and latched but not synthesized** in
+/// v0.9.x — the byte stream sits available for a future v1.x OPLL
+/// integration, and save-state round-trip works in both directions
+/// without an audio backend.
+#[derive(Clone)]
+struct Vrc7AudioRegs {
+ /// Last 6-bit register address written to `$9010`. YM2413 has 64
+ /// addressable registers; VRC7 exposes a 6-channel subset.
+ addr_latch: u8,
+ /// Last data byte written to `$9030`. Available for inspection /
+ /// equivalence testing against a future OPLL backend.
+ data_latch: u8,
+ /// 64-entry shadow of the most recent data written to each OPLL
+ /// register address. A future synthesizer reads this on demand
+ /// (e.g. on key-on) to seed channel state without re-running the
+ /// register-write history. Sized at 64 to match the full YM2413
+ /// register space (the chip's 6 channels use $10-$15 / $20-$25 /
+ /// $30-$35; instrument bytes are at $00-$07).
+ regs: [u8; 64],
+ /// Mirror of `$E000` bit 7 (expansion-sound silence). When set, a
+ /// future synthesizer's output is forced to zero; banking + IRQ
+ /// are unaffected.
+ silenced: bool,
+}
+
+impl Default for Vrc7AudioRegs {
+ fn default() -> Self {
+ Self {
+ addr_latch: 0,
+ data_latch: 0,
+ regs: [0u8; 64],
+ silenced: false,
+ }
+ }
+}
+
+/// VRC7 (Mapper 85). Banking + IRQ + (deferred per ADR-0004) FM audio
+/// surface for Lagrange Point.
+pub struct Vrc7 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+
+ /// 8 KiB PRG bank at $8000-$9FFF.
+ prg_0: u8,
+ /// 8 KiB PRG bank at $A000-$BFFF.
+ prg_1: u8,
+ /// 8 KiB PRG bank at $C000-$DFFF.
+ prg_2: u8,
+ /// 1 KiB CHR banks at $0000-$1FFF (one entry per KiB).
+ chr: [u8; 8],
+ mirroring: Mirroring,
+
+ // IRQ counter (identical shape to VRC6's).
+ irq_latch: u8,
+ irq_counter: u8,
+ irq_enabled: bool,
+ irq_enable_after_ack: bool,
+ irq_mode_scanline: bool,
+ irq_prescaler: i32,
+ irq_pending: bool,
+
+ /// PRG-RAM enable (bit 6 of `$E000`). When clear, `$6000-$7FFF`
+ /// reads/writes are ignored.
+ prg_ram_enable: bool,
+
+ /// 8 KiB WRAM at `$6000-$7FFF`. Lagrange Point's boot routine runs a
+ /// write-then-read-back self-test on this region (`STA ($00),Y` /
+ /// `CMP ($00),Y` with `$00/$01 = $6000`); without backing storage the
+ /// read-back always returned 0, the compare failed, and the game
+ /// jumped to its lockup loop at `$EC2F` (blank gray screen — it never
+ /// reaches CHR-RAM / nametable upload). Backed now, mirroring the
+ /// VRC2/VRC4 WRAM fix (T-60-003b).
+ prg_ram: Box<[u8]>,
+
+ /// Audio register surface. Decoded and latched in v0.9.x; not yet
+ /// synthesized (see ADR-0004).
+ audio: Vrc7AudioRegs,
+
+ /// OPLL FM synthesizer. Lives behind the `mapper-audio` feature
+ /// to keep the no_std cross-compile cheap; when the feature is
+ /// off, `mix_audio` returns 0 unconditionally (matching the
+ /// pre-v1.1.0 ADR-0004 deferred state).
+ #[cfg(feature = "mapper-audio")]
+ opll: rustynes_apu::Opll,
+
+ /// CPU-cycle counter for the OPLL native sample rate. NES NTSC
+ /// CPU runs at 1,789,773 Hz; the OPLL native rate is 49,716 Hz.
+ /// `1789773 / 49716 ≈ 35.997` — we tick the OPLL every 36 CPU
+ /// cycles, which is correct to 0.008% (< 1 Hz tuning drift).
+ #[cfg(feature = "mapper-audio")]
+ opll_clock_counter: u16,
+
+ /// Latest OPLL sample. The mapper holds this between OPLL ticks
+ /// (every 36 CPU cycles) so `mix_audio` calls in between return
+ /// the most-recent value. The APU's band-limited synthesis
+ /// handles the rate conversion from OPLL's 49,716 Hz to the
+ /// host sample rate.
+ #[cfg(feature = "mapper-audio")]
+ last_opll_sample: i16,
+}
+
+impl Vrc7 {
+ /// Construct a new VRC7 mapper.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] if the PRG-ROM size is not a
+ /// non-zero multiple of 8 KiB or the CHR-ROM size 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "VRC7 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "VRC7 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom: chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_0: 0,
+ prg_1: 0,
+ prg_2: 0,
+ chr: [0; 8],
+ mirroring,
+ irq_latch: 0,
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_enable_after_ack: false,
+ irq_mode_scanline: false,
+ irq_prescaler: 341,
+ irq_pending: false,
+ prg_ram_enable: false,
+ prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
+ audio: Vrc7AudioRegs::default(),
+ #[cfg(feature = "mapper-audio")]
+ opll: rustynes_apu::Opll::new(rustynes_apu::OpllChipType::Vrc7),
+ #[cfg(feature = "mapper-audio")]
+ opll_clock_counter: 0,
+ #[cfg(feature = "mapper-audio")]
+ last_opll_sample: 0,
+ })
+ }
+
+ fn prg_offset(&self, addr: u16) -> usize {
+ let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let last1 = total_8k - 1;
+ let (bank, off_in_8k) = match addr {
+ 0x8000..=0x9FFF => (self.prg_0 as usize, addr as usize & 0x1FFF),
+ 0xA000..=0xBFFF => (self.prg_1 as usize, addr as usize & 0x1FFF),
+ 0xC000..=0xDFFF => (self.prg_2 as usize, addr as usize & 0x1FFF),
+ 0xE000..=0xFFFF => (last1, addr as usize & 0x1FFF),
+ _ => return 0,
+ };
+ (bank % total_8k) * PRG_BANK_8K + off_in_8k
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let addr = (addr & 0x1FFF) as usize;
+ let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = addr / CHR_BANK_1K;
+ let bank = (self.chr[slot] as usize) % total_1k;
+ bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
+ }
+
+ fn clock_irq_counter(&mut self) {
+ if self.irq_counter == 0xFF {
+ self.irq_counter = self.irq_latch;
+ self.irq_pending = true;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_add(1);
+ }
+ }
+
+ /// Decode mirroring from the low 2 bits of `$E000`. Per NESdev
+ /// "VRC7": `00` = vertical, `01` = horizontal, `10` = single-screen
+ /// A, `11` = single-screen B.
+ fn decode_mirroring(value: u8) -> Mirroring {
+ match value & 0x03 {
+ 0 => Mirroring::Vertical,
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::SingleScreenA,
+ _ => Mirroring::SingleScreenB,
+ }
+ }
+}
+
+impl Mapper for Vrc7 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
+ // (the audio hook only exists under the `mapper-audio` feature).
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: true,
+ audio: cfg!(feature = "mapper-audio"),
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => {
+ // 8 KiB WRAM. Backed by storage so Lagrange Point's boot
+ // RAM self-test (write then read-back) succeeds. The
+ // enable bit (`$E000` bit 6) is modelled for completeness
+ // but does not gate the backing store: the game toggles it
+ // around the test, and real VRC7 emulators keep the WRAM
+ // continuously addressable.
+ self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()]
+ }
+ 0x8000..=0xFFFF => {
+ let off = self.prg_offset(addr);
+ self.prg_rom[off % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // VRC7 register decoding tolerates both A3 (`$_008`) and A4
+ // (`$_010`) variants per board revision. The high-nibble
+ // selector picks the register family; within each family the
+ // bank/IRQ/audio variant is chosen by bits 4-5 of the low byte.
+ match addr & 0xF000 {
+ 0x6000 | 0x7000 => {
+ // 8 KiB WRAM write (backed; see cpu_read).
+ let len = self.prg_ram.len();
+ self.prg_ram[(addr - 0x6000) as usize % len] = value;
+ }
+ 0x8000 => {
+ // $8000 selects PRG bank 0; $8010 / $8008 selects bank 1.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.prg_1 = value & 0x3F;
+ } else {
+ self.prg_0 = value & 0x3F;
+ }
+ }
+ 0x9000 => {
+ // $9000 (and $9008 mirror) -> PRG bank 2.
+ // $9010 (and $9018 mirror) -> OPLL register address latch.
+ // $9030 (and $9038 mirror) -> OPLL register data write.
+ let sub = addr & 0x0030;
+ if sub == 0x0010 {
+ self.audio.addr_latch = value & 0x3F;
+ } else if sub == 0x0030 {
+ let idx = (self.audio.addr_latch & 0x3F) as usize;
+ self.audio.regs[idx] = value;
+ self.audio.data_latch = value;
+ // Forward to the OPLL synthesizer. The address was
+ // latched on the previous `$9010` write; per
+ // `Vrc7Audio.h` (Mesen2) this is the canonical
+ // shape — `WriteReg($9010, addr); WriteReg($9030, data)`.
+ // The 7-cycle inter-write delay Lagrange Point
+ // observes on real hardware is enforced by the CPU
+ // emitter; the chip latches each independently.
+ #[cfg(feature = "mapper-audio")]
+ self.opll.write_reg(self.audio.addr_latch, value);
+ } else {
+ // $9000 / $9008 / $9020 / $9028 -> PRG bank 2.
+ self.prg_2 = value & 0x3F;
+ }
+ }
+ 0xA000 => {
+ // CHR banks 0 / 1.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.chr[1] = value;
+ } else {
+ self.chr[0] = value;
+ }
+ }
+ 0xB000 => {
+ // CHR banks 2 / 3.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.chr[3] = value;
+ } else {
+ self.chr[2] = value;
+ }
+ }
+ 0xC000 => {
+ // CHR banks 4 / 5.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.chr[5] = value;
+ } else {
+ self.chr[4] = value;
+ }
+ }
+ 0xD000 => {
+ // CHR banks 6 / 7.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.chr[7] = value;
+ } else {
+ self.chr[6] = value;
+ }
+ }
+ 0xE000 => {
+ // $E000: mirroring (bits 1-0), WRAM enable (bit 6),
+ // expansion-sound silence (bit 7).
+ // $E008 / $E010: IRQ latch.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.irq_latch = value;
+ } else {
+ self.mirroring = Self::decode_mirroring(value);
+ self.prg_ram_enable = (value & 0x40) != 0;
+ self.audio.silenced = (value & 0x80) != 0;
+ }
+ }
+ 0xF000 => {
+ // $F000: IRQ control. $F008/$F010: IRQ acknowledge.
+ if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
+ self.irq_pending = false;
+ self.irq_enabled = self.irq_enable_after_ack;
+ } else {
+ self.irq_enable_after_ack = (value & 0x01) != 0;
+ self.irq_enabled = (value & 0x02) != 0;
+ self.irq_mode_scanline = (value & 0x04) == 0;
+ if self.irq_enabled {
+ self.irq_counter = self.irq_latch;
+ self.irq_prescaler = 341;
+ }
+ self.irq_pending = false;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ // Must go through the SAME banked offset `ppu_read` uses
+ // (`chr_offset`), not the raw PPU address — otherwise a
+ // game that banks CHR-RAM (Lagrange Point) writes tiles to
+ // one offset and reads them back from another, leaving the
+ // pattern tables effectively blank.
+ let off = self.chr_offset(addr);
+ let len = self.chr_rom.len();
+ self.chr_rom[off % len] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring) % self.vram.len();
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ // Advance the OPLL synthesizer every 36 CPU cycles, matching
+ // the NES NTSC CPU clock / OPLL native sample rate ratio.
+ // Holds the produced sample in `last_opll_sample` for the
+ // bus's per-APU-sample `mix_audio` calls.
+ #[cfg(feature = "mapper-audio")]
+ {
+ self.opll_clock_counter = self.opll_clock_counter.wrapping_add(1);
+ if self.opll_clock_counter >= 36 {
+ self.opll_clock_counter = 0;
+ self.last_opll_sample = self.opll.calc();
+ }
+ }
+
+ if !self.irq_enabled {
+ return;
+ }
+ if self.irq_mode_scanline {
+ self.irq_prescaler -= 3;
+ if self.irq_prescaler <= 0 {
+ self.irq_prescaler += 341;
+ self.clock_irq_counter();
+ }
+ } else {
+ self.clock_irq_counter();
+ }
+ }
+
+ /// Mix the current OPLL sample into the APU's external-audio
+ /// channel. Returns 0 when the cartridge's expansion-sound
+ /// silence bit (`$E000` bit 7) is set OR the `mapper-audio`
+ /// feature is off; otherwise returns the most-recent OPLL
+ /// sample in the i16 range [-4095, 4095] (the chip's
+ /// 13-bit DAC scaled to 14-bit signed via `<< 1` in the
+ /// `lookup_exp_table` final stage).
+ #[cfg(feature = "mapper-audio")]
+ fn mix_audio(&mut self) -> i32 {
+ if self.audio.silenced {
+ 0
+ } else {
+ i32::from(self.last_opll_sample)
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
+ let mut info = crate::mapper::MapperDebugInfo {
+ mapper_id: 85,
+ name: "VRC7".into(),
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ info.prg_banks
+ .push(("PRG0".into(), format!("{:#04x}", self.prg_0)));
+ info.prg_banks
+ .push(("PRG1".into(), format!("{:#04x}", self.prg_1)));
+ info.prg_banks
+ .push(("PRG2".into(), format!("{:#04x}", self.prg_2)));
+ for (i, b) in self.chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{b:#04x}")));
+ }
+ info.irq_state
+ .push(("latch".into(), format!("{:#04x}", self.irq_latch)));
+ info.irq_state
+ .push(("counter".into(), format!("{:#04x}", self.irq_counter)));
+ info.irq_state
+ .push(("enabled".into(), format!("{}", self.irq_enabled)));
+ info.irq_state
+ .push(("pending".into(), format!("{}", self.irq_pending)));
+ info.extra.push((
+ "audio".into(),
+ "deferred (ADR-0004; mapper 85 audio = silent)".into(),
+ ));
+ info.extra.push((
+ "audio_addr".into(),
+ format!("{:#04x}", self.audio.addr_latch),
+ ));
+ info.extra.push((
+ "audio_data".into(),
+ format!("{:#04x}", self.audio.data_latch),
+ ));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ // v1 layout (audio synthesis deferred per ADR-0004):
+ // version(1)
+ // prg_0 / prg_1 / prg_2 (3)
+ // chr[0..8] (8)
+ // mirroring(1) + prg_ram_enable(1)
+ // irq_latch(1) + irq_counter(1) + irq_enabled(1) +
+ // irq_enable_after_ack(1) + irq_mode_scanline(1) +
+ // irq_prescaler(4 le) + irq_pending(1)
+ // audio addr_latch(1) + data_latch(1) + silenced(1) +
+ // audio.regs[0..64] (64)
+ // vram (2 KiB)
+ //
+ // Per ADR-0003: the future v1.x commit that lands the OPLL state
+ // bumps version 1 → 2, appending the synthesizer's internal
+ // state (operator phases, envelope phases, key-on flags) at the
+ // tail. v1 blobs default-load the synthesizer to silent.
+ // version(1) + prg(3) + chr(8) + mirroring(1) + prg_ram_enable(1)
+ // + irq_latch(1) + irq_counter(1) + irq_enabled(1)
+ // + irq_enable_after_ack(1) + irq_mode_scanline(1)
+ // + irq_prescaler(4) + irq_pending(1)
+ // + audio addr_latch(1) + data_latch(1) + silenced(1) + regs(64)
+ // = 1 + 3 + 8 + 1 + 1 + 5 + 5 + 67 = 91
+ let scalar_len = 1 + 3 + 8 + 1 + 1 + 10 + 3 + 64;
+ let mut out = Vec::with_capacity(scalar_len + self.vram.len());
+ out.push(1u8); // version
+ out.push(self.prg_0);
+ out.push(self.prg_1);
+ out.push(self.prg_2);
+ out.extend_from_slice(&self.chr);
+ out.push(self.mirroring as u8);
+ out.push(u8::from(self.prg_ram_enable));
+ out.push(self.irq_latch);
+ out.push(self.irq_counter);
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_enable_after_ack));
+ out.push(u8::from(self.irq_mode_scanline));
+ out.extend_from_slice(&self.irq_prescaler.to_le_bytes());
+ out.push(u8::from(self.irq_pending));
+ out.push(self.audio.addr_latch);
+ out.push(self.audio.data_latch);
+ out.push(u8::from(self.audio.silenced));
+ out.extend_from_slice(&self.audio.regs);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ // version(1) + prg(3) + chr(8) + mirroring(1) + prg_ram_enable(1)
+ // + irq_latch(1) + irq_counter(1) + irq_enabled(1)
+ // + irq_enable_after_ack(1) + irq_mode_scanline(1)
+ // + irq_prescaler(4) + irq_pending(1)
+ // + audio addr_latch(1) + data_latch(1) + silenced(1) + regs(64)
+ // = 1 + 3 + 8 + 1 + 1 + 5 + 5 + 67 = 91
+ let scalar_len = 1 + 3 + 8 + 1 + 1 + 10 + 3 + 64;
+ let core_expected = scalar_len + self.vram.len();
+ if data.len() < core_expected {
+ return Err(MapperError::Truncated {
+ expected: core_expected,
+ got: data.len(),
+ });
+ }
+ let version = data[0];
+ if version != 1 {
+ return Err(MapperError::UnsupportedVersion(version));
+ }
+ self.prg_0 = data[1];
+ self.prg_1 = data[2];
+ self.prg_2 = data[3];
+ self.chr.copy_from_slice(&data[4..12]);
+ self.mirroring = match data[12] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
+ };
+ self.prg_ram_enable = data[13] != 0;
+ self.irq_latch = data[14];
+ self.irq_counter = data[15];
+ self.irq_enabled = data[16] != 0;
+ self.irq_enable_after_ack = data[17] != 0;
+ self.irq_mode_scanline = data[18] != 0;
+ self.irq_prescaler = i32::from_le_bytes(
+ data[19..23]
+ .try_into()
+ .map_err(|_| MapperError::Invalid("prescaler".into()))?,
+ );
+ self.irq_pending = data[23] != 0;
+ self.audio.addr_latch = data[24];
+ self.audio.data_latch = data[25];
+ self.audio.silenced = data[26] != 0;
+ self.audio.regs.copy_from_slice(&data[27..91]);
+ self.vram.copy_from_slice(&data[91..91 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth(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.into_boxed_slice()
+ }
+
+ fn synth_chr(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.into_boxed_slice()
+ }
+
+ fn vrc7_default() -> Vrc7 {
+ // 8 × 8 KiB PRG (bank index byte at offset 0 of each bank to make
+ // the read path observable) + 16 × 1 KiB CHR (likewise).
+ Vrc7::new(synth(8), synth_chr(16), Mirroring::Vertical).unwrap()
+ }
+
+ #[test]
+ fn vrc7_prg_banking_three_switchable_plus_fixed_last() {
+ let mut m = vrc7_default();
+ // $8000 = PRG bank 0 (window $8000-$9FFF). Pick bank 5.
+ m.cpu_write(0x8000, 5);
+ // $8010 = PRG bank 1 ($A000-$BFFF). Pick bank 3.
+ m.cpu_write(0x8010, 3);
+ // $9000 = PRG bank 2 ($C000-$DFFF). Pick bank 7.
+ m.cpu_write(0x9000, 7);
+ // Read at the start of each window returns the synth's bank-index
+ // byte (bank index lives at offset 0 of each 8 KiB bank).
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xA000), 3);
+ assert_eq!(m.cpu_read(0xC000), 7);
+ // $E000-$FFFF is fixed to the LAST bank (synth has 8 banks → 7).
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+
+ #[test]
+ fn vrc7_prg_banking_accepts_a3_a4_mirror() {
+ // $8008 is the A3 mirror of $8010 → both select PRG bank 1.
+ let mut m = vrc7_default();
+ m.cpu_write(0x8008, 4);
+ assert_eq!(m.cpu_read(0xA000), 4);
+ m.cpu_write(0x8010, 2);
+ assert_eq!(m.cpu_read(0xA000), 2);
+ }
+
+ #[test]
+ fn vrc7_chr_banking_all_eight_slots() {
+ // CHR banks 0..=7 are addressable at $A000 / $A010 / $B000 /
+ // $B010 / $C000 / $C010 / $D000 / $D010. Each 1 KiB CHR bank
+ // in the synth ROM carries its bank index at offset 0.
+ let mut m = vrc7_default();
+ let writes = [
+ (0xA000u16, 1u8, 0x0000u16),
+ (0xA010, 2, 0x0400),
+ (0xB000, 3, 0x0800),
+ (0xB010, 4, 0x0C00),
+ (0xC000, 5, 0x1000),
+ (0xC010, 6, 0x1400),
+ (0xD000, 7, 0x1800),
+ (0xD010, 8, 0x1C00),
+ ];
+ for (addr, bank, _) in writes {
+ m.cpu_write(addr, bank);
+ }
+ for (_, bank, ppu_addr) in writes {
+ assert_eq!(m.ppu_read(ppu_addr), bank, "CHR slot for {ppu_addr:#x}");
+ }
+ }
+
+ #[test]
+ fn vrc7_mirroring_decode_from_e000_low_bits() {
+ let mut m = vrc7_default();
+ // 00 = Vertical (the default).
+ m.cpu_write(0xE000, 0b0000_0000);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ // 01 = Horizontal.
+ m.cpu_write(0xE000, 0b0000_0001);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // 10 = SingleScreen A.
+ m.cpu_write(0xE000, 0b0000_0010);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ // 11 = SingleScreen B.
+ m.cpu_write(0xE000, 0b0000_0011);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
+ }
+
+ #[test]
+ fn vrc7_irq_counter_cycle_mode_pending() {
+ // CPU-cycle mode: counter increments every CPU cycle; on $FF
+ // it reloads from latch and asserts IRQ. Same shape as VRC6.
+ let mut m = vrc7_default();
+ // Latch: 0xFE (so we need only 2 ticks to wrap from 0xFE -> 0xFF -> 0x00 + pending).
+ m.cpu_write(0xE008, 0xFE); // $E008 = IRQ latch
+ // Control: enable + cycle mode (mode bit 2 = 1 means CPU cycle).
+ // Bit 0 = enable_after_ack; bit 1 = enable; bit 2 = mode (1=cycle, 0=scanline).
+ m.cpu_write(0xF000, 0b0000_0110);
+ // After enable, counter = latch = 0xFE. Ticking until pending:
+ // 0xFE -> 0xFF (clock 1), pending fires (clock 2 reloads from latch).
+ m.notify_cpu_cycle();
+ assert!(!m.irq_pending(), "after 1 cycle, counter only at 0xFF");
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending(), "after 2 cycles, pending should be set");
+ }
+
+ #[test]
+ fn vrc7_irq_ack_clears_pending_and_restores_enable_state() {
+ // After IRQ fires, $F010 ack clears pending and restores
+ // enable from enable_after_ack. Match the VRC6 contract.
+ let mut m = vrc7_default();
+ m.cpu_write(0xE008, 0xFE);
+ m.cpu_write(0xF000, 0b0000_0111); // enable_after_ack=1, enable=1, cycle mode
+ m.notify_cpu_cycle();
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending());
+ m.cpu_write(0xF010, 0); // ack
+ assert!(!m.irq_pending());
+ assert!(m.irq_enabled, "enable should be restored from after_ack");
+ }
+
+ #[test]
+ fn vrc7_audio_register_latch_round_trip() {
+ // Per ADR-0004 the synthesizer is deferred, but the register
+ // surface must still latch state cleanly. This test pins the
+ // contract a future v1.x OPLL integration will read from.
+ let mut m = vrc7_default();
+ m.cpu_write(0x9010, 0x10); // OPLL register address = 0x10
+ assert_eq!(m.audio.addr_latch, 0x10);
+ m.cpu_write(0x9030, 0x42); // OPLL data byte
+ assert_eq!(m.audio.data_latch, 0x42);
+ assert_eq!(m.audio.regs[0x10], 0x42);
+ // A second address+data pair: write 0x30 (channel-1 volume +
+ // instrument select) then a different data byte.
+ m.cpu_write(0x9010, 0x30);
+ m.cpu_write(0x9030, 0x5F); // top nibble = inst 5, low nibble = vol 0xF
+ assert_eq!(m.audio.regs[0x30], 0x5F);
+ // Earlier write at 0x10 is preserved (independent slots).
+ assert_eq!(m.audio.regs[0x10], 0x42);
+ }
+
+ #[test]
+ fn vrc7_audio_custom_instrument_bytes_route_to_registers_0_through_7() {
+ // The 8 custom-instrument bytes live at OPLL registers $00-$07.
+ // Confirm they land in the right slots when written through
+ // the $9010 / $9030 protocol.
+ let mut m = vrc7_default();
+ for i in 0..8u8 {
+ m.cpu_write(0x9010, i);
+ m.cpu_write(0x9030, 0xA0 | i); // distinct payload per slot
+ assert_eq!(m.audio.regs[i as usize], 0xA0 | i);
+ }
+ }
+
+ #[test]
+ fn vrc7_mix_audio_silent_with_no_key_on() {
+ // Sprint 1.2 (v1.1.0): OPLL is wired but no channel has been
+ // keyed on — every slot's envelope sits at EG_MUTE, so every
+ // OPLL sample is 0. The mix_audio output should therefore be
+ // 0 across the entire register-surface scan.
+ let mut m = vrc7_default();
+ for reg in 0..=0x35u8 {
+ m.cpu_write(0x9010, reg);
+ m.cpu_write(0x9030, 0x00); // zero-fill — no key-on bits
+ }
+ // Tick the OPLL several times to confirm calc() also returns 0.
+ for _ in 0..200 {
+ m.notify_cpu_cycle();
+ }
+ assert_eq!(
+ m.mix_audio(),
+ 0,
+ "VRC7 mix_audio must be silent without key-on; got non-zero"
+ );
+ }
+
+ #[test]
+ fn vrc7_mix_audio_silenced_by_e000_bit7() {
+ // Even with a keyed-on channel, the `$E000` expansion-sound
+ // silence bit (bit 7) must force mix_audio to 0. Mesen2 calls
+ // this the "muted" flag in Vrc7Audio.h.
+ let mut m = vrc7_default();
+ // Set up channel 0: instrument 1, fnum 256, block 4, key-on,
+ // max volume (volume bits low = max — OPLL volume is attenuation).
+ m.cpu_write(0x9010, 0x30); // $30 = inst/volume for ch 0
+ m.cpu_write(0x9030, 0x10); // inst 1, volume 0 (loudest)
+ m.cpu_write(0x9010, 0x10); // $10 = fnum low for ch 0
+ m.cpu_write(0x9030, 0x00);
+ m.cpu_write(0x9010, 0x20); // $20 = fnum high + block + key for ch 0
+ m.cpu_write(0x9030, 0x35); // key-on bit set + block + fnum high
+ // Tick enough cycles for the envelope to clear Damp → Attack.
+ for _ in 0..16_384 {
+ m.notify_cpu_cycle();
+ }
+ // Now flip the silence bit on `$E000`.
+ m.cpu_write(0xE000, 0x80);
+ assert_eq!(
+ m.mix_audio(),
+ 0,
+ "silenced VRC7 must mix to 0; got non-zero"
+ );
+ // Verify the OPLL still ticks (its internal state advances) —
+ // re-clear silence and the audio should resume.
+ m.cpu_write(0xE000, 0x00);
+ // We don't assert non-zero here because the OPLL might have
+ // landed on a zero-crossing this exact tick — just confirm
+ // the silenced gate is the only thing stopping output.
+ // (The non-zero output is covered by the next test.)
+ }
+
+ #[test]
+ fn vrc7_opll_register_writes_forwarded_on_data_write() {
+ // `$9030` data writes must be forwarded to the OPLL's
+ // register shadow. Verifies the integration point even
+ // without ticking the synth.
+ let mut m = vrc7_default();
+ m.cpu_write(0x9010, 0x20); // address latch = $20
+ m.cpu_write(0x9030, 0x55); // data write
+ // Snapshot stores the byte in both the mapper's audio.regs
+ // (for save-state round-trip) and the OPLL's register shadow.
+ assert_eq!(m.audio.regs[0x20], 0x55);
+ #[cfg(feature = "mapper-audio")]
+ assert_eq!(
+ m.opll.read_reg(0x20),
+ 0x55,
+ "OPLL register shadow should mirror $9030 writes"
+ );
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn vrc7_keyed_on_channel_produces_nonzero_mix_within_one_envelope() {
+ // End-to-end: configure channel 0 with VRC7 patch 1, key on,
+ // run enough CPU cycles for Damp → Attack to progress past
+ // EG_MUTE, and observe a non-zero mix_audio sample.
+ let mut m = vrc7_default();
+ // Channel 0 setup matching the OPLL unit test's manual setup.
+ // $30 → bits 3-0 = volume (attenuation), bits 7-4 = instrument
+ m.cpu_write(0x9010, 0x30);
+ m.cpu_write(0x9030, 0x10); // inst=1, vol=0
+ m.cpu_write(0x9010, 0x10);
+ m.cpu_write(0x9030, 0x80); // fnum low byte
+ m.cpu_write(0x9010, 0x20);
+ m.cpu_write(0x9030, 0x35); // key-on + block(2) + fnum high(1)
+ // Each OPLL sample = 36 CPU cycles. 16,384 CPU cycles = ~455
+ // OPLL samples = ~9 ms of audio. Damp → Attack happens within
+ // a few hundred OPLL samples for any non-saturated AR.
+ // u32: `mix_audio` widened to i32 in v2.2.3 (A1).
+ let mut peak_abs: u32 = 0;
+ for _ in 0..16_384 {
+ m.notify_cpu_cycle();
+ let s = m.mix_audio();
+ peak_abs = peak_abs.max(s.unsigned_abs());
+ }
+ assert!(
+ peak_abs > 0,
+ "expected non-zero VRC7 mix after key-on + 16k cycles; got peak_abs={peak_abs}"
+ );
+ }
+
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn vrc7_opll_ticks_every_36_cpu_cycles() {
+ // The OPLL is clocked at NES NTSC CPU rate / 36. Verify the
+ // internal counter rolls over exactly on the 36th call to
+ // notify_cpu_cycle by watching eg_counter (which advances
+ // once per OPLL tick inside `update_slots`).
+ let mut m = vrc7_default();
+ // No way to read eg_counter through the public API, but we
+ // CAN read opll_clock_counter via direct field access in
+ // this module-local test. After 35 cycles, counter = 35;
+ // after 36, counter resets to 0 and the OPLL has advanced.
+ for _ in 0..35 {
+ m.notify_cpu_cycle();
+ }
+ assert_eq!(m.opll_clock_counter, 35);
+ m.notify_cpu_cycle();
+ assert_eq!(
+ m.opll_clock_counter, 0,
+ "counter should reset on 36th cycle"
+ );
+ }
+
+ #[test]
+ fn vrc7_save_state_round_trip_preserves_banking_irq_and_audio_latches() {
+ // v1 round-trip: configure banking, IRQ counter mid-state, and
+ // audio register latches → save → reload into a fresh mapper
+ // → all fields match.
+ let mut m = vrc7_default();
+ m.cpu_write(0x8000, 5);
+ m.cpu_write(0x8010, 3);
+ m.cpu_write(0x9000, 7);
+ m.cpu_write(0xA000, 1);
+ m.cpu_write(0xD010, 6);
+ m.cpu_write(0xE000, 0b1100_0001); // Horizontal + WRAM enable + audio silenced
+ m.cpu_write(0xE008, 0x80); // IRQ latch
+ m.cpu_write(0xF000, 0b0000_0011); // enable + scanline mode
+ // Audio register stream.
+ m.cpu_write(0x9010, 0x30);
+ m.cpu_write(0x9030, 0x5F);
+ let blob = m.save_state();
+ assert_eq!(blob[0], 1u8, "VRC7 save-state version tag");
+
+ let mut target = vrc7_default();
+ target.load_state(&blob).unwrap();
+ assert_eq!(target.cpu_read(0x8000), 5);
+ assert_eq!(target.cpu_read(0xA000), 3);
+ assert_eq!(target.cpu_read(0xC000), 7);
+ assert_eq!(target.ppu_read(0x0000), 1);
+ assert_eq!(target.ppu_read(0x1C00), 6);
+ assert_eq!(target.current_mirroring(), Mirroring::Horizontal);
+ assert!(target.prg_ram_enable);
+ assert!(target.audio.silenced);
+ assert_eq!(target.irq_latch, 0x80);
+ assert!(target.irq_enabled);
+ // We wrote 0b0000_0011 → bit 2 (mode) = 0 → scanline mode is on
+ // (the predicate is `(value & 0x04) == 0`).
+ assert!(target.irq_mode_scanline);
+ assert_eq!(target.audio.regs[0x30], 0x5F);
+ }
+
+ #[test]
+ fn vrc7_save_state_rejects_unknown_version() {
+ // Pre-v1 there is no VRC7 save-state; a future v1.x bumps to 2.
+ // Until then, any version != 1 must be rejected cleanly.
+ let m = vrc7_default();
+ let mut blob = m.save_state();
+ blob[0] = 99;
+ let mut target = vrc7_default();
+ let err = target.load_state(&blob).expect_err("must reject");
+ assert!(
+ matches!(err, MapperError::UnsupportedVersion(99)),
+ "expected UnsupportedVersion(99), got {err:?}"
+ );
+ }
+
+ #[test]
+ fn vrc7_namco163_mapper_audio_off_path_latches_state_but_stays_silent() {
+ // ADR-0004 invariant: register decoders unconditionally latch
+ // even when the synthesizer is absent. Confirm latching works
+ // identically regardless of the `mapper-audio` feature flag
+ // (the VRC7 surface does not branch on the flag — synthesis
+ // is just absent in v0.9.x, period).
+ let mut m = vrc7_default();
+ m.cpu_write(0x9010, 0x15);
+ m.cpu_write(0x9030, 0x77);
+ assert_eq!(m.audio.regs[0x15], 0x77);
+ // Drive a bunch of CPU cycles → no audio side-effects, but
+ // IRQ counter is unaffected if not enabled.
+ for _ in 0..1000 {
+ m.notify_cpu_cycle();
+ }
+ assert_eq!(
+ m.mix_audio(),
+ 0,
+ "feature-off path must remain silent (matches feature-on for VRC7 v0.9.x)"
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/jaleco87.rs b/crates/rustynes-mappers/src/m087_jaleco87.rs
similarity index 100%
rename from crates/rustynes-mappers/src/jaleco87.rs
rename to crates/rustynes-mappers/src/m087_jaleco87.rs
diff --git a/crates/rustynes-mappers/src/namco118.rs b/crates/rustynes-mappers/src/m088_namco118.rs
similarity index 100%
rename from crates/rustynes-mappers/src/namco118.rs
rename to crates/rustynes-mappers/src/m088_namco118.rs
diff --git a/crates/rustynes-mappers/src/sunsoft2.rs b/crates/rustynes-mappers/src/m089_sunsoft2.rs
similarity index 100%
rename from crates/rustynes-mappers/src/sunsoft2.rs
rename to crates/rustynes-mappers/src/m089_sunsoft2.rs
diff --git a/crates/rustynes-mappers/src/sunsoft3r.rs b/crates/rustynes-mappers/src/m093_sunsoft3r.rs
similarity index 82%
rename from crates/rustynes-mappers/src/sunsoft3r.rs
rename to crates/rustynes-mappers/src/m093_sunsoft3r.rs
index d462293c..80fe3333 100644
--- a/crates/rustynes-mappers/src/sunsoft3r.rs
+++ b/crates/rustynes-mappers/src/m093_sunsoft3r.rs
@@ -71,6 +71,26 @@ impl Sunsoft3r {
})
}
+ /// PRG-ROM read, shared by [`Mapper::cpu_read`] and the bus-conflict mask in
+ /// [`Mapper::cpu_write`] (which needs `&self`, not the trait's `&mut self`).
+ fn read_prg(&self, addr: u16) -> u8 {
+ let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank = (self.prg_bank as usize) % bank_count;
+ let off = (addr - 0x8000) as usize;
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ }
+ 0xC000..=0xFFFF => {
+ // `.max(1)` above makes this subtraction safe for any accepted image.
+ let last = bank_count - 1;
+ let off = (addr - 0xC000) as usize;
+ self.prg_rom[last * PRG_BANK_16K + off]
+ }
+ _ => 0,
+ }
+ }
+
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);
@@ -87,24 +107,22 @@ impl Mapper for Sunsoft3r {
}
fn cpu_read(&mut self, addr: u16) -> u8 {
- let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- match addr {
- 0x8000..=0xBFFF => {
- let bank = (self.prg_bank as usize) % bank_count;
- let off = (addr - 0x8000) as usize;
- self.prg_rom[bank * PRG_BANK_16K + off]
- }
- 0xC000..=0xFFFF => {
- let last = bank_count - 1;
- let off = (addr - 0xC000) as usize;
- self.prg_rom[last * PRG_BANK_16K + off]
- }
- _ => 0,
- }
+ self.read_prg(addr)
}
fn cpu_write(&mut self, addr: u16, value: u8) {
if let 0x8000..=0xFFFF = addr {
+ // The Sunsoft-3R board has **bus conflicts** — this module's own
+ // header already cites nesdev `INES_Mapper_093.xhtml` "BUS
+ // CONFLICTS", but the mask was missing, so the doc and the code
+ // disagreed. The register shares the address space with PRG-ROM, so
+ // a store drives the written byte ANDed with the ROM byte already at
+ // that address. Same treatment as the sibling Sunsoft-2 board in
+ // `m089_sunsoft2.rs`, and matching the designated reference
+ // `ref-proj/GeraNES/src/GeraNES/Mappers/Mapper093.h`, whose
+ // `writePrg` opens with `data &= readPrg(addr);`.
+ // Decode every field from the masked value.
+ let value = value & self.read_prg(addr);
// [.PPP ...E]: bits 4-6 = 16K PRG bank, bit 0 = CHR-RAM enable.
self.prg_bank = (value >> 4) & 0x07;
self.chr_ram_enabled = (value & 0x01) != 0;
@@ -205,8 +223,17 @@ impl Mapper for Sunsoft3r {
mod tests {
use super::*;
+ /// Filled `0xFF` — NOT `0x00` — with only the first byte of each bank
+ /// carrying its index as a marker.
+ ///
+ /// This board has bus conflicts: `cpu_write` ANDs the written byte with the
+ /// ROM byte at that address. A `0x00` fill would silently mask every
+ /// register write to zero and make these tests assert the wrong behavior,
+ /// so register writes below target `$8001` (a `0xFF` byte, mask
+ /// transparent) rather than `$8000` (the bank marker). Same convention as
+ /// the sibling `m089_sunsoft2.rs`.
fn synth_prg(banks: usize) -> Box<[u8]> {
- let mut v = vec![0u8; banks * PRG_BANK_16K];
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
for b in 0..banks {
v[b * PRG_BANK_16K] = b as u8;
}
@@ -224,7 +251,7 @@ mod tests {
fn prg_bank_select_bits_4_to_6() {
let mut m = Sunsoft3r::new(synth_prg(8), Mirroring::Vertical).unwrap();
// [.PPP ...E]: PRG=5 (bits 4-6) + CHR enable (bit 0) -> 0b0101_0001.
- m.cpu_write(0x8000, 0b0101_0001);
+ m.cpu_write(0x8001, 0b0101_0001);
assert_eq!(m.cpu_read(0x8000), 5);
assert_eq!(m.cpu_read(0xC000), 7); // fixed unchanged
}
@@ -236,18 +263,18 @@ mod tests {
m.ppu_write(0x0010, 0xCD);
assert_eq!(m.ppu_read(0x0010), 0xCD);
// Disable CHR-RAM (bit 0 = 0): writes ignored, reads open bus (0).
- m.cpu_write(0x8000, 0x00);
+ m.cpu_write(0x8001, 0x00);
m.ppu_write(0x0020, 0xEE);
assert_eq!(m.ppu_read(0x0020), 0);
// Re-enable: previously-written byte still there.
- m.cpu_write(0x8000, 0x01);
+ m.cpu_write(0x8001, 0x01);
assert_eq!(m.ppu_read(0x0010), 0xCD);
}
#[test]
fn save_state_round_trip() {
let mut m = Sunsoft3r::new(synth_prg(4), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000, 0b0010_0001);
+ m.cpu_write(0x8001, 0b0010_0001);
m.ppu_write(0x0001, 0x77);
let blob = m.save_state();
let mut m2 = Sunsoft3r::new(synth_prg(4), Mirroring::Vertical).unwrap();
diff --git a/crates/rustynes-mappers/src/m094_un1rom.rs b/crates/rustynes-mappers/src/m094_un1rom.rs
new file mode 100644
index 00000000..3d426617
--- /dev/null
+++ b/crates/rustynes-mappers/src/m094_un1rom.rs
@@ -0,0 +1,212 @@
+//! Nintendo `UN1ROM` (mapper 94) -- Senjou no Ookami.
+//!
+//! A `UxROM` (`m002_uxrom.rs`) whose PRG bank field sits in data bits 4-2 rather
+//! than the low bits, so the same 16 KiB-switchable / 16 KiB-fixed layout is
+//! driven by a shifted register value. CHR is RAM and there is no IRQ.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 94 (`UN1ROM`).
+pub struct Un1rom94 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Un1rom94 {
+ /// Construct a new mapper 94 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 94 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Un1rom94 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(self.prg_bank as usize, addr),
+ 0xC000..=0xFFFF => {
+ let last = (self.prg_rom.len() / PRG_BANK_16K).max(1) - 1;
+ self.read_prg(last, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict: AND with the byte the CPU actually reads at this
+ // address, using the SAME window mapping as `cpu_read` — the
+ // switchable bank for $8000-$BFFF, the fixed last bank for
+ // $C000-$FFFF (a register write can land in either half).
+ let conflict = self.cpu_read(addr);
+ let effective = value & conflict;
+ // UN1ROM (Senjou no Ookami) selects the 16 KiB bank from data
+ // bits 4..2 (a 3-bit field, 8 banks).
+ self.prg_bank = (effective >> 2) & 0x07;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ 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 = 2 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ let mut cursor = 2;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m94_prg_bank_from_data_bits() {
+ let mut m = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ // value (data>>2)&0x0F = 3 -> write 0b0000_1100 (12). Offset !=0 has
+ // no bus conflict (0xFF), so the value sticks.
+ m.cpu_write(0x8001, 0b0000_1100);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // $C000 is fixed to the last 16 KiB bank (7).
+ assert_eq!(m.cpu_read(0xC000), 7);
+ }
+
+ #[test]
+ fn m94_save_state_round_trip() {
+ let mut m = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8001, 0b0001_0000); // (16>>2)&0xF = 4
+ m.ppu_write(0x0002, 0x9A);
+ let blob = m.save_state();
+ let mut m2 = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 4);
+ assert_eq!(m2.ppu_read(0x0002), 0x9A);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m095_namcot3425.rs b/crates/rustynes-mappers/src/m095_namcot3425.rs
new file mode 100644
index 00000000..e6270c23
--- /dev/null
+++ b/crates/rustynes-mappers/src/m095_namcot3425.rs
@@ -0,0 +1,267 @@
+//! Namcot 3425 (mapper 95).
+//!
+//! The same Namco register-pair protocol as the 3446 in
+//! `m076_namcot3446.rs`, with one addition worth knowing: a bit of the CHR
+//! bank number is routed to the nametable select, so the board drives
+//! single-screen mirroring from the *CHR banking registers* rather than from a
+//! dedicated mirroring register.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const CHR_BANK_1K: usize = 0x0400;
+
+/// Mapper 95 (`NAMCOT-3425`, *Dragon Buster*).
+pub struct Namcot3425M95 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg_index: u8,
+ prg_banks: [u8; 2],
+ // chr[0],chr[1] are 2 KiB selects; chr[2..6] are 1 KiB selects.
+ chr_regs: [u8; 6],
+ one_screen_b: bool,
+}
+
+impl Namcot3425M95 {
+ /// Construct a new mapper 95 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB or CHR-ROM is empty / 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 95 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 95 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg_index: 0,
+ prg_banks: [0, 1],
+ chr_regs: [0; 6],
+ one_screen_b: false,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+
+ fn read_chr(&self, addr: u16) -> u8 {
+ let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ // Resolve the 1 KiB bank for this CHR address.
+ let bank1k = match addr {
+ 0x0000..=0x07FF => (self.chr_regs[0] as usize & !1) + ((addr as usize >> 10) & 1),
+ 0x0800..=0x0FFF => (self.chr_regs[1] as usize & !1) + ((addr as usize >> 10) & 1),
+ 0x1000..=0x13FF => self.chr_regs[2] as usize,
+ 0x1400..=0x17FF => self.chr_regs[3] as usize,
+ 0x1800..=0x1BFF => self.chr_regs[4] as usize,
+ _ => self.chr_regs[5] as usize,
+ };
+ let bank = bank1k % count1k;
+ self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
+ }
+}
+
+impl Mapper for Namcot3425M95 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
+ match addr {
+ 0x8000..=0x9FFF => self.read_prg(self.prg_banks[0] as usize, addr),
+ 0xA000..=0xBFFF => self.read_prg(self.prg_banks[1] as usize, addr),
+ 0xC000..=0xDFFF => self.read_prg(last - 1, addr),
+ 0xE000..=0xFFFF => self.read_prg(last, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x8000..=0x9FFF if addr & 1 == 0 => self.reg_index = value & 0x07,
+ 0x8000..=0x9FFF => match self.reg_index {
+ 0 => {
+ self.chr_regs[0] = value & 0x3F;
+ // CHR reg 0 bit 5 drives one-screen select on this board.
+ self.one_screen_b = (value & 0x20) != 0;
+ }
+ 1 => self.chr_regs[1] = value & 0x3F,
+ 2..=5 => self.chr_regs[self.reg_index as usize] = value & 0x3F,
+ 6 => self.prg_banks[0] = value,
+ _ => self.prg_banks[1] = value,
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.one_screen_b {
+ Mirroring::SingleScreenB
+ } else {
+ Mirroring::SingleScreenA
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(11 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg_index);
+ out.extend_from_slice(&self.prg_banks);
+ out.extend_from_slice(&self.chr_regs);
+ out.push(u8::from(self.one_screen_b));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 11 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg_index = data[1];
+ self.prg_banks.copy_from_slice(&data[2..4]);
+ self.chr_regs.copy_from_slice(&data[4..10]);
+ self.one_screen_b = data[10] != 0;
+ self.vram.copy_from_slice(&data[11..11 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m95_prg_select_and_one_screen() {
+ let mut m =
+ Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ // PRG reg 6 -> $8000.
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // CHR reg 0, value with bit 5 set -> one-screen B.
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0x8001, 0x20);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
+ // $C000/$E000 fixed to last two (6,7).
+ assert_eq!(m.cpu_read(0xC000), 6);
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+
+ #[test]
+ fn m95_save_state_round_trip() {
+ let mut m =
+ Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 7);
+ m.cpu_write(0x8001, 4);
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0x8001, 0x20);
+ let blob = m.save_state();
+ let mut m2 =
+ Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xA000), m.cpu_read(0xA000));
+ assert_eq!(m2.current_mirroring(), Mirroring::SingleScreenB);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs
new file mode 100644
index 00000000..7270c707
--- /dev/null
+++ b/crates/rustynes-mappers/src/m096_bandai96.rs
@@ -0,0 +1,286 @@
+//! Bandai Oeka Kids (mapper 96).
+//!
+//! The unusual one in this batch: the low CHR bank bits are not written by the
+//! CPU at all -- they are latched from the *PPU address bus* during nametable
+//! fetches. The board watches for a fetch in `$2000-$2FFF` and captures two
+//! address bits, so the CHR bank in use tracks which nametable quadrant the
+//! PPU is currently reading. That is how the drawing-tablet software swaps
+//! character data per screen region without CPU involvement, and it is why
+//! this board needs a PPU-read hook where its peers need none.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_4K: usize = 0x1000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 96 (Bandai Oeka Kids).
+pub struct Bandai96 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ outer_chr: u8,
+ inner_chr: u8,
+ last_ppu_addr: u16,
+ mirroring: Mirroring,
+}
+
+impl Bandai96 {
+ /// Construct a new mapper 96 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 4 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 96 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ // Two 4 KiB CHR-RAM banks (the Oeka Kids drawing buffer).
+ vec![0u8; 2 * CHR_BANK_4K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 96 CHR-ROM size {} is not a multiple of 4 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ outer_chr: 0,
+ inner_chr: 0,
+ last_ppu_addr: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_count_4k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_4K).max(1)
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ // $0000 slot uses outer|inner; $1000 slot uses outer|0x03.
+ let slot = (addr >> 12) & 0x01;
+ let bank = if slot == 0 {
+ self.outer_chr | self.inner_chr
+ } else {
+ self.outer_chr | 0x03
+ };
+ let bank = (bank as usize) % self.chr_count_4k();
+ bank * CHR_BANK_4K + (addr as usize & (CHR_BANK_4K - 1))
+ }
+}
+
+impl Mapper for Bandai96 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = value & 0x03;
+ self.outer_chr = value & 0x04;
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let masked = addr & 0x3FFF;
+ // Sniff the PPU address bus: rising edge into a nametable fetch latches
+ // the CHR inner bank from address bits 9-8.
+ if (self.last_ppu_addr & 0x3000) != 0x2000 && (masked & 0x3000) == 0x2000 {
+ self.inner_chr = ((masked >> 8) & 0x03) as u8;
+ }
+ self.last_ppu_addr = masked;
+ 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 {
+ self.inner_chr = ((masked >> 8) & 0x03) as u8;
+ }
+ self.last_ppu_addr = masked;
+ match masked {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(masked);
+ self.chr[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(masked, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(6 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.outer_chr);
+ out.push(self.inner_chr);
+ out.push((self.last_ppu_addr & 0xFF) as u8);
+ out.push((self.last_ppu_addr >> 8) as u8);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 6 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.outer_chr = data[2];
+ self.inner_chr = data[3];
+ self.last_ppu_addr = u16::from(data[4]) | (u16::from(data[5]) << 8);
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_4k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_4K];
+ for b in 0..banks {
+ v[b * CHR_BANK_4K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m96_prg_and_outer_chr() {
+ let mut m =
+ Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
+ // PRG = bits 0-1, outer CHR = bit 2.
+ m.cpu_write(0x8000, 0b0000_0011); // PRG 3, outer 0
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // $1000 slot = outer|0x03 = 3.
+ assert_eq!(m.ppu_read(0x1000), 3);
+ }
+
+ #[test]
+ fn m96_inner_chr_latched_from_ppu_bus() {
+ let mut m =
+ Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
+ // outer = 0 (PRG write bit2 clear).
+ m.cpu_write(0x8000, 0);
+ // Approach a nametable fetch from a non-$2xxx address (e.g. a pattern
+ // fetch at $0000), then fetch $2100 -> inner = (0x2100>>8)&3 = 1.
+ let _ = m.ppu_read(0x0000);
+ let _ = m.ppu_read(0x2100);
+ // $0000 slot bank = outer|inner = 0|1 = 1.
+ assert_eq!(m.ppu_read(0x0000), 1);
+ // Fetch $2300 -> inner = 3. (Must re-approach from outside $2xxx.)
+ let _ = m.ppu_read(0x0000);
+ let _ = m.ppu_read(0x2300);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ }
+
+ #[test]
+ fn m96_save_state_round_trips_ppu_bus_latch() {
+ let mut b =
+ Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
+ b.cpu_write(0x8000, 0);
+ let _ = b.ppu_read(0x0000);
+ let _ = b.ppu_read(0x2200);
+ let blob = b.save_state();
+ let mut b2 =
+ Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
+ b2.load_state(&blob).unwrap();
+ assert_eq!(b2.ppu_read(0x0000), b.ppu_read(0x0000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m097_irem_tam_s1.rs b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs
new file mode 100644
index 00000000..37bcf2ef
--- /dev/null
+++ b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs
@@ -0,0 +1,240 @@
+//! Irem `TAM-S1`, Kaiketsu Yanchamaru (mapper 97).
+//!
+//! Inverts the normal `UxROM` arrangement: the *first* 16 KiB is fixed and the
+//! *second* switchable. That matters because the 6502 reset and interrupt
+//! vectors live at the top of the address space -- on this board they sit in
+//! the switchable half, so the bank in place at reset is load-bearing.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 97 (Irem `TAM-S1`, Kaiketsu Yanchamaru).
+pub struct Irem97 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ vertical_mirroring: bool,
+}
+
+impl Irem97 {
+ /// Construct a new mapper 97 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 97 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 97 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ vertical_mirroring: mirroring == Mirroring::Vertical,
+ })
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+}
+
+impl Mapper for Irem97 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // $8000-$BFFF fixed to the last 16 KiB bank.
+ 0x8000..=0xBFFF => {
+ let last = self.prg_count_16k() - 1;
+ self.prg_rom[last * PRG_BANK_16K + (addr as usize - 0x8000)]
+ }
+ // $C000-$FFFF switchable.
+ 0xC000..=0xFFFF => {
+ let bank = (self.prg_bank as usize) % self.prg_count_16k();
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize - 0xC000)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = value & 0x1F;
+ self.vertical_mirroring = (value & 0x80) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ self.chr[addr as usize] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.vertical_mirroring {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.vertical_mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.vertical_mirroring = data[2] != 0;
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m97_fixed_first_switchable_second() {
+ let mut m = Irem97::new(synth_prg_16k(8), synth_chr_8k(1), Mirroring::Horizontal).unwrap();
+ // $8000-$BFFF fixed to last bank (7).
+ assert_eq!(m.cpu_read(0x8000), 7);
+ // Switch $C000 bank to 3, set vertical mirroring (bit 7).
+ m.cpu_write(0x8000, 0b1000_0011);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ // $8000 still fixed.
+ assert_eq!(m.cpu_read(0x8000), 7);
+ }
+}
diff --git a/crates/rustynes-mappers/src/vs_system.rs b/crates/rustynes-mappers/src/m099_vs_system.rs
similarity index 100%
rename from crates/rustynes-mappers/src/vs_system.rs
rename to crates/rustynes-mappers/src/m099_vs_system.rs
diff --git a/crates/rustynes-mappers/src/m107_magic_dragon107.rs b/crates/rustynes-mappers/src/m107_magic_dragon107.rs
new file mode 100644
index 00000000..7c54d75f
--- /dev/null
+++ b/crates/rustynes-mappers/src/m107_magic_dragon107.rs
@@ -0,0 +1,202 @@
+//! Magic Dragon (mapper 107) -- Magicseries pirate board.
+//!
+//! One write-anywhere register in `$8000-$FFFF` carrying both bank fields:
+//! the low bit selects a 32 KiB PRG bank and the upper bits an 8 KiB CHR
+//! bank.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 107 (Magic Dragon).
+pub struct MagicDragon107 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl MagicDragon107 {
+ /// Construct a new mapper 107 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 107 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 107 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for MagicDragon107 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = value >> 1;
+ self.chr_bank = value;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m107_latch_selects_prg_and_chr() {
+ let mut m =
+ MagicDragon107::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // value 6: PRG = 6>>1 = 3; CHR = 6.
+ m.cpu_write(0x8000, 6);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.ppu_read(0x0000), 6);
+ }
+
+ #[test]
+ fn m107_save_state_round_trip() {
+ let mut m =
+ MagicDragon107::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 4); // PRG 2, CHR 4
+ let blob = m.save_state();
+ let mut m2 =
+ MagicDragon107::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 2);
+ assert_eq!(m2.ppu_read(0x0000), 4);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m113_ave_nina006.rs b/crates/rustynes-mappers/src/m113_ave_nina006.rs
new file mode 100644
index 00000000..85408606
--- /dev/null
+++ b/crates/rustynes-mappers/src/m113_ave_nina006.rs
@@ -0,0 +1,236 @@
+//! AVE `NINA-006` / `MB-91` multicart (mapper 113).
+//!
+//! Mapper 79 (`m079_ave_nina03_06.rs`) plus a register-controlled mirroring
+//! bit and a wider CHR field -- the additions a multicart needs, since each
+//! bundled game may want different mirroring and the combined CHR is larger
+//! than any single title's. Same A8 decode in the `$4100-$5FFF` window.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 113 (`NINA-006`/`MB-91` multicart).
+pub struct Nina006M113 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ vertical_mirroring: bool,
+}
+
+impl Nina006M113 {
+ /// Construct a new mapper 113 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 KiB.
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 113 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 113 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ vertical_mirroring: false,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Nina006M113 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4100..=0x5FFF).contains(&addr) && (addr & 0x0100) != 0 {
+ self.prg_bank = (value >> 3) & 0x07;
+ self.chr_bank = (value & 0x07) | ((value >> 3) & 0x08);
+ self.vertical_mirroring = (value & 0x80) != 0;
+ }
+ }
+
+ 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[nametable_offset(addr, self.current_mirroring())],
+ _ => 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 = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.vertical_mirroring {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(4 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.push(u8::from(self.vertical_mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 4 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vertical_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m113_decodes_prg_chr_and_mirroring() {
+ let mut m = Nina006M113::new(synth_prg_32k(4), synth_chr_8k(16)).unwrap();
+ // value 0b1100_0010 (0xC2):
+ // PRG = (v>>3)&7 = (24)&7 = 0
+ // CHR = (v&7) | ((v>>3)&8) = 2 | (24 & 8 = 8) = 10
+ // mirroring bit7 set -> vertical.
+ m.cpu_write(0x4100, 0b1100_0010);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.ppu_read(0x0000), 10);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ // Clear bit 7 -> horizontal.
+ m.cpu_write(0x4100, 0b0000_0001);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+}
diff --git a/crates/rustynes-mappers/src/txsrom.rs b/crates/rustynes-mappers/src/m118_txsrom.rs
similarity index 99%
rename from crates/rustynes-mappers/src/txsrom.rs
rename to crates/rustynes-mappers/src/m118_txsrom.rs
index dc6f5a99..4f2613b1 100644
--- a/crates/rustynes-mappers/src/txsrom.rs
+++ b/crates/rustynes-mappers/src/m118_txsrom.rs
@@ -32,8 +32,8 @@
)]
use crate::cartridge::Mirroring;
+use crate::m004_mmc3::Mmc3;
use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
-use crate::mmc3::Mmc3;
use alloc::format;
use alloc::{boxed::Box, vec::Vec};
@@ -71,7 +71,7 @@ impl TxSrom {
chr_rom,
initial_mirroring,
prg_ram_bytes,
- crate::mmc3::Mmc3Revision::Sharp,
+ crate::m004_mmc3::Mmc3Revision::Sharp,
)?;
Ok(Self {
inner,
diff --git a/crates/rustynes-mappers/src/tqrom.rs b/crates/rustynes-mappers/src/m119_tqrom.rs
similarity index 99%
rename from crates/rustynes-mappers/src/tqrom.rs
rename to crates/rustynes-mappers/src/m119_tqrom.rs
index 9a3071db..5f5a4f88 100644
--- a/crates/rustynes-mappers/src/tqrom.rs
+++ b/crates/rustynes-mappers/src/m119_tqrom.rs
@@ -33,8 +33,8 @@
)]
use crate::cartridge::Mirroring;
+use crate::m004_mmc3::Mmc3;
use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
-use crate::mmc3::Mmc3;
use alloc::format;
use alloc::string::ToString;
use alloc::{boxed::Box, vec, vec::Vec};
@@ -77,7 +77,7 @@ impl Tqrom {
chr_rom,
initial_mirroring,
prg_ram_bytes,
- crate::mmc3::Mmc3Revision::Sharp,
+ crate::m004_mmc3::Mmc3Revision::Sharp,
)?;
Ok(Self {
inner,
diff --git a/crates/rustynes-mappers/src/m132_txc_22211.rs b/crates/rustynes-mappers/src/m132_txc_22211.rs
new file mode 100644
index 00000000..d74887f3
--- /dev/null
+++ b/crates/rustynes-mappers/src/m132_txc_22211.rs
@@ -0,0 +1,306 @@
+//! TXC `UNL-22211` (mapper 132).
+//!
+//! Built around the TXC bank-select ASIC, modelled here as [`TxcChip`]: a
+//! small accumulator-and-latch state machine driven through the
+//! `$4100-$4103` window whose output only reaches the banking registers when
+//! the game subsequently writes `$8000`. That two-stage handshake is the whole
+//! point of the chip -- it is a crude copy-protection measure, since a naive
+//! emulator that banks on the `$4100` write alone produces the wrong bank.
+//!
+//! The simpler TXC board on mapper 36 is in `m036_txc_policeman.rs`; Sachen's
+//! 3011 drives a variant of the same chip, duplicated in `m136_sachen_3011.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// The TXC scrambling-accumulator chip (mappers 132 / 172 / 173 family). This
+/// is the non-JV001 variant used by mapper 132.
+#[derive(Clone, Copy, Default)]
+struct TxcChip {
+ accumulator: u8,
+ inverter: u8,
+ staging: u8,
+ output: u8,
+ increase: bool,
+ invert: bool,
+}
+
+impl TxcChip {
+ const MASK: u8 = 0x07;
+
+ const fn output(self) -> u8 {
+ self.output
+ }
+
+ const fn read(self) -> u8 {
+ let invert_xor = if self.invert { 0xFF } else { 0x00 };
+ (self.accumulator & Self::MASK) | ((self.inverter ^ invert_xor) & !Self::MASK)
+ }
+
+ /// `absolute` is the full CPU address of the write (e.g. `0x4100` or
+ /// `0x8000`); `value` is the 4-bit-masked data already supplied by the
+ /// caller for the register path.
+ const fn write(&mut self, absolute: u16, value: u8) {
+ if absolute < 0x8000 {
+ match absolute & 0xE103 {
+ 0x4100 => {
+ if self.increase {
+ self.accumulator = self.accumulator.wrapping_add(1);
+ } else {
+ let invert_xor = if self.invert { 0xFF } else { 0x00 };
+ self.accumulator = ((self.accumulator & !Self::MASK)
+ | (self.staging & Self::MASK))
+ ^ invert_xor;
+ }
+ }
+ 0x4101 => self.invert = (value & 0x01) != 0,
+ 0x4102 => {
+ self.staging = value & Self::MASK;
+ self.inverter = value & !Self::MASK;
+ }
+ 0x4103 => self.increase = (value & 0x01) != 0,
+ _ => {}
+ }
+ } else {
+ // $8000+ latches the scrambled output (non-JV001 layout).
+ self.output = (self.accumulator & 0x0F) | ((self.inverter & 0x08) << 1);
+ }
+ }
+}
+
+/// Mapper 132 (TXC 22211).
+pub struct Txc132 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ txc: TxcChip,
+ mirroring: Mirroring,
+}
+
+impl Txc132 {
+ /// Construct a new mapper 132 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 132 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 132 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ txc: TxcChip::default(),
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Txc132 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The chip's read port lives at $4100-$5FFF (mapped); only the $4020-$40FF
+ // gap below it is open bus. $8000-$FFFF PRG-ROM stays mapped (the trait
+ // default) — a `!(...)` here would wrongly open-bus the program ROM and the
+ // reset vector, so the board never boots.
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x40FF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x4100..=0x5FFF => {
+ // GeraNES decodes the read on (addr & 0x0103) == 0x0100.
+ if (addr & 0x0103) == 0x0100 {
+ self.txc.read() & 0x0F
+ } else {
+ 0
+ }
+ }
+ 0x8000..=0xFFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (((self.txc.output() >> 2) & 0x01) as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4100..=0x5FFF).contains(&addr) || (0x8000..=0xFFFF).contains(&addr) {
+ self.txc.write(addr, value & 0x0F);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(7 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.txc.accumulator);
+ out.push(self.txc.inverter);
+ out.push(self.txc.staging);
+ out.push(self.txc.output);
+ out.push(u8::from(self.txc.increase));
+ out.push(u8::from(self.txc.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::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.txc.accumulator = data[1];
+ self.txc.inverter = data[2];
+ self.txc.staging = data[3];
+ self.txc.output = data[4];
+ self.txc.increase = data[5] != 0;
+ self.txc.invert = data[6] != 0;
+ self.vram.copy_from_slice(&data[7..7 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m132_txc_chip_drives_banks() {
+ let mut m = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // Program the chip: set staging via $4102 (low 3 bits = staging,
+ // high bits -> inverter), set increase off ($4103 = 0), then $4100
+ // loads accumulator from staging, then $8000 latches the output.
+ m.cpu_write(0x4103, 0x00); // increase = false
+ m.cpu_write(0x4102, 0b0000_1011 & 0x0F); // staging = 3 (0b011), inverter = 0b1000
+ m.cpu_write(0x4100, 0x00); // accumulator = staging (no invert) = 3
+ m.cpu_write(0x8000, 0x00); // latch: output = (acc&0xF) | ((inv&8)<<1)
+ // acc = 3, inverter low nibble 0b1000 -> (8<<1)=0x10
+ // output = 3 | 0x10 = 0x13.
+ // PRG = (0x13>>2)&1 = 0; CHR = 0x13&3 = 3.
+ assert_eq!(m.cpu_read(0x8000), 0); // PRG bank 0
+ assert_eq!(m.ppu_read(0x0000), 3); // CHR bank 3
+ // Register read window is mapped (not open bus).
+ assert!(!m.cpu_read_unmapped(0x4100));
+ }
+
+ #[test]
+ fn m132_save_state_round_trips_txc_chip_state() {
+ let mut t = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ t.cpu_write(0x4103, 0x00);
+ t.cpu_write(0x4102, 0x03);
+ t.cpu_write(0x4100, 0x00);
+ t.cpu_write(0x8000, 0x00);
+ let blob = t.save_state();
+ let mut t2 = Txc132::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ t2.load_state(&blob).unwrap();
+ assert_eq!(t2.ppu_read(0x0000), t.ppu_read(0x0000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m136_sachen_3011.rs b/crates/rustynes-mappers/src/m136_sachen_3011.rs
new file mode 100644
index 00000000..ed66b4de
--- /dev/null
+++ b/crates/rustynes-mappers/src/m136_sachen_3011.rs
@@ -0,0 +1,358 @@
+//! Sachen `3011` (mapper 136).
+//!
+//! Drives its CHR select through a TXC-style accumulator chip -- the same
+//! two-stage arrangement modelled in `txc.rs`, where a value is assembled in
+//! the `$4100-$4103` window and only latched into the banking registers on a
+//! subsequent write. The chip state is duplicated here rather than shared
+//! because the two boards clock it differently.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+#[derive(Default, Clone)]
+struct TxcChip {
+ accumulator: u8,
+ inverter: u8,
+ staging: u8,
+ output: u8,
+ increase: bool,
+ invert: bool,
+}
+
+impl TxcChip {
+ const MASK: u8 = 0x07;
+ const SAVE_LEN: usize = 6;
+
+ fn read(&self) -> u8 {
+ (self.accumulator & Self::MASK)
+ | ((self.inverter ^ if self.invert { 0xFF } else { 0 }) & !Self::MASK)
+ }
+
+ fn write(&mut self, addr: u16, value: u8) {
+ if addr < 0x8000 {
+ match addr & 0xE103 {
+ 0x4100 => {
+ if self.increase {
+ self.accumulator = self.accumulator.wrapping_add(1);
+ } else {
+ self.accumulator = ((self.accumulator & !Self::MASK)
+ | (self.staging & Self::MASK))
+ ^ if self.invert { 0xFF } else { 0 };
+ }
+ }
+ 0x4101 => self.invert = value & 0x01 != 0,
+ 0x4102 => {
+ self.staging = value & Self::MASK;
+ self.inverter = value & !Self::MASK;
+ }
+ 0x4103 => self.increase = value & 0x01 != 0,
+ _ => {}
+ }
+ } else {
+ self.output = (self.accumulator & 0x0F) | ((self.inverter & 0x08) << 1);
+ }
+ }
+
+ fn save(&self, out: &mut Vec) {
+ out.push(self.accumulator);
+ out.push(self.inverter);
+ out.push(self.staging);
+ out.push(self.output);
+ out.push(u8::from(self.increase));
+ out.push(u8::from(self.invert));
+ }
+
+ fn load(&mut self, d: &[u8]) {
+ self.accumulator = d[0];
+ self.inverter = d[1];
+ self.staging = d[2];
+ self.output = d[3];
+ self.increase = d[4] != 0;
+ self.invert = d[5] != 0;
+ }
+}
+
+/// Sachen 3011 (mapper 136): TXC protection chip driving an 8 KiB CHR select.
+pub struct Sachen3011 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ chr_count_8k: usize,
+ txc: TxcChip,
+}
+
+impl Sachen3011 {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 136)?;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ let chr_count_8k = (chr.len() / CHR_BANK_8K).max(1);
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ chr_count_8k,
+ txc: TxcChip::default(),
+ })
+ }
+}
+
+impl Mapper for Sachen3011 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x4100..=0x5FFF => {
+ // $4100 returns the chip read in the low 6 bits.
+ let v = if addr & 0x103 == 0x100 {
+ self.txc.read() & 0x3F
+ } else {
+ 0
+ };
+ self.txc.write(addr, 0); // refresh output (read has side-effects).
+ v
+ }
+ 0x8000..=0xFFFF => {
+ // Wrap against the ACTUAL image length, not a rounded-up bank
+ // count. `check_prg` admits any non-zero multiple of 8 KiB, so a
+ // 16 KiB PRG yields `count == 1` and a modulus of 32768 — and
+ // `$C000` then resolves to offset 16384, one past the end of a
+ // 16 KiB slice. `% len()` is what actually upholds this crate's
+ // "a register write can never index out of bounds" invariant on
+ // ROM-parsed (untrusted) sizes. `check_prg` guarantees non-empty,
+ // so the modulus cannot divide by zero.
+ self.prg_rom[(addr as usize & 0x7FFF) % self.prg_rom.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // $4100-$5FFF except the protection port reads open bus.
+ (0x4020..=0x5FFF).contains(&addr) && (addr & 0x103 != 0x100)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4100..=0xFFFF).contains(&addr) {
+ self.txc.write(addr, value & 0x3F);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + TxcChip::SAVE_LEN + 1 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ self.txc.save(&mut out);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + TxcChip::SAVE_LEN + 1 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let mut c = 1;
+ self.txc.load(&data[c..c + TxcChip::SAVE_LEN]);
+ c += TxcChip::SAVE_LEN;
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 136 (Sachen 3011, TXC protection CHR select).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+pub fn new_m136(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Sachen3011::new(prg_rom, chr_rom, mirroring)
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn sachen3011_txc_chr_select() {
+ let mut m = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // Stage a value into the accumulator, then latch it via $8000 to output.
+ m.cpu_write(0x4102, 0x03); // staging = 3
+ m.cpu_write(0x4103, 0x00); // increase = false
+ m.cpu_write(0x4100, 0x00); // accumulator = staging
+ m.cpu_write(0x8000, 0x00); // refresh output
+ // output low nibble = accumulator low nibble (3) -> CHR bank 3.
+ assert_eq!(m.ppu_read(0x0000), 3);
+ }
+
+ #[test]
+ fn sachen3011_save_state_round_trip() {
+ let mut m = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4102, 0x02);
+ m.cpu_write(0x4100, 0x00);
+ m.cpu_write(0x8000, 0x00);
+ m.ppu_write(0x2003, 0x11);
+ let blob = m.save_state();
+ let mut m2 = new_m136(synth_prg_8k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ assert_eq!(m2.ppu_read(0x2003), 0x11);
+ }
+}
diff --git a/crates/rustynes-mappers/src/konami_vs.rs b/crates/rustynes-mappers/src/m151_konami_vs.rs
similarity index 99%
rename from crates/rustynes-mappers/src/konami_vs.rs
rename to crates/rustynes-mappers/src/m151_konami_vs.rs
index e75bd745..0e70b5e0 100644
--- a/crates/rustynes-mappers/src/konami_vs.rs
+++ b/crates/rustynes-mappers/src/m151_konami_vs.rs
@@ -19,8 +19,8 @@
#![allow(clippy::doc_markdown)]
use crate::cartridge::Mirroring;
+use crate::m075_vrc1::Vrc1;
use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
-use crate::sprint2::Vrc1;
use alloc::{boxed::Box, vec::Vec};
/// Konami VS (Mapper 151) — VRC1 on Vs. System hardware.
diff --git a/crates/rustynes-mappers/src/bandai152.rs b/crates/rustynes-mappers/src/m152_bandai152.rs
similarity index 100%
rename from crates/rustynes-mappers/src/bandai152.rs
rename to crates/rustynes-mappers/src/m152_bandai152.rs
diff --git a/crates/rustynes-mappers/src/m156_daou156.rs b/crates/rustynes-mappers/src/m156_daou156.rs
new file mode 100644
index 00000000..73e0baf7
--- /dev/null
+++ b/crates/rustynes-mappers/src/m156_daou156.rs
@@ -0,0 +1,268 @@
+//! Daou Infosys (mapper 156) -- Korean licensed boards, e.g. Metal Force.
+//!
+//! Separate register windows for PRG and for each of the eight 1 KiB CHR
+//! slots, plus a runtime single-screen mirroring control -- unusually
+//! fine-grained for a board with no IRQ. The CHR registers are 16 bits wide,
+//! split across a low and a high write, which is why each slot occupies two
+//! addresses.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_1K: usize = 0x0400;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 156 (DIS23C01 DAOU).
+pub struct Daou156 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ // 8 low nibbles + 8 high nibbles, composed into a 1 KiB bank per slot.
+ chr_lo: [u8; 8],
+ chr_hi: [u8; 8],
+ mirroring: Mirroring,
+}
+
+impl Daou156 {
+ /// Construct a new mapper 156 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / 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 156 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 156 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_lo: [0; 8],
+ chr_hi: [0; 8],
+ // DAOU/DIS23C01 powers on single-screen (nametable A) per Mesen2
+ // InitMapper; the $C014 register flips it to H/V at runtime.
+ mirroring: Mirroring::SingleScreenA,
+ })
+ }
+
+ fn read_chr(&self, addr: u16) -> u8 {
+ let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = (addr as usize >> 10) & 0x07;
+ let bank = ((self.chr_lo[slot] as usize) | ((self.chr_hi[slot] as usize) << 8)) % count1k;
+ self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
+ }
+}
+
+impl Mapper for Daou156 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ 0xC000..=0xFFFF => {
+ let last = count - 1;
+ self.prg_rom[last * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ // $C000-$C00F: 16 CHR-bank-nibble registers. Mesen2 decodes the
+ // 1 KiB slot as (addr & 0x03) + (addr >= 0xC008 ? 4 : 0) and selects
+ // the low/high nibble array by bit 2 (0x04) — NOT a flat lo[0..8] /
+ // hi[0..8] split. The old flat decode wrote the wrong slot's nibble,
+ // so CHR banks resolved to garbage → blank/garbled boot.
+ 0xC000..=0xC00F => {
+ let slot = ((addr & 0x03) + if addr >= 0xC008 { 4 } else { 0 }) as usize;
+ if addr & 0x04 != 0 {
+ self.chr_hi[slot] = value;
+ } else {
+ self.chr_lo[slot] = value;
+ }
+ }
+ 0xC010 => self.prg_bank = value,
+ // $C014: 0 = vertical, 1 = horizontal (Mesen2). The old code mapped
+ // this to a single-screen A/B toggle, which never matched the game's
+ // expected nametable layout.
+ 0xC014 => {
+ self.mirroring = if value & 0x01 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(19 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.extend_from_slice(&self.chr_lo);
+ out.extend_from_slice(&self.chr_hi);
+ out.push(match self.mirroring {
+ Mirroring::Horizontal => 0,
+ Mirroring::Vertical => 1,
+ Mirroring::SingleScreenB => 2,
+ _ => 3, // SingleScreenA (power-on default) + any other
+ });
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 19 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_lo.copy_from_slice(&data[2..10]);
+ self.chr_hi.copy_from_slice(&data[10..18]);
+ self.mirroring = match data[18] {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenB,
+ _ => Mirroring::SingleScreenA,
+ };
+ self.vram.copy_from_slice(&data[19..19 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m156_chr_compose_prg_and_mirroring() {
+ let mut m = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
+ // Power-on mirroring is single-screen A (Mesen2 InitMapper).
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ // PRG $C010 -> bank 3.
+ m.cpu_write(0xC010, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 7); // fixed last
+ // CHR slot 0: low = 5 ($C000), high = 0 -> bank 5.
+ m.cpu_write(0xC000, 5);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // High nibble of slot 0 lives at $C004 (bit 2 selects the high array):
+ // low 5 | (high 1 << 8) = 0x105, wraps mod 32 -> 5.
+ m.cpu_write(0xC004, 1);
+ assert_eq!(m.ppu_read(0x0000), (0x105usize % 32) as u8);
+ // Mirroring $C014: 1 = horizontal, 0 = vertical.
+ m.cpu_write(0xC014, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0xC014, 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m156_save_state_round_trip() {
+ let mut m = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xC010, 2);
+ m.cpu_write(0xC001, 4);
+ m.cpu_write(0xC014, 1);
+ let blob = m.save_state();
+ let mut m2 = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0400), m.ppu_read(0x0400));
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m176_bmc_fk23c.rs b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs
new file mode 100644
index 00000000..ef1e0ac9
--- /dev/null
+++ b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs
@@ -0,0 +1,641 @@
+//! `FK23C` / `BMC-FK23C` (mapper 176) -- the most widely reused pirate ASIC.
+//!
+//! An MMC3 core wrapped in four outer registers at `$5000-$5FFF` that can
+//! *override* the MMC3 entirely: depending on the mode bits the chip either
+//! passes banking through to the inner MMC3 or substitutes its own 16/32 KiB
+//! layout, and it can redirect CHR to RAM. That flexibility is why one chip
+//! backs so many different Chinese multicarts -- the same silicon is
+//! configured per-cartridge by the outer registers rather than by a board
+//! respin.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+// ===========================================================================
+// Fk23c (mapper 176) — Waixing FK23C 8/16 Mbit BMC ASIC.
+//
+// A $5000-$5003 config bank wrapping a full MMC3 register surface (eight bank
+// registers + the $8000/$A000/$C000/$E000 protocol + an A12 scanline IRQ) with
+// an outer-bank / extended-MMC3 / CNROM-CHR mode. This is the
+// register-decode-faithful BestEffort port: the MMC3 PRG/CHR layout plus the
+// FK23C $5000 banking modes (0-2 MMC3, 3 = 32 KiB, 4 = whole-256 KiB) and the
+// $5001/$5002 outer PRG/CHR base bits. Ported from Mesen2 Waixing/Fk23C.h.
+// ===========================================================================
+
+/// Waixing FK23C 8/16 Mbit BMC ASIC (mapper 176).
+pub struct Fk23c {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ wram: Box<[u8]>,
+ mirroring: Mirroring,
+ prg_count_8k: usize,
+ chr_count_1k: usize,
+ // MMC3 core.
+ regs: [u8; 8],
+ bank_select: u8,
+ prg_mode: bool,
+ chr_mode: bool,
+ irq_counter: u8,
+ irq_latch: u8,
+ irq_reload: bool,
+ irq_enabled: bool,
+ irq_pending: bool,
+ last_a12: bool,
+ // FK23C config ($5000-$5003).
+ prg_banking_mode: u8,
+ outer_chr_64k: bool,
+ select_chr_ram: bool,
+ mmc3_chr_mode: bool,
+ cnrom_chr_mode: bool,
+ extended_mmc3: bool,
+ prg_base: u16,
+ chr_base: u8,
+ cnrom_chr_reg: u8,
+}
+
+impl Fk23c {
+ // 8 regs + 9 MMC3 scalars + 6 config bools + 2 prg_base + 3 (chr_base,
+ // cnrom_chr_reg, mirroring).
+ const SAVE_LEN: usize = 8 + 9 + 6 + 2 + 3;
+
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 176)?;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; 0x40000].into_boxed_slice() // up to 256 KiB CHR-RAM.
+ } else {
+ if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 176 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ chr_rom
+ };
+ let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
+ let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ wram: vec![0u8; 0x8000].into_boxed_slice(),
+ mirroring,
+ prg_count_8k,
+ chr_count_1k,
+ regs: [0; 8],
+ bank_select: 0,
+ prg_mode: false,
+ chr_mode: false,
+ irq_counter: 0,
+ irq_latch: 0,
+ irq_reload: false,
+ irq_enabled: false,
+ irq_pending: false,
+ last_a12: false,
+ prg_banking_mode: 0,
+ outer_chr_64k: false,
+ select_chr_ram: false,
+ mmc3_chr_mode: true,
+ cnrom_chr_mode: false,
+ extended_mmc3: false,
+ prg_base: 0,
+ chr_base: 0,
+ cnrom_chr_reg: 0,
+ })
+ }
+
+ fn prg_bank_mmc3(&self, slot: usize) -> usize {
+ let last = self.prg_count_8k - 1;
+ let second_last = last.saturating_sub(1);
+ let r6 = self.regs[6] as usize;
+ let r7 = self.regs[7] as usize;
+ match (slot, self.prg_mode) {
+ (0, false) => r6,
+ (0, true) => second_last,
+ (1, _) => r7,
+ (2, false) => second_last,
+ (2, true) => r6,
+ (3, _) => last,
+ _ => 0,
+ }
+ }
+
+ fn resolve_prg(&self, slot: usize) -> usize {
+ let outer = (self.prg_base as usize) << 1;
+ let bank = match self.prg_banking_mode {
+ 0..=2 => {
+ if self.extended_mmc3 {
+ self.prg_bank_mmc3(slot) | outer
+ } else {
+ let inner_mask = 0x3F >> self.prg_banking_mode;
+ let outer = outer & !inner_mask;
+ (self.prg_bank_mmc3(slot) & inner_mask) | outer
+ }
+ }
+ 3 => {
+ // 32 KiB fixed window from the outer base.
+ (outer & !0x03) + slot
+ }
+ _ => {
+ // mode 4: whole 256 KiB.
+ ((self.prg_base as usize & 0xFFE) << 1 & !0x07) + slot
+ }
+ };
+ bank % self.prg_count_8k
+ }
+
+ fn chr_bank_mmc3(&self, slot: usize) -> usize {
+ let banks: [usize; 8] = if self.chr_mode {
+ [
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ ]
+ } else {
+ [
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ ]
+ };
+ banks[slot & 0x07]
+ }
+
+ fn resolve_chr(&self, slot: usize) -> usize {
+ let bank = if self.mmc3_chr_mode {
+ let outer = (self.chr_base as usize) << 3;
+ if self.extended_mmc3 {
+ self.chr_bank_mmc3(slot) | outer
+ } else {
+ let inner_mask = if self.outer_chr_64k { 0x7F } else { 0xFF };
+ let outer = outer & !inner_mask;
+ (self.chr_bank_mmc3(slot) & inner_mask) | outer
+ }
+ } else {
+ // CNROM mode: 8 KiB blocks from the CNROM CHR reg + base.
+ let inner_mask = if self.cnrom_chr_mode {
+ if self.outer_chr_64k { 1 } else { 3 }
+ } else {
+ 0
+ };
+ (((self.cnrom_chr_reg as usize & inner_mask) | self.chr_base as usize) << 3) + slot
+ };
+ bank % self.chr_count_1k
+ }
+
+ fn write_5000(&mut self, addr: u16, value: u8) {
+ match addr & 0x03 {
+ 0 => {
+ self.prg_banking_mode = value & 0x07;
+ self.outer_chr_64k = value & 0x10 != 0;
+ self.select_chr_ram = value & 0x20 != 0;
+ self.mmc3_chr_mode = value & 0x40 == 0;
+ self.prg_base = (self.prg_base & !0x180)
+ | (((value as u16) & 0x80) << 1)
+ | (((value as u16) & 0x08) << 4);
+ }
+ 1 => self.prg_base = (self.prg_base & !0x7F) | (value as u16 & 0x7F),
+ 2 => {
+ self.prg_base = (self.prg_base & !0x200) | (((value as u16) & 0x40) << 3);
+ self.chr_base = value;
+ self.cnrom_chr_reg = 0;
+ }
+ _ => {
+ self.extended_mmc3 = value & 0x02 != 0;
+ self.cnrom_chr_mode = value & 0x44 != 0;
+ }
+ }
+ }
+
+ fn write_mmc3(&mut self, addr: u16, value: u8) {
+ if self.cnrom_chr_mode && (addr <= 0x9FFF || addr >= 0xC000) {
+ self.cnrom_chr_reg = value & 0x03;
+ }
+ match addr & 0xE001 {
+ 0x8000 => {
+ self.bank_select = value & 0x0F;
+ self.prg_mode = value & 0x40 != 0;
+ self.chr_mode = value & 0x80 != 0;
+ }
+ 0x8001 => {
+ let idx = (self.bank_select & 0x07) as usize;
+ self.regs[idx] = value;
+ }
+ 0xA000 => {
+ self.mirroring = if value & 0x01 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ 0xC000 => self.irq_latch = value,
+ 0xC001 => {
+ self.irq_counter = 0;
+ self.irq_reload = true;
+ }
+ 0xE000 => {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ 0xE001 => self.irq_enabled = true,
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Fk23c {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: false,
+ audio: false,
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => self.wram[addr as usize & 0x1FFF],
+ 0x8000..=0x9FFF => {
+ let b = self.resolve_prg(0);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xA000..=0xBFFF => {
+ let b = self.resolve_prg(1);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xC000..=0xDFFF => {
+ let b = self.resolve_prg(2);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xE000..=0xFFFF => {
+ let b = self.resolve_prg(3);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x5000..=0x5FFF => self.write_5000(addr, value),
+ 0x6000..=0x7FFF => self.wram[addr as usize & 0x1FFF] = value,
+ 0x8000..=0xFFFF => self.write_mmc3(addr, value),
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram || self.select_chr_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 {
+ // Only the CHR-RAM variant accepts CHR writes. When the cart
+ // provided CHR-ROM (`chr_is_ram == false`), the `select_chr_ram`
+ // banking bit selects a flat-CHR read window but must NOT make
+ // the ROM mutable: writing it here would corrupt CHR-ROM and
+ // (since `save_state` only serializes `self.chr` when
+ // `chr_is_ram`) would not round-trip across a save-state. Gate
+ // the write on `chr_is_ram` so behaviour + serialization agree.
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (self.chr.len() - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ let rising = level && !self.last_a12;
+ self.last_a12 = level;
+ if !rising {
+ return;
+ }
+ if self.irq_counter == 0 || self.irq_reload {
+ self.irq_counter = self.irq_latch;
+ self.irq_reload = false;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_sub(1);
+ }
+ if self.irq_counter == 0 && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out =
+ Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.regs);
+ out.push(self.bank_select);
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.chr_mode));
+ out.push(self.irq_counter);
+ out.push(self.irq_latch);
+ out.push(u8::from(self.irq_reload));
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.push(u8::from(self.last_a12));
+ out.push(self.prg_banking_mode);
+ out.push(u8::from(self.outer_chr_64k));
+ out.push(u8::from(self.select_chr_ram));
+ out.push(u8::from(self.mmc3_chr_mode));
+ out.push(u8::from(self.cnrom_chr_mode));
+ out.push(u8::from(self.extended_mmc3));
+ out.extend_from_slice(&self.prg_base.to_le_bytes());
+ out.push(self.chr_base);
+ out.push(self.cnrom_chr_reg);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.wram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let mut c = 1;
+ self.regs.copy_from_slice(&data[c..c + 8]);
+ c += 8;
+ self.bank_select = data[c];
+ self.prg_mode = data[c + 1] != 0;
+ self.chr_mode = data[c + 2] != 0;
+ self.irq_counter = data[c + 3];
+ self.irq_latch = data[c + 4];
+ self.irq_reload = data[c + 5] != 0;
+ self.irq_enabled = data[c + 6] != 0;
+ self.irq_pending = data[c + 7] != 0;
+ self.last_a12 = data[c + 8] != 0;
+ c += 9;
+ self.prg_banking_mode = data[c];
+ self.outer_chr_64k = data[c + 1] != 0;
+ self.select_chr_ram = data[c + 2] != 0;
+ self.mmc3_chr_mode = data[c + 3] != 0;
+ self.cnrom_chr_mode = data[c + 4] != 0;
+ self.extended_mmc3 = data[c + 5] != 0;
+ c += 6;
+ self.prg_base = u16::from_le_bytes([data[c], data[c + 1]]);
+ c += 2;
+ self.chr_base = data[c];
+ self.cnrom_chr_reg = data[c + 1];
+ self.mirroring = byte_to_mirroring(data[c + 2], self.mirroring);
+ c += 3;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ self.wram.copy_from_slice(&data[c..c + self.wram.len()]);
+ c += self.wram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 176 (Waixing FK23C 8/16 Mbit BMC).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG/CHR size.
+pub fn new_m176(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Fk23c::new(prg_rom, chr_rom, mirroring)
+}
+
+// ===========================================================================
+// Coolboy (mapper 268) — COOLBOY / MINDKIDS MMC3-clone.
+//
+// An MMC3 core wrapped by four $6000-$7FFF outer-bank registers (_exRegs[0..3])
+// that supply PRG/CHR base bits + a wider/narrower mask + an extended-bank mode
+// (_exRegs[3] & 0x10). This is the register-decode-faithful BestEffort port of
+// the FCEUX/Mesen2 banking transforms. Ported from
+// Mesen2 Mmc3Variants/MMC3_Coolboy.h.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn fk23c_truncated_save_state_rejected() {
+ let m = new_m176(synth_prg_8k(16), synth_chr_1k(32), Mirroring::Vertical).unwrap();
+ let mut blob = m.save_state();
+ blob.pop();
+ let mut m2 = new_m176(synth_prg_8k(16), synth_chr_1k(32), Mirroring::Vertical).unwrap();
+ assert!(m2.load_state(&blob).is_err());
+ }
+
+ #[test]
+ fn fk23c_mmc3_prg_and_a12_irq() {
+ let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x06); // select R6
+ m.cpu_write(0x8001, 5);
+ assert_eq!(m.cpu_read(0x8000), 5); // R6 @ $8000
+ assert_eq!(m.cpu_read(0xE000), 31); // last @ $E000
+
+ m.cpu_write(0xC000, 2); // latch
+ m.cpu_write(0xC001, 0); // reload
+ m.cpu_write(0xE001, 0); // enable
+ for _ in 0..3 {
+ m.notify_a12(false);
+ m.notify_a12(true);
+ }
+ assert!(m.irq_pending());
+ m.cpu_write(0xE000, 0); // disable + ack
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn fk23c_outer_prg_base() {
+ let mut m = new_m176(synth_prg_8k(128), synth_chr_1k(64), Mirroring::Vertical).unwrap();
+ // $5001 sets PRG base low bits; $5000 mode 0 = MMC3 with outer.
+ m.cpu_write(0x5001, 0x08); // prg_base low = 8 -> outer = 16 (8<<1)
+ m.cpu_write(0x8000, 0x06); // R6
+ m.cpu_write(0x8001, 0); // R6 = 0
+ // mode 0 inner_mask = 0x3F, outer = 16 & ~0x3F = 0 -> bank 0. base only
+ // affects above the inner window; just confirm read is in range + no panic.
+ let _ = m.cpu_read(0x8000);
+ assert!(m.cpu_read(0x8000) < 128);
+ }
+
+ #[test]
+ fn fk23c_save_state_round_trip() {
+ let mut m = new_m176(synth_prg_8k(32), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x5000, 0x20); // select CHR-RAM
+ m.cpu_write(0x8000, 0x06);
+ m.cpu_write(0x8001, 7);
+ m.ppu_write(0x0040, 0x99);
+ m.cpu_write(0x6000, 0x5A); // WRAM
+ let blob = m.save_state();
+ let mut m2 = new_m176(synth_prg_8k(32), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0040), 0x99);
+ assert_eq!(m2.cpu_read(0x6000), 0x5A);
+ }
+
+ #[test]
+ fn fk23c_chr_rom_not_writable_via_select_chr_ram() {
+ // FK23C: even with `select_chr_ram` set, a CHR-ROM cart must not be
+ // mutated (regression: `ppu_write` wrote through `self.chr`, which
+ // corrupted CHR-ROM and was never serialized).
+ let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x5000, 0x20); // select_chr_ram = true
+ let before = m.ppu_read(0x0010);
+ m.ppu_write(0x0010, before.wrapping_add(1));
+ assert_eq!(m.ppu_read(0x0010), before, "CHR-ROM must not be mutable");
+ }
+}
diff --git a/crates/rustynes-mappers/src/m177_hengedianzi.rs b/crates/rustynes-mappers/src/m177_hengedianzi.rs
new file mode 100644
index 00000000..65cd20c9
--- /dev/null
+++ b/crates/rustynes-mappers/src/m177_hengedianzi.rs
@@ -0,0 +1,210 @@
+//! Hengedianzi (mapper 177) -- Chinese unlicensed board.
+//!
+//! A 32 KiB PRG bank select plus a mirroring bit, both in one write-anywhere
+//! register at `$8000-$FFFF`. Its sibling mapper 179
+//! (`m179_hengedianzi.rs`) splits the same two fields across two windows.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 177 (Hengedianzi).
+pub struct Hengedianzi177 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Hengedianzi177 {
+ /// Construct a new mapper 177 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB.
+ 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 177 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ horizontal_mirroring: false,
+ })
+ }
+}
+
+impl Mapper for Hengedianzi177 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // $8000-$FFFF: `..MP PPPP` — PRG bank is bits 0-4 (5 bits), mirroring
+ // is bit 5. The old code latched all 8 bits as the bank, so a write
+ // that flips the mirroring bit (e.g. $20) selected bank 32 and the
+ // reset vector read garbage → blank boot.
+ self.prg_bank = value & 0x1F;
+ self.horizontal_mirroring = (value & 0x20) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ 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(self.prg_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ 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::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.horizontal_mirroring = data[2] != 0;
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m177_prg_and_mirroring() {
+ let mut m = Hengedianzi177::new(synth_prg_32k(8), &[]).unwrap();
+ // value 0b0010_0011 (0x23): PRG = 0x23 % 8 = 3; bit5 set -> horizontal.
+ m.cpu_write(0x8000, 0b0010_0011);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // Clear bit 5 -> vertical.
+ m.cpu_write(0x8000, 0b0000_0010);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m177_save_state_round_trip() {
+ let mut m = Hengedianzi177::new(synth_prg_32k(8), &[]).unwrap();
+ m.cpu_write(0x8000, 0b0010_0101); // PRG 5, horizontal
+ m.ppu_write(0x0004, 0xEE);
+ let blob = m.save_state();
+ let mut m2 = Hengedianzi177::new(synth_prg_32k(8), &[]).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 5);
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ assert_eq!(m2.ppu_read(0x0004), 0xEE);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m179_hengedianzi.rs b/crates/rustynes-mappers/src/m179_hengedianzi.rs
new file mode 100644
index 00000000..94c8dcae
--- /dev/null
+++ b/crates/rustynes-mappers/src/m179_hengedianzi.rs
@@ -0,0 +1,213 @@
+//! Hengedianzi (mapper 179) -- Chinese unlicensed board.
+//!
+//! The same 32 KiB PRG select plus mirroring bit as mapper 177
+//! (`m177_hengedianzi.rs`), but split across two windows: PRG through
+//! `$5000-$5FFF` and mirroring through `$8000-$FFFF`, so a bank switch can
+//! never be confused with an ordinary ROM write.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 179 (Hengedianzi variant).
+pub struct Hengedianzi179 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Hengedianzi179 {
+ /// Construct a new mapper 179 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB.
+ 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 179 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ horizontal_mirroring: false,
+ })
+ }
+}
+
+impl Mapper for Hengedianzi179 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The PRG-bank register answers a write-only window at $5000-$5FFF; reads
+ // there are open bus, so the default `cpu_read_unmapped` is correct.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x5000..=0x5FFF => self.prg_bank = value >> 1,
+ 0x8000..=0xFFFF => self.horizontal_mirroring = (value & 0x01) != 0,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ 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(self.prg_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ 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::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.horizontal_mirroring = data[2] != 0;
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m179_prg_via_5000_and_mirror_via_8000() {
+ let mut m = Hengedianzi179::new(synth_prg_32k(8), &[]).unwrap();
+ // PRG = value >> 1; write 6 -> bank 3.
+ m.cpu_write(0x5000, 6);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Mirroring bit at $8000-$FFFF (bit 0).
+ m.cpu_write(0x8000, 0x01);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0x8000, 0x00);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m179_save_state_round_trip() {
+ let mut m = Hengedianzi179::new(synth_prg_32k(8), &[]).unwrap();
+ m.cpu_write(0x5000, 8); // PRG 4
+ m.cpu_write(0x8000, 0x01); // horizontal
+ m.ppu_write(0x0006, 0x12);
+ let blob = m.save_state();
+ let mut m2 = Hengedianzi179::new(synth_prg_32k(8), &[]).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 4);
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ assert_eq!(m2.ppu_read(0x0006), 0x12);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m180_nichibutsu180.rs b/crates/rustynes-mappers/src/m180_nichibutsu180.rs
new file mode 100644
index 00000000..6f032681
--- /dev/null
+++ b/crates/rustynes-mappers/src/m180_nichibutsu180.rs
@@ -0,0 +1,232 @@
+//! Nichibutsu / Hokutosha (mapper 180) -- Crazy Climber.
+//!
+//! An inverted `UxROM` board: the *low* 16 KiB is fixed and the *high* 16 KiB
+//! switchable, the opposite of stock `UxROM`. That inversion is not a design
+//! flourish -- Crazy Climber ships with a special controller, and the board is
+//! wired so the fixed half holds the code that must always be reachable.
+//! Writes are subject to a bus conflict, as on any ungated discrete board.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 180 (Nichibutsu `UNROM`-inverted, Crazy Climber).
+pub struct Nichibutsu180 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Nichibutsu180 {
+ /// Construct a new mapper 180 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not 8 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 180 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len() == CHR_BANK_8K {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 180 expects 8 KiB CHR (RAM or ROM); got {} bytes",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, offset_in_bank: usize) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_16K + offset_in_bank]
+ }
+}
+
+impl Mapper for Nichibutsu180 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(0, addr as usize - 0x8000),
+ 0xC000..=0xFFFF => self.read_prg(self.prg_bank as usize, addr as usize - 0xC000),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict: AND with the byte currently visible at addr.
+ let prg_byte = self.cpu_read_at_for_conflict(addr);
+ let effective = value & prg_byte;
+ self.prg_bank = effective & 0x07;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ self.chr[addr as usize] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(2 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 2 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ let mut cursor = 2;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+impl Nichibutsu180 {
+ /// The byte currently visible at `addr` in the $8000-$FFFF window, used for
+ /// bus-conflict masking (mirrors the active `cpu_read` banking).
+ fn cpu_read_at_for_conflict(&self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(0, addr as usize - 0x8000),
+ _ => self.read_prg(self.prg_bank as usize, addr as usize - 0xC000),
+ }
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m180_fixes_low_switches_high() {
+ let mut m =
+ Nichibutsu180::new(synth_prg_16k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ // $8000-$BFFF is fixed to bank 0.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ // Write at $C001 (PRG byte 0xFF -> no masking) selects $C000 bank 3.
+ m.cpu_write(0xC001, 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ // $8000 still fixed.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ }
+
+ #[test]
+ fn m180_bus_conflict() {
+ // $C000 bank 0 offset 0 holds the bank index (0). Writing 3 there ANDs
+ // with 0 -> bank 0.
+ let mut m =
+ Nichibutsu180::new(synth_prg_16k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xC000, 3);
+ assert_eq!(m.cpu_read(0xC000), 0);
+ }
+}
diff --git a/crates/rustynes-mappers/src/sunsoft1.rs b/crates/rustynes-mappers/src/m184_sunsoft1.rs
similarity index 100%
rename from crates/rustynes-mappers/src/sunsoft1.rs
rename to crates/rustynes-mappers/src/m184_sunsoft1.rs
diff --git a/crates/rustynes-mappers/src/m185_cnrom185.rs b/crates/rustynes-mappers/src/m185_cnrom185.rs
new file mode 100644
index 00000000..43dd0999
--- /dev/null
+++ b/crates/rustynes-mappers/src/m185_cnrom185.rs
@@ -0,0 +1,271 @@
+//! CNROM with CHR copy protection (mapper 185).
+//!
+//! Electrically a stock CNROM, but the board's CHR-ROM is used as a
+//! protection check: the game reads a known pattern back from CHR and, if the
+//! value is wrong, the board disables CHR entirely so the screen fills with
+//! garbage. Emulating it means modelling the *disable*, not just the banking
+//! -- and the exact value that counts as "correct" varies by submapper, which
+//! is why the decode matches on submapper rather than assuming one rule.
+//!
+//! Stock CNROM is in `m003_cnrom.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 185 (`CNROM` with CHR-disable copy protection).
+pub struct CnRom185 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_reg_raw: u8,
+ chr_bank: u8,
+ /// CHR-ROM enable latch. Powers on ENABLED (Mesen2 `CnromProtect`); the
+ /// protection write may disable it. Initialising this to a derived-from-
+ /// `chr_reg_raw=0` value left CHR reading $FF before the first register
+ /// write, so the title screen never drew -> blank boot.
+ chr_enabled: bool,
+ sub_mapper: u8,
+ mirroring: Mirroring,
+}
+
+impl CnRom185 {
+ /// Construct a new mapper 185 board.
+ ///
+ /// `sub_mapper` selects the CHR-enable pattern (0 = default heuristic,
+ /// 4..=7 = exact-match `value & 0x03`).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not 16/32 KiB or CHR-ROM is
+ /// empty / not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ sub_mapper: u8,
+ ) -> Result {
+ if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
+ return Err(MapperError::Invalid(format!(
+ "mapper 185 expects 16 or 32 KiB PRG, got {} bytes",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 185 expects non-empty CHR-ROM in 8 KiB units, got {} bytes",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_reg_raw: 0,
+ chr_bank: 0,
+ chr_enabled: true,
+ sub_mapper: sub_mapper & 0x0F,
+ mirroring,
+ })
+ }
+
+ // The per-submapper CHR-enable rule (Mesen2 CnromProtect): submapper 0 is a
+ // heuristic on the raw written latch; 4..=7 are exact low-2-bit matches.
+ #[allow(clippy::verbose_bit_mask)]
+ const fn chr_enable_for(&self, value: u8) -> bool {
+ match self.sub_mapper {
+ 4 => (value & 0x03) == 0,
+ 5 => (value & 0x03) == 1,
+ 6 => (value & 0x03) == 2,
+ 7 => (value & 0x03) == 3,
+ // Submapper 0 heuristic: enabled iff low nibble nonzero and != $13.
+ _ => (value & 0x0F) != 0 && value != 0x13,
+ }
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let off = (addr - 0x8000) as usize;
+ if self.prg_rom.len() == PRG_BANK_16K {
+ self.prg_rom[off & (PRG_BANK_16K - 1)]
+ } else {
+ self.prg_rom[off]
+ }
+ }
+}
+
+impl Mapper for CnRom185 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.read_prg(addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict (mapper 185 always has AND-type bus conflicts).
+ let effective = value & self.read_prg(addr);
+ self.chr_reg_raw = effective;
+ self.chr_enabled = self.chr_enable_for(effective);
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let mask = u8::try_from((count - 1) | 0x03).unwrap_or(u8::MAX);
+ self.chr_bank = effective & mask;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.chr_reg_raw);
+ out.push(self.chr_bank);
+ out.push(self.sub_mapper);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 4 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.chr_reg_raw = data[1];
+ self.chr_bank = data[2];
+ self.sub_mapper = data[3] & 0x0F;
+ // chr_enabled is a deterministic function of the latched register +
+ // submapper, so it is reconstructed rather than serialised (keeps the
+ // save format stable). Power-on (chr_reg_raw == 0) restores to enabled
+ // only if the heuristic agrees; the first write re-evaluates anyway.
+ self.chr_enabled = self.chr_enable_for(self.chr_reg_raw);
+ self.vram.copy_from_slice(&data[4..4 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg(bytes: usize, fill: u8) -> Box<[u8]> {
+ vec![fill; bytes].into_boxed_slice()
+ }
+
+ #[test]
+ fn m185_chr_disable_protection_default() {
+ let mut m = CnRom185::new(
+ synth_prg(PRG_BANK_32K, 0xFF),
+ synth_chr_8k(4),
+ Mirroring::Vertical,
+ 0,
+ )
+ .unwrap();
+ // Power-on: CHR is ENABLED before any register write (Mesen2), so the
+ // title screen draws. (The old derive-from-zero model read $FF here.)
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // Submapper-0 heuristic: enabled iff (value & 0x0F) != 0 and value != $13.
+ // Write 1 -> enabled, bank = 1 & mask.
+ m.cpu_write(0x8000, 1);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ // Write 0 -> CHR disabled -> reads $FF.
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.ppu_read(0x0000), 0xFF);
+ // Write $13 -> the documented disabled sentinel -> $FF.
+ m.cpu_write(0x8000, 0x13);
+ assert_eq!(m.ppu_read(0x0000), 0xFF);
+ }
+
+ #[test]
+ fn m185_submapper_exact_match() {
+ let mut m = CnRom185::new(
+ synth_prg(PRG_BANK_32K, 0xFF),
+ synth_chr_8k(4),
+ Mirroring::Vertical,
+ 4, // enabled iff (value & 3) == 0
+ )
+ .unwrap();
+ m.cpu_write(0x8000, 0); // (0 & 3) == 0 -> enabled, bank 0
+ assert_eq!(m.ppu_read(0x0000), 0);
+ m.cpu_write(0x8000, 1); // (1 & 3) == 1 != 0 -> disabled
+ assert_eq!(m.ppu_read(0x0000), 0xFF);
+ }
+}
diff --git a/crates/rustynes-mappers/src/namco175.rs b/crates/rustynes-mappers/src/m210_namco175.rs
similarity index 100%
rename from crates/rustynes-mappers/src/namco175.rs
rename to crates/rustynes-mappers/src/m210_namco175.rs
diff --git a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs
new file mode 100644
index 00000000..36833917
--- /dev/null
+++ b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs
@@ -0,0 +1,209 @@
+//! Camerica / Codemasters `BF9096` (mapper 232) -- the Quattro multicarts.
+//!
+//! Two-level 16 KiB PRG banking: an outer block select and an inner bank
+//! select within that block, which is how a four-game cartridge presents each
+//! title as if it owned the whole address space.
+//!
+//! The single-game `BF9093` is a different ASIC on mapper 71; see
+//! `m071_camerica_bf9093.rs`.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 232 (Camerica Quattro / `BF9096`).
+pub struct Camerica232 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ outer_block: u8,
+ inner_page: u8,
+ mirroring: Mirroring,
+}
+
+impl Camerica232 {
+ /// Construct a new mapper 232 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB. CHR is always 8 KiB RAM (any supplied CHR-ROM is rejected).
+ 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 232 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr: Box<[u8]> = if chr_rom.is_empty() {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len() == CHR_BANK_8K {
+ // Some dumps carry 8 KiB CHR-ROM; accept and use it read-only.
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 232 expects 8 KiB CHR (RAM or ROM); got {} bytes",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ outer_block: 0,
+ inner_page: 0,
+ mirroring,
+ })
+ }
+
+ fn map_16k(&self, page_in_block: u8) -> usize {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = ((self.outer_block << 2) | (page_in_block & 0x03)) as usize;
+ bank % count
+ }
+}
+
+impl Mapper for Camerica232 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank = self.map_16k(self.inner_page);
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize - 0x8000)]
+ }
+ 0xC000..=0xFFFF => {
+ let bank = self.map_16k(3);
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize - 0xC000)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x8000..=0xBFFF => self.outer_block = (value >> 3) & 0x03,
+ 0xC000..=0xFFFF => self.inner_page = value & 0x03,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ // CHR-RAM dumps allow writes; if CHR is the supplied 8 KiB
+ // image we still let the program scribble its 8 KiB window.
+ self.chr[addr as usize] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.outer_block);
+ out.push(self.inner_page);
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.chr);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len() + self.chr.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.outer_block = data[1];
+ self.inner_page = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m232_two_level_banking() {
+ // 8 16 KiB banks = 2 blocks of 4 pages.
+ let mut m = Camerica232::new(synth_prg_16k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ // Select outer block 1 ($8000 write, bits 4-3 = 0b01 << 3 = 0b1000).
+ m.cpu_write(0x8000, 0b0000_1000);
+ // Select inner page 2 ($C000 write, bits 1-0).
+ m.cpu_write(0xC000, 0b0000_0010);
+ // $8000-$BFFF reads bank (1<<2)|2 = 6.
+ assert_eq!(m.cpu_read(0x8000), 6);
+ // $C000-$FFFF is fixed to page 3 of block 1 -> bank (1<<2)|3 = 7.
+ assert_eq!(m.cpu_read(0xC000), 7);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m240_cne_multicart.rs b/crates/rustynes-mappers/src/m240_cne_multicart.rs
new file mode 100644
index 00000000..aff2ef04
--- /dev/null
+++ b/crates/rustynes-mappers/src/m240_cne_multicart.rs
@@ -0,0 +1,204 @@
+//! C&E multicart (mapper 240).
+//!
+//! One register carrying both the PRG and CHR bank fields, decoded in the
+//! `$4020-$5FFF` expansion window -- so it does not collide with the PRG-RAM
+//! window a bundled game may also be using.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 240 (C&E multicart).
+pub struct Cne240 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Cne240 {
+ /// Construct a new mapper 240 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 240 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 240 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Cne240 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The register window is write-only at $4020-$5FFF; reads there fall
+ // through to open bus, so the default `cpu_read_unmapped` is correct.
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4020..=0x5FFF).contains(&addr) {
+ self.prg_bank = (value >> 4) & 0x0F;
+ self.chr_bank = value & 0x0F;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m240_register_in_4020_5fff() {
+ let mut m = Cne240::new(synth_prg_32k(4), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // value DDDD_PPPP: PRG = (v>>4)&0xF, CHR = v&0xF.
+ m.cpu_write(0x5000, 0b0011_1010); // PRG 3, CHR 10
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.ppu_read(0x0000), 10);
+ // $4020 is the bottom of the register window.
+ m.cpu_write(0x4020, 0b0001_0101); // PRG 1, CHR 5
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // Reads in the register window fall through to open bus.
+ assert!(m.cpu_read_unmapped(0x5000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m241_bxrom241.rs b/crates/rustynes-mappers/src/m241_bxrom241.rs
new file mode 100644
index 00000000..fdce3c0d
--- /dev/null
+++ b/crates/rustynes-mappers/src/m241_bxrom241.rs
@@ -0,0 +1,202 @@
+//! `BxROM`-like pirate board (mapper 241), e.g. the Mortal Kombat bootlegs.
+//!
+//! The whole written byte selects a 32 KiB PRG bank -- no masking, no bus
+//! conflict -- with CHR-RAM and fixed mirroring. Effectively BNROM with a
+//! wider bank field; see `m034_bnrom_nina001.rs` for BNROM proper.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 241 (`BxROM`-like pirate board).
+pub struct Bxrom241 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Bxrom241 {
+ /// Construct a new mapper 241 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 241 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len() == CHR_BANK_8K {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 241 expects 8 KiB CHR (RAM or ROM); got {} bytes",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Bxrom241 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = value;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ self.chr[addr as usize] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(2 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 2 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ let mut cursor = 2;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m241_full_byte_selects_32k_prg() {
+ let mut m = Bxrom241::new(synth_prg_32k(8), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 5);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ // No bus conflict: the written value sticks even though offset 0 of the
+ // landing bank is not 0xFF.
+ m.cpu_write(0xFFFF, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ }
+
+ #[test]
+ fn m241_chr_ram_round_trip() {
+ let mut m = Bxrom241::new(synth_prg_32k(2), Box::new([]), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x0010, 0xAB);
+ assert_eq!(m.ppu_read(0x0010), 0xAB);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m244_cne_decathlon.rs b/crates/rustynes-mappers/src/m244_cne_decathlon.rs
new file mode 100644
index 00000000..8792cafa
--- /dev/null
+++ b/crates/rustynes-mappers/src/m244_cne_decathlon.rs
@@ -0,0 +1,238 @@
+//! C&E Decathlon (mapper 244).
+//!
+//! A value-decoded sibling of the C&E multicart in `m240_cne_multicart.rs`:
+//! the *written byte* selects the PRG and CHR banks through lookup tables
+//! rather than supplying the bank number directly.
+//!
+//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
+//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
+//! audio. Banking / mirroring semantics are cross-checked against the
+//! `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, and validated by register-decode + save-state unit
+//! tests.
+//!
+//! See `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 244 (Decathlon).
+pub struct Decathlon244 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Decathlon244 {
+ /// Construct a new mapper 244 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 244 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 244 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Decathlon244 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // Mapper 244 decodes the written DATA byte (not the address) through two
+ // scramble LUTs, selecting CHR vs PRG by bit 3:
+ // value & 0x08 != 0 -> CHR 8 KiB = LUT_CHR[(value>>4)&7][value&7]
+ // else -> PRG 32 KiB = LUT_PRG[(value>>4)&3][value&3]
+ // The old code ignored the data byte and decoded address bits with no
+ // scramble, so it banked to the wrong PRG/CHR and the menu never drew.
+ // (Mesen2 Mapper244 / puNES mapper_244 carry the identical tables.)
+ const LUT_PRG: [[u8; 4]; 4] = [[0, 1, 2, 3], [3, 2, 1, 0], [0, 2, 1, 3], [3, 1, 2, 0]];
+ const LUT_CHR: [[u8; 8]; 8] = [
+ [0, 1, 2, 3, 4, 5, 6, 7],
+ [0, 2, 1, 3, 4, 6, 5, 7],
+ [0, 1, 4, 5, 2, 3, 6, 7],
+ [0, 4, 1, 5, 2, 6, 3, 7],
+ [0, 4, 2, 6, 1, 5, 3, 7],
+ [0, 2, 4, 6, 1, 3, 5, 7],
+ [7, 6, 5, 4, 3, 2, 1, 0],
+ [7, 6, 5, 4, 3, 2, 1, 0],
+ ];
+ if (0x8000..=0xFFFF).contains(&addr) {
+ if value & 0x08 != 0 {
+ self.chr_bank = LUT_CHR[((value >> 4) & 0x07) as usize][(value & 0x07) as usize];
+ } else {
+ self.prg_bank = LUT_PRG[((value >> 4) & 0x03) as usize][(value & 0x03) as usize];
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m244_value_decoded_banks() {
+ let mut m =
+ Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // PRG select (value & 0x08 == 0): value 0x11 -> LUT_PRG[(1)][1] = 2.
+ m.cpu_write(0x8000, 0x11);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // value 0x30 -> LUT_PRG[3][0] = 3.
+ m.cpu_write(0x8000, 0x30);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // CHR select (value & 0x08 != 0): value 0x09 -> LUT_CHR[0][1] = 1.
+ m.cpu_write(0x8000, 0x09);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ // value 0x6E -> LUT_CHR[6][6] = 1 (table row 6 reversed).
+ m.cpu_write(0x8000, 0x6E);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn m244_save_state_round_trip() {
+ let mut m =
+ Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x11); // PRG = LUT_PRG[1][1] = 2
+ let blob = m.save_state();
+ let mut m2 =
+ Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs
new file mode 100644
index 00000000..1b3591fb
--- /dev/null
+++ b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs
@@ -0,0 +1,261 @@
+//! Fong Shen Bang / Feng Shen Bang (mapper 246).
+//!
+//! Four bank-select registers in the `$6000-$67FF` window -- two PRG, two
+//! CHR -- with battery-backed PRG-RAM sharing the same `$6000` region above
+//! the register window. The split matters: a write below `$6800` is a
+//! register, a write above it is save RAM.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_2K: usize = 0x0800;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 246 (`Fong Shen Bang` / G0151-1).
+pub struct FongShenBang246 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// 2 KiB battery-backed PRG-RAM at $6800-$6FFF.
+ prg_ram: Box<[u8]>,
+ prg_banks: [u8; 4],
+ chr_banks: [u8; 4],
+ mirroring: Mirroring,
+}
+
+impl FongShenBang246 {
+ /// Construct a new mapper 246 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB or CHR-ROM is empty / not a multiple of 2 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 246 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_2K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 246 CHR-ROM size {} is not a non-zero multiple of 2 KiB",
+ chr_rom.len()
+ )));
+ }
+ // Power-on (per the nesdev wiki): the $6000-$6002 PRG regs are 0, but
+ // $6003 (the $E000-$FFFF slot) initializes to 0xFF — so the reset vector
+ // at $FFFC resolves into the last PRG bank, where the boot code lives.
+ let prg_banks = [0, 0, 0, 0xFF];
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_ram: vec![0u8; 0x0800].into_boxed_slice(),
+ prg_banks,
+ chr_banks: [0, 0, 0, 0],
+ mirroring,
+ })
+ }
+
+ fn prg_byte(&self, slot: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let mut bank = self.prg_banks[slot] as usize;
+ // $E000-$FFFF hardware quirk: reads from $FFE4-$FFE7, $FFEC-$FFEF,
+ // $FFF4-$FFF7, and $FFFC-$FFFF force PRG A17 high (bank bit 4 of an 8 KiB
+ // index). The interrupt/reset vectors live in that forced region.
+ if slot == 3 {
+ let low = addr & 0x001F;
+ let in_window = (0xFFE4..=0xFFFF).contains(&addr)
+ && matches!(low, 0x04..=0x07 | 0x0C..=0x0F | 0x14..=0x17 | 0x1C..=0x1F);
+ if in_window {
+ bank |= 0x10;
+ }
+ }
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+}
+
+impl Mapper for FongShenBang246 {
+ fn sram(&self) -> &[u8] {
+ &self.prg_ram
+ }
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.prg_ram
+ }
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // Only the dead sub-ranges below the PRG window are open bus: $4020-$67FF
+ // (the write-only register file at $6000-$67FF + the $4020-$5FFF gap) and
+ // the $7000-$7FFF mirror gap. The 2 KiB PRG-RAM at $6800-$6FFF and the PRG
+ // ROM at $8000-$FFFF are mapped (matching the trait default of "$6000-$FFFF
+ // is mapped" but carving out the register/gap holes).
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x67FF).contains(&addr) || (0x7000..=0x7FFF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6800..=0x6FFF => self.prg_ram[(addr - 0x6800) as usize],
+ 0x8000..=0x9FFF => self.prg_byte(0, addr),
+ 0xA000..=0xBFFF => self.prg_byte(1, addr),
+ 0xC000..=0xDFFF => self.prg_byte(2, addr),
+ 0xE000..=0xFFFF => self.prg_byte(3, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x6000..=0x6003 => self.prg_banks[(addr & 0x03) as usize] = value,
+ 0x6004..=0x6007 => self.chr_banks[(addr & 0x03) as usize] = value,
+ 0x6800..=0x6FFF => self.prg_ram[(addr - 0x6800) as usize] = value,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + 4 + 4 + self.prg_ram.len() + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.prg_banks);
+ out.extend_from_slice(&self.chr_banks);
+ out.extend_from_slice(&self.prg_ram);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 1 + 4 + 4 + self.prg_ram.len() + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_banks.copy_from_slice(&data[1..5]);
+ self.chr_banks.copy_from_slice(&data[5..9]);
+ let mut cursor = 9;
+ self.prg_ram
+ .copy_from_slice(&data[cursor..cursor + self.prg_ram.len()]);
+ cursor += self.prg_ram.len();
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_2k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_2K];
+ for b in 0..banks {
+ v[b * CHR_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m246_register_banking_and_prg_ram() {
+ let mut m =
+ FongShenBang246::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ // $6000 -> PRG $8000 = bank 3.
+ m.cpu_write(0x6000, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // $6004 -> CHR slot 0 = bank 5.
+ m.cpu_write(0x6004, 5);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // PRG-RAM round-trips at $6800.
+ m.cpu_write(0x6800, 0xC4);
+ assert_eq!(m.cpu_read(0x6800), 0xC4);
+ }
+
+ #[test]
+ fn m246_save_state_round_trip() {
+ let mut m =
+ FongShenBang246::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6001, 4); // PRG $A000 = bank 4
+ m.cpu_write(0x6007, 6); // CHR slot 3 = bank 6
+ m.cpu_write(0x6900, 0x9D); // PRG-RAM at $6800-$6FFF
+ let blob = m.save_state();
+ let mut m2 =
+ FongShenBang246::new(synth_prg_8k(8), synth_chr_2k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xA000), 4);
+ assert_eq!(m2.ppu_read(0x1800), 6);
+ assert_eq!(m2.cpu_read(0x6900), 0x9D);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m250_nitra250.rs b/crates/rustynes-mappers/src/m250_nitra250.rs
new file mode 100644
index 00000000..d1eb7370
--- /dev/null
+++ b/crates/rustynes-mappers/src/m250_nitra250.rs
@@ -0,0 +1,359 @@
+//! Nitra (mapper 250) -- Time Diver Avenger.
+//!
+//! An MMC3 work-alike that moves the register interface into the *address*:
+//! the value written is ignored, and the low byte of the address supplies
+//! the data instead. The underlying bank/IRQ behaviour is MMC3's, which is
+//! why this board carries an A12-driven scanline IRQ counter where the rest
+//! of its size class carries none.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 250 (Nitra, *Time Diver Avenger*).
+// Independent banking / mode / IRQ flags; grouping them would obscure the
+// MMC3-equivalent register decode for no gain (mirrors `MapperCaps`).
+#[allow(clippy::struct_excessive_bools)]
+pub struct Nitra250 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg_index: u8,
+ bank_regs: [u8; 8],
+ prg_mode: bool,
+ chr_mode: bool,
+ horizontal_mirroring: bool,
+ irq_latch: u8,
+ irq_counter: u8,
+ irq_reload: bool,
+ irq_enabled: bool,
+ irq_pending: bool,
+}
+
+impl Nitra250 {
+ /// Construct a new mapper 250 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB or CHR-ROM is empty / 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 250 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 250 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg_index: 0,
+ bank_regs: [0; 8],
+ prg_mode: false,
+ chr_mode: false,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ irq_latch: 0,
+ irq_counter: 0,
+ irq_reload: false,
+ irq_enabled: false,
+ irq_pending: false,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+
+ fn prg_bank_for(&self, addr: u16) -> usize {
+ let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
+ // `saturating_sub`, not `last - 1`: `new()` accepts any non-zero 8 KiB
+ // multiple, so an 8 KiB PRG gives `last == 0` and the fixed second-last
+ // slot underflows on an ordinary `$C000` read — a panic under overflow
+ // checks, contradicting this module's own "cannot afford a panic on a
+ // register access" invariant. `m004_mmc3` uses the same guard.
+ let second_last = last.saturating_sub(1);
+ let r6 = self.bank_regs[6] as usize;
+ let r7 = self.bank_regs[7] as usize;
+ match (self.prg_mode, addr) {
+ (false, 0x8000..=0x9FFF) | (true, 0xC000..=0xDFFF) => r6,
+ (false, 0xC000..=0xDFFF) | (true, 0x8000..=0x9FFF) => second_last,
+ (_, 0xA000..=0xBFFF) => r7,
+ _ => last,
+ }
+ }
+
+ fn read_chr(&self, addr: u16) -> u8 {
+ let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ // MMC3-style: chr_mode swaps the two 2 KiB and four 1 KiB regions.
+ let region = (addr >> 10) & 0x07;
+ let region = if self.chr_mode { region ^ 0x04 } else { region };
+ let bank1k = match region {
+ 0 => self.bank_regs[0] as usize & !1,
+ 1 => (self.bank_regs[0] as usize & !1) + 1,
+ 2 => self.bank_regs[1] as usize & !1,
+ 3 => (self.bank_regs[1] as usize & !1) + 1,
+ 4 => self.bank_regs[2] as usize,
+ 5 => self.bank_regs[3] as usize,
+ 6 => self.bank_regs[4] as usize,
+ _ => self.bank_regs[5] as usize,
+ };
+ let bank = bank1k % count1k;
+ self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
+ }
+}
+
+impl Mapper for Nitra250 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let bank = self.prg_bank_for(addr);
+ self.read_prg(bank, addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ // The MMC3-equivalent "data" is the low byte of the address (A0-A7); the
+ // MMC3 even/odd register line is carried by A10 (bit 10 of the address),
+ // not A8 — Mesen2 MMC3_250 decodes `(addr & 0xE000) | ((addr & 0x0400)
+ // >> 10)`. A8 left the bank-select / mirroring writes mis-routed, so the
+ // reset vector landed in the wrong PRG bank → blank boot.
+ let value = (addr & 0x00FF) as u8;
+ let odd = (addr & 0x0400) != 0;
+ match addr & 0xE000 {
+ 0x8000 => {
+ if odd {
+ self.bank_regs[self.reg_index as usize] = value;
+ } else {
+ self.reg_index = value & 0x07;
+ self.prg_mode = (value & 0x40) != 0;
+ self.chr_mode = (value & 0x80) != 0;
+ }
+ }
+ 0xA000 => {
+ if !odd {
+ self.horizontal_mirroring = (value & 0x01) != 0;
+ }
+ }
+ 0xC000 => {
+ if odd {
+ self.irq_reload = true;
+ } else {
+ self.irq_latch = value;
+ }
+ }
+ 0xE000 => {
+ if odd {
+ self.irq_enabled = true;
+ } else {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ // A simple 8-bit M2 reload counter (Nitra wires the MMC3 IRQ to M2 on
+ // this board rather than to A12). On reload or zero, reload from latch;
+ // otherwise decrement, asserting at the 1->0 transition when enabled.
+ if self.irq_reload || self.irq_counter == 0 {
+ self.irq_counter = self.irq_latch;
+ self.irq_reload = false;
+ } else {
+ self.irq_counter -= 1;
+ if self.irq_counter == 0 && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(18 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg_index);
+ out.extend_from_slice(&self.bank_regs);
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.chr_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ out.push(self.irq_latch);
+ out.push(self.irq_counter);
+ out.push(u8::from(self.irq_reload));
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 18 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg_index = data[1] & 0x07;
+ self.bank_regs.copy_from_slice(&data[2..10]);
+ self.prg_mode = data[10] != 0;
+ self.chr_mode = data[11] != 0;
+ self.horizontal_mirroring = data[12] != 0;
+ self.irq_latch = data[13];
+ self.irq_counter = data[14];
+ self.irq_reload = data[15] != 0;
+ self.irq_enabled = data[16] != 0;
+ self.irq_pending = data[17] != 0;
+ self.vram.copy_from_slice(&data[18..18 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m250_address_encoded_mmc3_banking() {
+ let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ // A10 (0x0400) carries the MMC3 even/odd line; A0-A7 carry the data.
+ // Even $8000 (A10=0), data 0x06 -> reg select index 6.
+ m.cpu_write(0x8000 | 0x06, 0);
+ // Odd $8000 (A10=1), data 0x03 -> bank_regs[6] = 3.
+ m.cpu_write(0x8000 | 0x400 | 0x03, 0);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Mirroring via even $A000 (A10=0), data bit0 = 1.
+ m.cpu_write(0xA000 | 0x01, 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m250_irq_counts_down() {
+ let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ // latch = 3 via even $C000 (A10=0), data 0x03.
+ m.cpu_write(0xC000 | 0x03, 0);
+ m.cpu_write(0xC000 | 0x400, 0); // reload (odd, A10=1)
+ m.cpu_write(0xE000 | 0x400, 0); // enable (odd, A10=1)
+ // First cycle reloads from latch (=3); subsequent decrements reach 0.
+ for _ in 0..5 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ m.cpu_write(0xE000, 0); // disable + ack (even, A10=0)
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn m250_save_state_round_trip() {
+ let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000 | 0x06, 0);
+ m.cpu_write(0x8000 | 0x400 | 0x02, 0);
+ m.cpu_write(0xC000 | 0x05, 0);
+ m.cpu_write(0xC000 | 0x400, 0);
+ m.cpu_write(0xE000 | 0x400, 0);
+ m.notify_cpu_cycle();
+ let blob = m.save_state();
+ let mut m2 = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/m268_bmc_coolboy.rs b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs
new file mode 100644
index 00000000..7f89899c
--- /dev/null
+++ b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs
@@ -0,0 +1,507 @@
+//! `COOLBOY` / `MINDKIDS` (mapper 268).
+//!
+//! Another MMC3-core-plus-outer-registers pirate ASIC, closely related to the
+//! `FK23C` in `m176_bmc_fk23c.rs` but with its outer registers in the
+//! `$6000-$7FFF` PRG-RAM window and a different mode encoding. The two are
+//! kept separate rather than merged because their outer decode is where all
+//! the per-board behaviour lives, and conflating them would obscure exactly
+//! the part that differs.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// COOLBOY / MINDKIDS MMC3-clone multicart (mapper 268).
+pub struct Coolboy {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ prg_count_8k: usize,
+ chr_count_1k: usize,
+ regs: [u8; 8],
+ bank_select: u8,
+ prg_mode: bool,
+ chr_mode: bool,
+ irq_counter: u8,
+ irq_latch: u8,
+ irq_reload: bool,
+ irq_enabled: bool,
+ irq_pending: bool,
+ last_a12: bool,
+ ex_regs: [u8; 4],
+}
+
+impl Coolboy {
+ const SAVE_LEN: usize = 8 + 9 + 4 + 1;
+
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 268)?;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; 0x40000].into_boxed_slice()
+ } else {
+ if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 268 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ chr_rom
+ };
+ let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
+ let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg_count_8k,
+ chr_count_1k,
+ regs: [0; 8],
+ bank_select: 0,
+ prg_mode: false,
+ chr_mode: false,
+ irq_counter: 0,
+ irq_latch: 0,
+ irq_reload: false,
+ irq_enabled: false,
+ irq_pending: false,
+ last_a12: false,
+ ex_regs: [0; 4],
+ })
+ }
+
+ fn prg_bank_mmc3(&self, slot: usize) -> usize {
+ let last = self.prg_count_8k - 1;
+ let second_last = last.saturating_sub(1);
+ let r6 = self.regs[6] as usize;
+ let r7 = self.regs[7] as usize;
+ match (slot, self.prg_mode) {
+ (0, false) => r6,
+ (0, true) => second_last,
+ (1, _) => r7,
+ (2, false) => second_last,
+ (2, true) => r6,
+ (3, _) => last,
+ _ => 0,
+ }
+ }
+
+ fn resolve_prg(&self, slot: usize) -> usize {
+ let page = self.prg_bank_mmc3(slot);
+ let e0 = self.ex_regs[0] as usize;
+ let e1 = self.ex_regs[1] as usize;
+ let e3 = self.ex_regs[3] as usize;
+ let mut mask =
+ ((0x3F | (e1 & 0x40) | ((e1 & 0x20) << 2)) ^ ((e0 & 0x40) >> 2)) ^ ((e1 & 0x80) >> 2);
+ let base = (e0 & 0x07) | ((e1 & 0x10) >> 1) | ((e1 & 0x0C) << 2) | ((e0 & 0x30) << 2);
+ let bank = if e3 & 0x10 == 0 {
+ ((base << 4) & !mask) | (page & mask)
+ } else {
+ mask &= 0xF0;
+ let emask = if e1 & 0x02 != 0 {
+ (e3 & 0x0C) | (slot & 0x01)
+ } else {
+ e3 & 0x0E
+ };
+ ((base << 4) & !mask) | (page & mask) | emask | (slot & 0x01)
+ };
+ bank % self.prg_count_8k
+ }
+
+ fn chr_bank_mmc3(&self, slot: usize) -> usize {
+ let banks: [usize; 8] = if self.chr_mode {
+ [
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ ]
+ } else {
+ [
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ ]
+ };
+ banks[slot & 0x07]
+ }
+
+ fn resolve_chr(&self, slot: usize) -> usize {
+ let page = self.chr_bank_mmc3(slot);
+ let e0 = self.ex_regs[0] as usize;
+ let e2 = self.ex_regs[2] as usize;
+ let e3 = self.ex_regs[3] as usize;
+ let mask = 0xFF ^ (e0 & 0x80);
+ let bank = if e3 & 0x10 != 0 {
+ (page & 0x80 & mask) | (((e0 & 0x08) << 4) & !mask) | ((e2 & 0x0F) << 3) | slot
+ } else {
+ (page & mask) | (((e0 & 0x08) << 4) & !mask)
+ };
+ bank % self.chr_count_1k
+ }
+
+ fn write_mmc3(&mut self, addr: u16, value: u8) {
+ match addr & 0xE001 {
+ 0x8000 => {
+ self.bank_select = value & 0x07;
+ self.prg_mode = value & 0x40 != 0;
+ self.chr_mode = value & 0x80 != 0;
+ }
+ 0x8001 => {
+ let idx = (self.bank_select & 0x07) as usize;
+ self.regs[idx] = value;
+ }
+ 0xA000 => {
+ self.mirroring = if value & 0x01 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ 0xC000 => self.irq_latch = value,
+ 0xC001 => {
+ self.irq_counter = 0;
+ self.irq_reload = true;
+ }
+ 0xE000 => {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ 0xE001 => self.irq_enabled = true,
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Coolboy {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: false,
+ audio: false,
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0x9FFF => {
+ let b = self.resolve_prg(0);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xA000..=0xBFFF => {
+ let b = self.resolve_prg(1);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xC000..=0xDFFF => {
+ let b = self.resolve_prg(2);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xE000..=0xFFFF => {
+ let b = self.resolve_prg(3);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x6000..=0x7FFF => {
+ // Outer-bank registers, latched while $E000-bit-7 mode allows.
+ if (self.ex_regs[3] & 0x90) != 0x80 {
+ self.ex_regs[(addr & 0x03) as usize] = value;
+ }
+ }
+ 0x8000..=0xFFFF => self.write_mmc3(addr, value),
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (self.chr.len() - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ let rising = level && !self.last_a12;
+ self.last_a12 = level;
+ if !rising {
+ return;
+ }
+ if self.irq_counter == 0 || self.irq_reload {
+ self.irq_counter = self.irq_latch;
+ self.irq_reload = false;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_sub(1);
+ }
+ if self.irq_counter == 0 && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.regs);
+ out.push(self.bank_select);
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.chr_mode));
+ out.push(self.irq_counter);
+ out.push(self.irq_latch);
+ out.push(u8::from(self.irq_reload));
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.push(u8::from(self.last_a12));
+ out.extend_from_slice(&self.ex_regs);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + Self::SAVE_LEN + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let mut c = 1;
+ self.regs.copy_from_slice(&data[c..c + 8]);
+ c += 8;
+ self.bank_select = data[c];
+ self.prg_mode = data[c + 1] != 0;
+ self.chr_mode = data[c + 2] != 0;
+ self.irq_counter = data[c + 3];
+ self.irq_latch = data[c + 4];
+ self.irq_reload = data[c + 5] != 0;
+ self.irq_enabled = data[c + 6] != 0;
+ self.irq_pending = data[c + 7] != 0;
+ self.last_a12 = data[c + 8] != 0;
+ c += 9;
+ self.ex_regs.copy_from_slice(&data[c..c + 4]);
+ c += 4;
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 268 (COOLBOY / MINDKIDS MMC3-clone multicart).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG/CHR size.
+pub fn new_m268(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Coolboy::new(prg_rom, chr_rom, mirroring)
+}
+
+// ===========================================================================
+// Sachen9602 (mapper 513) — Sachen 9602 MMC3-clone.
+//
+// A plain MMC3 core with a PRG-A19/A20 outer bank from the high two bits of
+// $8001 (captured when the selected register is < 6), forced into the top of
+// the address space. CHR is RAM. Ported from Mesen2 Sachen/Sachen9602.h.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn coolboy_outer_regs_and_irq() {
+ let mut m = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6000, 0x01); // ex_reg0
+ m.cpu_write(0x8000, 0x06); // R6
+ m.cpu_write(0x8001, 3);
+ let v = m.cpu_read(0x8000);
+ assert!((v as usize) < 64); // in range, no panic.
+
+ m.cpu_write(0xC000, 1);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ m.notify_a12(false);
+ m.notify_a12(true);
+ m.notify_a12(false);
+ m.notify_a12(true);
+ assert!(m.irq_pending());
+ }
+
+ #[test]
+ fn coolboy_save_state_round_trip() {
+ let mut m = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Horizontal).unwrap();
+ m.cpu_write(0x6000, 0x05);
+ m.cpu_write(0x6001, 0x02);
+ m.cpu_write(0x8000, 0x06);
+ m.cpu_write(0x8001, 4);
+ m.ppu_write(0x2005, 0x3C);
+ let blob = m.save_state();
+ let mut m2 = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Horizontal).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x2005), 0x3C);
+ }
+}
diff --git a/crates/rustynes-mappers/src/m513_sachen_9602.rs b/crates/rustynes-mappers/src/m513_sachen_9602.rs
new file mode 100644
index 00000000..cbfdc193
--- /dev/null
+++ b/crates/rustynes-mappers/src/m513_sachen_9602.rs
@@ -0,0 +1,383 @@
+//! Sachen `9602` (mapper 513).
+//!
+//! An MMC3-derived Sachen ASIC with an outer PRG bank register, later and
+//! more capable than the 8259 family in `sachen_8259.rs` and the discrete
+//! boards in `sachen_discrete.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// Sachen 9602 MMC3-clone (mapper 513).
+pub struct Sachen9602 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ prg_count_8k: usize,
+ regs: [u8; 8],
+ bank_select: u8,
+ prg_mode: bool,
+ chr_mode: bool,
+ irq_counter: u8,
+ irq_latch: u8,
+ irq_reload: bool,
+ irq_enabled: bool,
+ irq_pending: bool,
+ last_a12: bool,
+ /// PRG outer bank (high two bits, << 6).
+ outer: u8,
+}
+
+impl Sachen9602 {
+ const SAVE_LEN: usize = 8 + 9 + 1 + 1;
+
+ fn new(prg_rom: Box<[u8]>, mirroring: Mirroring) -> Result {
+ check_prg(&prg_rom, 513)?;
+ let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
+ Ok(Self {
+ prg_rom,
+ chr: vec![0u8; CHR_BANK_8K].into_boxed_slice(), // 8 KiB CHR-RAM.
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg_count_8k,
+ regs: [0; 8],
+ bank_select: 0,
+ prg_mode: false,
+ chr_mode: false,
+ irq_counter: 0,
+ irq_latch: 0,
+ irq_reload: false,
+ irq_enabled: false,
+ irq_pending: false,
+ last_a12: false,
+ outer: 0,
+ })
+ }
+
+ fn resolve_prg(&self, slot: usize) -> usize {
+ let outer = (self.outer as usize) << 6;
+ // m9602 fixes the two top banks to $3E/$3F (within the outer bank).
+ let bank = match (slot, self.prg_mode) {
+ (1, _) => (self.regs[7] as usize & 0x3F) | outer,
+ (0, false) => (self.regs[6] as usize & 0x3F) | outer,
+ (0, true) => 0x3E | outer,
+ (2, false) => 0x3E | outer,
+ (2, true) => (self.regs[6] as usize & 0x3F) | outer,
+ (3, _) => 0x3F | outer,
+ _ => 0,
+ };
+ bank % self.prg_count_8k
+ }
+
+ fn write_mmc3(&mut self, addr: u16, value: u8) {
+ match addr & 0xE001 {
+ 0x8000 => {
+ self.bank_select = value & 0x07;
+ self.prg_mode = value & 0x40 != 0;
+ self.chr_mode = value & 0x80 != 0;
+ }
+ 0x8001 => {
+ if (self.bank_select & 0x07) < 6 {
+ self.outer = value >> 6;
+ }
+ let idx = (self.bank_select & 0x07) as usize;
+ self.regs[idx] = value & 0x3F;
+ }
+ 0xA000 => {
+ self.mirroring = if value & 0x01 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ 0xC000 => self.irq_latch = value,
+ 0xC001 => {
+ self.irq_counter = 0;
+ self.irq_reload = true;
+ }
+ 0xE000 => {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ 0xE001 => self.irq_enabled = true,
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Sachen9602 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: false,
+ audio: false,
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0x9FFF => {
+ let b = self.resolve_prg(0);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xA000..=0xBFFF => {
+ let b = self.resolve_prg(1);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xC000..=0xDFFF => {
+ let b = self.resolve_prg(2);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xE000..=0xFFFF => {
+ let b = self.resolve_prg(3);
+ self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.write_mmc3(addr, value);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr[addr as usize & (CHR_BANK_8K - 1)] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ let rising = level && !self.last_a12;
+ self.last_a12 = level;
+ if !rising {
+ return;
+ }
+ if self.irq_counter == 0 || self.irq_reload {
+ self.irq_counter = self.irq_latch;
+ self.irq_reload = false;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_sub(1);
+ }
+ if self.irq_counter == 0 && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + self.chr.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.regs);
+ out.push(self.bank_select);
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.chr_mode));
+ out.push(self.irq_counter);
+ out.push(self.irq_latch);
+ out.push(u8::from(self.irq_reload));
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.push(u8::from(self.last_a12));
+ out.push(self.outer);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.chr);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 1 + Self::SAVE_LEN + self.vram.len() + self.chr.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let mut c = 1;
+ self.regs.copy_from_slice(&data[c..c + 8]);
+ c += 8;
+ self.bank_select = data[c];
+ self.prg_mode = data[c + 1] != 0;
+ self.chr_mode = data[c + 2] != 0;
+ self.irq_counter = data[c + 3];
+ self.irq_latch = data[c + 4];
+ self.irq_reload = data[c + 5] != 0;
+ self.irq_enabled = data[c + 6] != 0;
+ self.irq_pending = data[c + 7] != 0;
+ self.last_a12 = data[c + 8] != 0;
+ c += 9;
+ self.outer = data[c];
+ self.mirroring = byte_to_mirroring(data[c + 1], self.mirroring);
+ c += 2;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 513 (Sachen 9602 MMC3-clone).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+// The `_chr_rom: Box<[u8]>` keeps the factory signature uniform with the
+// dispatch site even though the 9602 is always CHR-RAM.
+#[allow(clippy::boxed_local)]
+pub fn new_m513(
+ prg_rom: Box<[u8]>,
+ _chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Sachen9602::new(prg_rom, mirroring)
+}
+
+// ===========================================================================
+// TxcChip — the TXC protection accumulator (shared by Sachen 3011 / m136).
+// Ported from Mesen2 Txc/TxcChip.h (the non-JV001 variant, mask 0x07).
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn sachen9602_prg_outer_bank() {
+ let mut m = new_m513(synth_prg_8k(128), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x06); // select R6 (< 6 is false; 6 captures? <6 only)
+ m.cpu_write(0x8001, 0xC5); // value>>6 = 3 only if reg<6; R6 is not <6.
+ // Use R0 (<6) to set the outer bank.
+ m.cpu_write(0x8000, 0x00);
+ m.cpu_write(0x8001, 0xC0); // outer = 3 -> <<6 = 192
+ let v = m.cpu_read(0x8000);
+ assert!((v as usize) < 128);
+ }
+
+ #[test]
+ fn sachen9602_save_state_round_trip() {
+ let mut m = new_m513(synth_prg_8k(64), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x00);
+ m.cpu_write(0x8001, 0x45);
+ m.ppu_write(0x0001, 0x77);
+ let blob = m.save_state();
+ let mut m2 = new_m513(synth_prg_8k(64), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0001), 0x77);
+ }
+}
diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs
index 7304b7f2..eb7a933f 100644
--- a/crates/rustynes-mappers/src/mapper.rs
+++ b/crates/rustynes-mappers/src/mapper.rs
@@ -470,7 +470,21 @@ pub trait Mapper: Send {
/// Return one signed audio sample for mappers with on-cart audio
/// (VRC6/7, MMC5, Sunsoft 5B, Namco 163, FDS). Default returns silence.
- fn mix_audio(&mut self) -> i16 {
+ ///
+ /// **`i32`, widened from `i16` in v2.2.3 (A1).** The bus scales this by
+ /// `/ 65536.0` into roughly the same `[-0.5, 0.5]` range as the APU mixer's
+ /// own output, so the old `i16` return capped a chip's representable level
+ /// at `32767 / 65536 ≈ 0.5`. That was fine for every board except the
+ /// Sunsoft 5B, whose logarithmic DAC needs ~3.6x the 2A03 pulse at full
+ /// volume — and three simultaneous full-volume tones (Gimmick!, Hebereke)
+ /// several times more. The 5B's absolute level was therefore a documented,
+ /// deliberately un-calibrated gap purely because the return type could not
+ /// hold it. Widening the type is what unblocks it; see
+ /// `docs/accuracy-ledger.md` §Expansion-audio levels and `SUNSOFT5B_MIX_SCALE`.
+ ///
+ /// Boards other than the 5B return exactly the values they always did —
+ /// the widening is representational only and changes no mixed output.
+ fn mix_audio(&mut self) -> i32 {
0
}
diff --git a/crates/rustynes-mappers/src/mmc3_clones.rs b/crates/rustynes-mappers/src/mmc3_clones.rs
new file mode 100644
index 00000000..dbcf3604
--- /dev/null
+++ b/crates/rustynes-mappers/src/mmc3_clones.rs
@@ -0,0 +1,880 @@
+//! MMC3-clone ASICs: mappers 44, 49, 52, 115, 134, 189, 205, 238, 245, 348,
+//! 366 and relatives.
+//!
+//! Unlicensed manufacturers cloned the MMC3 more than any other Nintendo
+//! ASIC, because it was the cheapest way to run existing MMC3 games off a
+//! multicart. The clones keep the MMC3 register protocol and its A12-driven
+//! scanline IRQ counter *exactly*, and add an outer bank register that
+//! selects which 128/256/512 KiB "cartridge" the inner MMC3 sees.
+//!
+//! That is why this is one implementation with a board discriminant
+//! ([`CloneBoard`]) rather than eleven: the shared [`Mmc3Clone`] core carries
+//! the real MMC3 behaviour, and each board contributes only its outer-register
+//! decode. Getting the IRQ timing right once benefits all of them.
+//!
+//! The genuine Nintendo MMC3 is in `m004_mmc3.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
+//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
+//! every bank select wraps with `% count`, so a register write can never index
+//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
+//! afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::match_same_arms,
+ clippy::doc_markdown,
+ clippy::similar_names,
+ clippy::too_many_lines,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::bool_to_int_with_if
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+// ===========================================================================
+// Mmc3Clone — reusable MMC3-style core for the clone boards.
+//
+// The MMC3 register protocol (NES 2.0 mapper 4):
+// $8000 even : bank-select (low 3 bits = R index, bit 6 = PRG mode,
+// bit 7 = CHR mode).
+// $8001 odd : bank-data (the value loaded into the selected R register).
+// $A000 even : mirroring (bit 0: 0 = vertical, 1 = horizontal).
+// $C000 even : IRQ latch (reload value).
+// $C001 odd : IRQ reload (force a reload on the next A12 rise).
+// $E000 even : IRQ disable + acknowledge.
+// $E001 odd : IRQ enable.
+//
+// The A12 IRQ counter clocks on every PPU A12 rising edge: if the counter is 0
+// or a reload is pending, it reloads from the latch; otherwise it decrements.
+// After the update, if the counter is 0 and IRQs are enabled, the IRQ asserts.
+// ===========================================================================
+
+/// A reusable MMC3-style banking + A12-IRQ core for the clone boards.
+struct Mmc3Clone {
+ regs: [u8; 8],
+ bank_select: u8,
+ prg_mode: bool,
+ chr_mode: bool,
+ mirroring: Mirroring,
+ irq_counter: u8,
+ irq_latch: u8,
+ irq_reload: bool,
+ irq_enabled: bool,
+ irq_pending: bool,
+ last_a12: bool,
+ prg_count_8k: usize,
+ chr_count_1k: usize,
+}
+
+impl Mmc3Clone {
+ const SAVE_LEN: usize = 8 + 10;
+
+ fn new(prg_count_8k: usize, chr_count_1k: usize, mirroring: Mirroring) -> Self {
+ Self {
+ regs: [0; 8],
+ bank_select: 0,
+ prg_mode: false,
+ chr_mode: false,
+ mirroring,
+ irq_counter: 0,
+ irq_latch: 0,
+ irq_reload: false,
+ irq_enabled: false,
+ irq_pending: false,
+ last_a12: false,
+ prg_count_8k: prg_count_8k.max(1),
+ chr_count_1k: chr_count_1k.max(1),
+ }
+ }
+
+ /// Handle a write to the `$8000-$FFFF` MMC3 register space.
+ fn write_register(&mut self, addr: u16, value: u8) {
+ match addr & 0xE001 {
+ 0x8000 => {
+ self.bank_select = value & 0x07;
+ self.prg_mode = value & 0x40 != 0;
+ self.chr_mode = value & 0x80 != 0;
+ }
+ 0x8001 => {
+ let idx = (self.bank_select & 0x07) as usize;
+ self.regs[idx] = value;
+ }
+ 0xA000 => {
+ self.mirroring = if value & 0x01 == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+ 0xC000 => self.irq_latch = value,
+ 0xC001 => {
+ self.irq_counter = 0;
+ self.irq_reload = true;
+ }
+ 0xE000 => {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ 0xE001 => self.irq_enabled = true,
+ _ => {}
+ }
+ }
+
+ /// The base 8 KiB PRG bank for CPU slot 0..=3 ($8000/$A000/$C000/$E000),
+ /// before any wrapper outer-bank transform. Mirrors the MMC3 PRG layout.
+ fn prg_bank(&self, slot: usize) -> usize {
+ let last = self.prg_count_8k - 1;
+ let second_last = last.saturating_sub(1);
+ let r6 = self.regs[6] as usize;
+ let r7 = self.regs[7] as usize;
+ match (slot, self.prg_mode) {
+ (0, false) => r6,
+ (0, true) => second_last,
+ (1, _) => r7,
+ (2, false) => second_last,
+ (2, true) => r6,
+ (3, _) => last,
+ _ => 0,
+ }
+ }
+
+ /// The base 1 KiB CHR bank for PPU 1 KiB slot 0..=7, before any wrapper
+ /// outer-bank transform. Mirrors the MMC3 CHR layout (2 KiB R0/R1 +
+ /// 1 KiB R2-R5, swapped by `chr_mode`).
+ fn chr_bank(&self, slot: usize) -> usize {
+ let banks: [usize; 8] = if self.chr_mode {
+ [
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ ]
+ } else {
+ [
+ self.regs[0] as usize & !1,
+ (self.regs[0] as usize & !1) | 1,
+ self.regs[1] as usize & !1,
+ (self.regs[1] as usize & !1) | 1,
+ self.regs[2] as usize,
+ self.regs[3] as usize,
+ self.regs[4] as usize,
+ self.regs[5] as usize,
+ ]
+ };
+ banks[slot & 0x07]
+ }
+
+ /// Clock the A12 IRQ counter on a PPU A12 transition.
+ fn notify_a12(&mut self, level: bool) {
+ let rising = level && !self.last_a12;
+ self.last_a12 = level;
+ if !rising {
+ return;
+ }
+ if self.irq_counter == 0 || self.irq_reload {
+ self.irq_counter = self.irq_latch;
+ self.irq_reload = false;
+ } else {
+ self.irq_counter = self.irq_counter.wrapping_sub(1);
+ }
+ if self.irq_counter == 0 && self.irq_enabled {
+ self.irq_pending = true;
+ }
+ }
+
+ fn save(&self, out: &mut Vec) {
+ out.extend_from_slice(&self.regs);
+ out.push(self.bank_select);
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.chr_mode));
+ out.push(mirroring_to_byte(self.mirroring));
+ out.push(self.irq_counter);
+ out.push(self.irq_latch);
+ out.push(u8::from(self.irq_reload));
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.push(u8::from(self.last_a12));
+ }
+
+ fn load(&mut self, data: &[u8]) {
+ self.regs.copy_from_slice(&data[0..8]);
+ self.bank_select = data[8];
+ self.prg_mode = data[9] != 0;
+ self.chr_mode = data[10] != 0;
+ self.mirroring = byte_to_mirroring(data[11], self.mirroring);
+ self.irq_counter = data[12];
+ self.irq_latch = data[13];
+ self.irq_reload = data[14] != 0;
+ self.irq_enabled = data[15] != 0;
+ self.irq_pending = data[16] != 0;
+ self.last_a12 = data[17] != 0;
+ }
+}
+
+/// Which clone board's outer-bank transform [`Mmc3CloneMapper`] applies.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CloneBoard {
+ /// Mapper 44 — 7-block selector via `$A001`.
+ M44,
+ /// Mapper 49 — `$6000` outer block-select with a simplified-PRG mode bit.
+ M49,
+ /// Mapper 52 — `$6000` outer-block / PRG+CHR-size selector.
+ M52,
+ /// Mapper 115 — `$5000`/`$4100` PRG-override + CHR outer-256K register.
+ M115,
+ /// Mapper 134 — `$6001` PRG (bit 1) + CHR (bit 5) 256 KiB outer bank.
+ M134,
+ /// Mapper 189 — `$4120-$7FFF` 32 KiB PRG select (overrides MMC3 PRG).
+ M189,
+ /// Mapper 205 — `$6000` 2-bit block-select (PRG/CHR outer window).
+ M205,
+ /// Mapper 238 — `$4020-$7FFF` security register (read-back LUT).
+ M238,
+ /// Mapper 245 — `$8001` R0 bit 1 -> PRG 256 KiB outer; CHR-RAM 4K/4K swap.
+ M245,
+ /// Mapper 348 — `$6800` outer-bank register (BMC-830118C).
+ M348,
+ /// Mapper 366 — `$6000-$7FFF` block-select (BMC-GN-45).
+ M366,
+}
+
+// ===========================================================================
+// Mmc3CloneMapper — wraps `Mmc3Clone` + a `CloneBoard` outer transform.
+// ===========================================================================
+
+/// An MMC3-clone board: the shared MMC3-style core plus a board-specific
+/// outer-bank register and PRG/CHR transform.
+pub struct Mmc3CloneMapper {
+ board: CloneBoard,
+ core: Mmc3Clone,
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ /// Board-specific outer register (semantics per `CloneBoard`).
+ outer: u8,
+ /// A second board register where needed (115 CHR-hi / protection read).
+ outer2: u8,
+}
+
+impl Mmc3CloneMapper {
+ fn new(
+ board: CloneBoard,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ mapper_id: u16,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {mapper_id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; 0x8000].into_boxed_slice() // 32 KiB CHR-RAM (245 needs >8K).
+ } else {
+ if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {mapper_id} CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ chr_rom
+ };
+ let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
+ let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
+ Ok(Self {
+ board,
+ core: Mmc3Clone::new(prg_count_8k, chr_count_1k, mirroring),
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ outer: 0,
+ outer2: 0,
+ })
+ }
+
+ /// Resolve the final 8 KiB PRG bank for a CPU slot after the board outer
+ /// transform.
+ fn resolve_prg(&self, slot: usize) -> usize {
+ let base = self.core.prg_bank(slot);
+ let count = self.core.prg_count_8k;
+ let bank = match self.board {
+ CloneBoard::M44 => {
+ let block = (self.outer & 0x07).min(6) as usize;
+ let mask = if block <= 5 { 0x0F } else { 0x1F };
+ (base & mask) | (block * 0x10)
+ }
+ CloneBoard::M49 => {
+ let block = ((self.outer >> 6) & 0x03) as usize;
+ if self.outer & 0x01 != 0 {
+ (base & 0x0F) | (block * 0x10)
+ } else {
+ ((self.outer >> 4) & 0x03) as usize * 4 + slot
+ }
+ }
+ CloneBoard::M52 => {
+ if self.outer & 0x08 != 0 {
+ (base & 0x0F) | ((self.outer as usize & 0x07) << 4)
+ } else {
+ (base & 0x1F) | ((self.outer as usize & 0x06) << 4)
+ }
+ }
+ CloneBoard::M115 => {
+ if self.outer & 0x80 != 0 {
+ if self.outer & 0x20 != 0 {
+ ((self.outer as usize & 0x0F) >> 1) * 4 + slot
+ } else {
+ let b16 = (self.outer as usize & 0x0F) * 2;
+ b16 + (slot & 0x01)
+ }
+ } else {
+ base
+ }
+ }
+ CloneBoard::M134 => (base & 0x1F) | ((self.outer as usize & 0x02) << 4),
+ CloneBoard::M189 => {
+ let page = ((self.outer as usize) | (self.outer as usize >> 4)) & 0x07;
+ page * 4 + slot
+ }
+ CloneBoard::M205 => {
+ let block = self.outer as usize & 0x03;
+ let mask = if block <= 1 { 0x1F } else { 0x0F };
+ (base & mask) | (block * 0x10)
+ }
+ CloneBoard::M238 => base,
+ CloneBoard::M245 => {
+ let or = if self.core.regs[0] & 0x02 != 0 {
+ 0x40
+ } else {
+ 0
+ };
+ (base & 0x3F) | or
+ }
+ CloneBoard::M348 => (base & 0x0F) | ((self.outer as usize & 0x0C) << 2),
+ CloneBoard::M366 => (base & 0x0F) | (self.outer as usize & 0x30),
+ };
+ bank % count
+ }
+
+ /// Resolve the final 1 KiB CHR bank for a PPU slot after the board outer
+ /// transform.
+ fn resolve_chr(&self, slot: usize) -> usize {
+ let base = self.core.chr_bank(slot);
+ let count = self.core.chr_count_1k;
+ let bank = match self.board {
+ CloneBoard::M44 => {
+ let block = (self.outer & 0x07).min(6) as usize;
+ let mask = if block <= 5 { 0x7F } else { 0xFF };
+ (base & mask) | (block * 0x80)
+ }
+ CloneBoard::M49 => {
+ let block = ((self.outer >> 6) & 0x03) as usize;
+ (base & 0x7F) | (block * 0x80)
+ }
+ CloneBoard::M52 => {
+ if self.outer & 0x40 != 0 {
+ (base & 0x7F)
+ | (((self.outer as usize & 0x04) | ((self.outer as usize >> 4) & 0x03))
+ << 7)
+ } else {
+ (base & 0xFF)
+ | (((self.outer as usize & 0x04) | ((self.outer as usize >> 4) & 0x02))
+ << 7)
+ }
+ }
+ CloneBoard::M115 => base | ((self.outer2 as usize & 0x01) << 8),
+ CloneBoard::M134 => (base & 0xFF) | ((self.outer as usize & 0x20) << 3),
+ CloneBoard::M189 => base,
+ CloneBoard::M205 => {
+ let block = self.outer as usize & 0x03;
+ if block >= 2 {
+ (base & 0x7F) | 0x100
+ } else {
+ base | if block == 1 { 0x80 } else { 0 }
+ }
+ }
+ CloneBoard::M238 => base,
+ CloneBoard::M245 => base, // CHR-RAM; handled in ppu_read.
+ CloneBoard::M348 => (base & 0x7F) | ((self.outer as usize & 0x0C) << 5),
+ CloneBoard::M366 => (base & 0x7F) | ((self.outer as usize & 0x30) << 3),
+ };
+ bank % count
+ }
+}
+
+impl Mapper for Mmc3CloneMapper {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps {
+ cpu_cycle_hook: false,
+ audio: false,
+ frame_event_hook: false,
+ irq_source: true,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0x9FFF => {
+ let bank = self.resolve_prg(0);
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xA000..=0xBFFF => {
+ let bank = self.resolve_prg(1);
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xC000..=0xDFFF => {
+ let bank = self.resolve_prg(2);
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0xE000..=0xFFFF => {
+ let bank = self.resolve_prg(3);
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ // 115 protection read-back at $5000-$5FFF.
+ 0x5000..=0x5FFF if matches!(self.board, CloneBoard::M115) => self.outer2,
+ // 238 security read-back at $4020-$7FFF.
+ 0x4020..=0x7FFF if matches!(self.board, CloneBoard::M238) => self.outer2,
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ match self.board {
+ CloneBoard::M115 => (0x4020..=0x4FFF).contains(&addr),
+ CloneBoard::M238 => false, // $4020-$7FFF is all mapped (security reg).
+ _ => (0x4020..=0x5FFF).contains(&addr),
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match self.board {
+ CloneBoard::M115 => match addr {
+ 0x5080 => self.outer2 = value,
+ 0x4100..=0x7FFF => {
+ if addr & 0x01 == 0 {
+ self.outer = value; // PRG override reg.
+ } else {
+ self.outer2 = value; // CHR-hi reg (bit 0 used).
+ }
+ }
+ 0x8000..=0xFFFF => self.core.write_register(addr, value),
+ _ => {}
+ },
+ CloneBoard::M134 => {
+ if addr == 0x6001 {
+ self.outer = value;
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M189 => {
+ if (0x4120..=0x7FFF).contains(&addr) {
+ self.outer = value;
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M238 => {
+ if (0x4020..=0x7FFF).contains(&addr) {
+ const LUT: [u8; 4] = [0x00, 0x02, 0x02, 0x03];
+ self.outer2 = LUT[(value & 0x03) as usize];
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M44 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ if addr & 0xE001 == 0xA001 {
+ self.outer = value & 0x07;
+ }
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M348 => {
+ if (0x6800..=0x68FF).contains(&addr) {
+ self.outer = value;
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M366 => {
+ if (0x6000..=0x7FFF).contains(&addr) {
+ self.outer = if addr < 0x7000 {
+ (addr as u8) & 0x30
+ } else {
+ value & 0x30
+ };
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M49 | CloneBoard::M52 | CloneBoard::M205 => {
+ if (0x6000..=0x7FFF).contains(&addr) {
+ self.outer = value;
+ } else if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ CloneBoard::M245 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.core.write_register(addr, value);
+ }
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 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);
+ self.chr[off] = value;
+ } else {
+ let off = addr as usize & (self.chr.len() - 1);
+ self.chr[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.core.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ self.core.notify_a12(level);
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.core.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.core.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.core.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(3 + Mmc3Clone::SAVE_LEN + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.outer);
+ out.push(self.outer2);
+ self.core.save(&mut out);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 3 + Mmc3Clone::SAVE_LEN + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.outer = data[1];
+ self.outer2 = data[2];
+ let mut cursor = 3;
+ self.core.load(&data[cursor..cursor + Mmc3Clone::SAVE_LEN]);
+ cursor += Mmc3Clone::SAVE_LEN;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+macro_rules! clone_ctor {
+ ($fn_name:ident, $board:expr, $id:expr, $doc:expr) => {
+ #[doc = $doc]
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG/CHR size.
+ pub fn $fn_name(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ Mmc3CloneMapper::new($board, prg_rom, chr_rom, mirroring, $id)
+ }
+ };
+}
+
+clone_ctor!(
+ new_m44,
+ CloneBoard::M44,
+ 44,
+ "Mapper 44 (BMC SuperBig 7-in-1 MMC3 multicart)."
+);
+clone_ctor!(
+ new_m49,
+ CloneBoard::M49,
+ 49,
+ "Mapper 49 (BMC 4-in-1 MMC3 multicart)."
+);
+clone_ctor!(
+ new_m52,
+ CloneBoard::M52,
+ 52,
+ "Mapper 52 (BMC Mario 7-in-1 MMC3 multicart)."
+);
+clone_ctor!(
+ new_m115,
+ CloneBoard::M115,
+ 115,
+ "Mapper 115 (Kasheng SFC-02B/-03/-004 MMC3 clone)."
+);
+clone_ctor!(
+ new_m134,
+ CloneBoard::M134,
+ 134,
+ "Mapper 134 (T4A54A / WX-KB4K MMC3-clone multicart)."
+);
+clone_ctor!(
+ new_m189,
+ CloneBoard::M189,
+ 189,
+ "Mapper 189 (TXC 32 KiB-PRG MMC3 clone)."
+);
+clone_ctor!(
+ new_m205,
+ CloneBoard::M205,
+ 205,
+ "Mapper 205 (BMC 3-in-1 / 15-in-1 MMC3 multicart)."
+);
+clone_ctor!(
+ new_m238,
+ CloneBoard::M238,
+ 238,
+ "Mapper 238 (MMC3 clone + $4020-$7FFF security LUT)."
+);
+clone_ctor!(
+ new_m245,
+ CloneBoard::M245,
+ 245,
+ "Mapper 245 (Waixing MMC3 clone, CHR-RAM PRG-256K outer)."
+);
+clone_ctor!(
+ new_m348,
+ CloneBoard::M348,
+ 348,
+ "Mapper 348 (BMC-830118C MMC3 multicart)."
+);
+clone_ctor!(
+ new_m366,
+ CloneBoard::M366,
+ 366,
+ "Mapper 366 (BMC-GN-45 MMC3 multicart)."
+);
+
+// ===========================================================================
+// Sachen 8259 (A/B/C) — the 2 KiB-CHR variants of the protection ASIC.
+//
+// $4100 (addr & 0xC101 == 0x4100) : command — selects internal reg 0..=7.
+// $4101 (addr & 0xC101 == 0x4101) : data — writes the selected reg (& 0x07).
+// 32 KiB fixed PRG ($8000), four 2 KiB CHR banks. The variants differ only by a
+// CHR left-shift and three per-slot OR constants:
+// 8259A: shift 1, chrOr [1,0,1] (mapper 141)
+// 8259B: shift 0, chrOr [0,0,0] (mapper 138)
+// 8259C: shift 2, chrOr [1,2,3] (mapper 139)
+// reg7 bits 1-2 select mirroring (reg7 bit 0 = "simple mode" override).
+// reg5 selects the 32 KiB PRG bank; reg4 supplies the CHR high bits.
+// Ported from Mesen2 Sachen/Sachen8259.h.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn mmc3_clone_prg_layout_and_a12_irq() {
+ let mut m = new_m245(synth_prg_8k(16), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x06); // bank-select R6
+ m.cpu_write(0x8001, 5);
+ m.cpu_write(0x8000, 0x07); // bank-select R7
+ m.cpu_write(0x8001, 6);
+ assert_eq!(m.cpu_read(0x8000), 5); // R6 @ $8000
+ assert_eq!(m.cpu_read(0xA000), 6); // R7 @ $A000
+ assert_eq!(m.cpu_read(0xE000), 15); // last @ $E000
+
+ m.cpu_write(0xC000, 2); // latch
+ m.cpu_write(0xC001, 0); // reload
+ m.cpu_write(0xE001, 0); // enable
+ assert!(!m.irq_pending());
+ for _ in 0..3 {
+ m.notify_a12(false);
+ m.notify_a12(true);
+ }
+ assert!(m.irq_pending());
+ m.cpu_write(0xE000, 0); // disable + ack
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn m245_prg_outer_bank_from_reg0_bit1() {
+ let mut m = new_m245(synth_prg_8k(128), Box::new([]), Mirroring::Vertical).unwrap();
+ // m245: the PRG-A18 outer bit is R0 bit 1 (select reg 0, write value).
+ m.cpu_write(0x8000, 0x00); // select R0
+ m.cpu_write(0x8001, 0x02); // R0 bit 1 set -> PRG OR 0x40
+ m.cpu_write(0x8000, 0x06); // select R6
+ m.cpu_write(0x8001, 5); // R6 = 5
+ // R6 base 5 -> (5 & 0x3F) | 0x40 = 69.
+ assert_eq!(m.cpu_read(0x8000), 69);
+ }
+
+ #[test]
+ fn m115_chr_outer_and_protection_read() {
+ let mut m = new_m115(synth_prg_8k(32), synth_chr_1k(512), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4101, 0x01); // CHR-hi reg bit 0 -> +0x100.
+ assert_eq!(m.ppu_read(0x0000), 0); // bank 256 % 512 -> stored index 0.
+ m.cpu_write(0x5080, 0xAB);
+ assert_eq!(m.cpu_read(0x5000), 0xAB);
+ }
+
+ #[test]
+ fn m189_prg_32k_select_overrides_mmc3() {
+ let mut m = new_m189(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4120, 0x33); // (3|3) = 3 -> page 3 -> bank 12.
+ assert_eq!(m.cpu_read(0x8000), 12);
+ assert_eq!(m.cpu_read(0xA000), 13);
+ }
+
+ #[test]
+ fn mmc3_clone_save_state_round_trip() {
+ let mut m = new_m115(synth_prg_8k(32), synth_chr_1k(256), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x06);
+ m.cpu_write(0x8001, 4);
+ m.cpu_write(0x4101, 0x01);
+ m.cpu_write(0xC000, 7);
+ m.ppu_write(0x2005, 0x5A);
+ let blob = m.save_state();
+ let mut m2 = new_m115(synth_prg_8k(32), synth_chr_1k(256), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x2005), 0x5A);
+ }
+
+ #[test]
+ fn m245_chr_ram_round_trip() {
+ let mut m = new_m245(synth_prg_8k(16), Box::new([]), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x0010, 0x42);
+ let blob = m.save_state();
+ let mut m2 = new_m245(synth_prg_8k(16), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0010), 0x42);
+ }
+}
diff --git a/crates/rustynes-mappers/src/multicart_discrete.rs b/crates/rustynes-mappers/src/multicart_discrete.rs
new file mode 100644
index 00000000..6f2658ba
--- /dev/null
+++ b/crates/rustynes-mappers/src/multicart_discrete.rs
@@ -0,0 +1,4661 @@
+//! Discrete-logic multicart boards addressed by their iNES mapper number:
+//! K-1029 / Contra Function 16 (mapper 15), and the 20-in-1 / Super 700-in-1
+//! style boards on mappers 61 and 62.
+//!
+//! What these share is that the *address written to* carries as much
+//! information as the byte written. Mapper 15 selects among four PRG banking
+//! modes from the low two address bits; mapper 61 picks 16 KiB-vs-32 KiB mode
+//! the same way; mapper 62 splits a CHR bank field across the address and the
+//! data. That is cheaper in discrete logic than decoding a wide register, and
+//! it is why these decode paths look address-driven rather than value-driven.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+// ===========================================================================
+// Mapper 15 — K-1029 / 100-in-1 Contra Function 16.
+//
+// Single register decoded across $8000-$FFFF (data + low two address bits):
+// addr bits 0-1 select the banking MODE; data holds the PRG bank, a CHR-RAM
+// mirroring bit (bit 6) and a "half-bank" bit (bit 7).
+// mode 0: 32 KiB at the 16 KiB granularity, second half = bank|1
+// mode 1: 128 KiB? upper half forced to bank|7 (UNROM-like fixed top)
+// mode 2: 8 KiB-granular ((bank<<1)|b) mirrored across the whole window
+// mode 3: single 16 KiB bank mirrored across the whole window
+// CHR is always 8 KiB RAM; CHR writes are protected in modes 0 and 3.
+// mirroring: data bit 6 (1 = horizontal, 0 = vertical). No IRQ.
+// ===========================================================================
+
+const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// Allocate the CHR slice, falling back to an 8 KiB CHR-RAM bank when the ROM
+/// ships no CHR-ROM. Returns `(chr, is_ram)`.
+fn chr_or_ram(chr_rom: Box<[u8]>) -> (Box<[u8]>, bool) {
+ if chr_rom.is_empty() {
+ (vec![0u8; CHR_BANK_8K].into_boxed_slice(), true)
+ } else {
+ (chr_rom, false)
+ }
+}
+
+const fn mirroring_to_byte(m: Mirroring) -> u8 {
+ match m {
+ Mirroring::Horizontal => 0,
+ Mirroring::Vertical => 1,
+ Mirroring::SingleScreenA => 2,
+ Mirroring::SingleScreenB => 3,
+ Mirroring::FourScreen => 4,
+ Mirroring::MapperControlled => 5,
+ }
+}
+
+/// Mapper 15 (K-1029 / 100-in-1 Contra Function 16 multicart).
+pub struct Multicart15 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ mode: u8,
+ prg_bank: u8,
+ half: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart15 {
+ /// Construct a new mapper 15 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB. CHR is always 8 KiB RAM (any supplied CHR-ROM is rejected).
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 15 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.is_empty() {
+ return Err(MapperError::Invalid(format!(
+ "mapper 15 uses 8 KiB CHR-RAM; got {} bytes of CHR-ROM",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mode: 0,
+ prg_bank: 0,
+ half: 0,
+ horizontal_mirroring: false,
+ })
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn read_16k(&self, bank: usize, off: usize) -> u8 {
+ let bank = bank % self.prg_count_16k();
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ }
+
+ fn read_8k(&self, bank: usize, off: usize) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + off]
+ }
+}
+
+impl Mapper for Multicart15 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return 0;
+ }
+ let win = (addr as usize) - 0x8000; // 0..0x8000 (the visible 32 KiB)
+ let bank = self.prg_bank as usize;
+ match self.mode {
+ 0 => {
+ if win < PRG_BANK_16K {
+ self.read_16k(bank, win)
+ } else {
+ self.read_16k(bank | 1, win - PRG_BANK_16K)
+ }
+ }
+ 1 => {
+ if win < PRG_BANK_16K {
+ self.read_16k(bank, win)
+ } else {
+ self.read_16k(bank | 7, win - PRG_BANK_16K)
+ }
+ }
+ 2 => {
+ // 8 KiB-granular, mirrored across the whole window.
+ let off = win & (PRG_BANK_8K - 1);
+ self.read_8k((bank << 1) | (self.half as usize), off)
+ }
+ // mode 3: a single 16 KiB bank mirrored across the window.
+ _ => {
+ let off = win & (PRG_BANK_16K - 1);
+ self.read_16k(bank, off)
+ }
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.mode = (addr & 0x03) as u8;
+ self.horizontal_mirroring = (value & 0x40) != 0;
+ self.prg_bank = value & 0x3F;
+ self.half = u8::from((value & 0x80) != 0);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ // CHR-RAM write-protected in modes 0 and 3.
+ if self.mode != 0 && self.mode != 3 {
+ self.chr_ram[addr as usize] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(5 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.mode);
+ out.push(self.prg_bank);
+ out.push(self.half);
+ out.push(u8::from(self.horizontal_mirroring));
+ 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 = 5 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.mode = data[1];
+ self.prg_bank = data[2];
+ self.half = data[3];
+ self.horizontal_mirroring = data[4] != 0;
+ let mut cursor = 5;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 61 (0x80-style multicart).
+pub struct Multicart61 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_page: u8,
+ prg_16k_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart61 {
+ /// Construct a new mapper 61 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB. CHR is always 8 KiB RAM (any supplied CHR-ROM is rejected).
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 61 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.is_empty() {
+ return Err(MapperError::Invalid(format!(
+ "mapper 61 uses 8 KiB CHR-RAM; got {} bytes of CHR-ROM",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_page: 0,
+ prg_16k_mode: false,
+ horizontal_mirroring: false,
+ })
+ }
+}
+
+impl Mapper for Multicart61 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return 0;
+ }
+ let win = (addr as usize) - 0x8000;
+ if self.prg_16k_mode {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_page as usize) % count;
+ let off = win & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ } else {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = ((self.prg_page >> 1) as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + win]
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let lo = (addr & 0x0F) as u8;
+ let hi = ((addr >> 5) & 0x01) as u8;
+ self.prg_page = (lo << 1) | hi;
+ self.prg_16k_mode = (addr & 0x10) != 0;
+ self.horizontal_mirroring = (addr & 0x80) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_page);
+ out.push(u8::from(self.prg_16k_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ 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 = 4 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_page = data[1];
+ self.prg_16k_mode = data[2] != 0;
+ self.horizontal_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 62 — multicart.
+//
+// Both the CPU address and the written byte feed the register ($8000-$FFFF).
+// With `A = addr` and `D = data`:
+// prg_page = ((A & 0x3F00) >> 8) | (A & 0x40)
+// chr_bank (4-bit?) = ((A & 0x1F) << 2) | (D & 0x03)
+// prg_16k_mode = (A & 0x20) != 0
+// horizontal_mirror = (A & 0x80) != 0
+// In 16 KiB mode the 16 KiB bank `prg_page` is mirrored across the window; in
+// 32 KiB mode bank `prg_page >> 1` is used. CHR is 8 KiB ROM banked. No IRQ.
+// ===========================================================================
+
+/// Mapper 62 (multicart).
+pub struct Multicart62 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_page: u8,
+ chr_bank: u8,
+ prg_16k_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart62 {
+ /// Construct a new mapper 62 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 62 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 62 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_page: 0,
+ chr_bank: 0,
+ // Seed from the header arrangement so the power-on default is sane.
+ prg_16k_mode: false,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+}
+
+impl Mapper for Multicart62 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return 0;
+ }
+ let win = (addr as usize) - 0x8000;
+ if self.prg_16k_mode {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_page as usize) % count;
+ let off = win & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ } else {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = ((self.prg_page >> 1) as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + win]
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let page_lo = ((addr & 0x3F00) >> 8) as u8;
+ let page_hi = (addr & 0x40) as u8;
+ self.prg_page = page_lo | page_hi;
+ let chr_hi = ((addr & 0x1F) as u8) << 2;
+ self.chr_bank = chr_hi | (value & 0x03);
+ self.prg_16k_mode = (addr & 0x20) != 0;
+ self.horizontal_mirroring = (addr & 0x80) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(5 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_page);
+ out.push(self.chr_bank);
+ out.push(u8::from(self.prg_16k_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 5 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_page = data[1];
+ self.chr_bank = data[2];
+ self.prg_16k_mode = data[3] != 0;
+ self.horizontal_mirroring = data[4] != 0;
+ self.vram.copy_from_slice(&data[5..5 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 200 (`MG109` NROM-128 multicart).
+pub struct Multicart200 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart200 {
+ /// Construct a new mapper 200 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 200 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 200 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ bank: 0,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+}
+
+impl Mapper for Multicart200 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // NROM-128: $8000-$BFFF mirrors $C000-$FFFF.
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.bank as usize) % count;
+ let off = (addr as usize - 0x8000) & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.bank = (addr & 0x07) as u8;
+ self.horizontal_mirroring = (addr & 0x08) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ 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] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.bank = data[1];
+ self.horizontal_mirroring = data[2] != 0;
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 201 — NROM-256 multicart (BNROM + CNROM overlaid, address-driven).
+//
+// Write $8000-$FFFF: the ADDRESS low byte selects one bank that drives both a
+// 32 KiB PRG bank and an 8 KiB CHR bank:
+// PRG (32 KiB) = addr & 0x03 (masked to PRG bank count)
+// CHR (8 KiB) = addr & 0x07 (masked to CHR bank count)
+// (All known games use only the low 2 bits.) Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 201 (NROM-256 multicart).
+pub struct Multicart201 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Multicart201 {
+ /// Construct a new mapper 201 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 201 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 201 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart201 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = (addr & 0x03) as u8;
+ self.chr_bank = (addr & 0x07) as u8;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 202 — 150-in-1 multicart (address latch).
+//
+// Write $8000-$FFFF, ADDRESS-driven (data ignored):
+// A~[.... .... .... O..O] (PRG mode bits, combine to form a 2-bit value)
+// A~[.... .... .... RRRM] (R = page register, M = mirroring)
+// prg_mode_is_32k = (((addr >> 1) & 0x01) == 1 && (addr & 0x01) == 1)
+// i.e. the two "O" bits (addr bit 3 and addr bit 0) == 0b11
+// page = (addr >> 1) & 0x07
+// mirroring = addr & 0x01 (0: vertical, 1: horizontal)
+// In 16 KiB mode the page maps both halves; in 32 KiB mode (page>>1) selects a
+// 32 KiB bank. CHR (8 KiB) = page. Mirroring runtime; no IRQ.
+//
+// Per the nesdev wiki the "O" bits are addr bit 3 and addr bit 0; if both set,
+// 32 KiB mode. We follow the BizHawk/Disch convention used in the wiki note.
+// ===========================================================================
+
+/// Mapper 202 (150-in-1 multicart).
+pub struct Multicart202 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ page: u8,
+ prg_32k_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart202 {
+ /// Construct a new mapper 202 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 202 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 202 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ page: 0,
+ prg_32k_mode: false,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.page as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart202 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ if self.prg_32k_mode {
+ // 32 KiB bank (page >> 1) spread across the whole window.
+ let lo16 = ((self.page >> 1) << 1) as usize % count;
+ let off = addr as usize - 0x8000;
+ self.prg_rom[lo16 * PRG_BANK_16K + off]
+ } else {
+ // 16 KiB mode: same page mirrored at $8000 and $C000.
+ let bank = (self.page as usize) % count;
+ let off = (addr as usize - 0x8000) & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ }
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // "O" bits = addr bit 3 and addr bit 0; both set => 32 KiB mode.
+ self.prg_32k_mode = (addr & 0x09) == 0x09;
+ self.page = ((addr >> 1) & 0x07) as u8;
+ self.horizontal_mirroring = (addr & 0x01) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(4 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.page);
+ out.push(u8::from(self.prg_32k_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 4 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.page = data[1];
+ self.prg_32k_mode = data[2] != 0;
+ self.horizontal_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 203 — 35-in-1 multicart (data latch).
+//
+// Write $8000-$FFFF, DATA-driven:
+// PPPP PPCC
+// PRG (16 KiB, mirrored at $8000 and $C000) = (data >> 2) & 0x3F
+// CHR (8 KiB) = data & 0x03
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 203 (35-in-1 multicart).
+pub struct Multicart203 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Multicart203 {
+ /// Construct a new mapper 203 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 203 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 203 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart203 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // NROM-128: $8000-$BFFF mirrors $C000-$FFFF.
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ let off = (addr as usize - 0x8000) & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = (value >> 2) & 0x3F;
+ self.chr_bank = value & 0x03;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 212 — BMC Super HiK 300-in-1 (address latch).
+//
+// Write $8000-$FFFF, ADDRESS-driven (data ignored):
+// A~[1o.. .... .... MBBb]
+// prg_32k_mode = bit 14 of addr ("o")
+// page (3-bit) = addr & 0x07 (drives 16 KiB PRG, 32 KiB PRG and 8 KiB CHR)
+// mirroring = bit 3 of addr (0: vertical, 1: horizontal)
+// 16 KiB mode: page maps both $8000 and $C000 windows
+// 32 KiB mode: (page >> 1) selects a 32 KiB bank
+// CHR (8 KiB) = page (regardless of "o")
+// Reads at $6000-$7FFF with (addr & 0x10) == 0 return bit 7 set (a protection
+// signature). Mirroring runtime; no IRQ.
+// ===========================================================================
+
+/// Mapper 212 (`BMC` Super `HiK` 300-in-1 multicart).
+pub struct Multicart212 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ page: u8,
+ prg_32k_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart212 {
+ /// Construct a new mapper 212 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 212 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 212 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ page: 0,
+ prg_32k_mode: false,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.page as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart212 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // $6000-$7FFF carries the protection signature (mapped). $4020-$5FFF
+ // is unmapped open bus, as for stock boards.
+ (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => {
+ // Protection: (addr & 0x10) == 0 reads $80; else open-bus-ish 0.
+ if (addr & 0x0010) == 0 { 0x80 } else { 0x00 }
+ }
+ 0x8000..=0xFFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ if self.prg_32k_mode {
+ let lo16 = ((self.page >> 1) << 1) as usize % count;
+ let off = addr as usize - 0x8000;
+ self.prg_rom[lo16 * PRG_BANK_16K + off]
+ } else {
+ let bank = (self.page as usize) % count;
+ let off = (addr as usize - 0x8000) & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ }
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_32k_mode = (addr & 0x4000) != 0;
+ self.page = (addr & 0x07) as u8;
+ self.horizontal_mirroring = (addr & 0x0008) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(4 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.page);
+ out.push(u8::from(self.prg_32k_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 4 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.page = data[1];
+ self.prg_32k_mode = data[2] != 0;
+ self.horizontal_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 213 — 9999999-in-1 multicart (address latch; duplicate of 58).
+//
+// Write $8000-$FFFF, ADDRESS-driven (data ignored):
+// CHR (8 KiB) = (addr >> 3) & 0x07
+// PRG (32 KiB) = (addr >> 1) & 0x03
+// NROM-256-style mirroring (header-fixed). No IRQ.
+// ===========================================================================
+
+/// Mapper 213 (9999999-in-1 multicart).
+pub struct Multicart213 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Multicart213 {
+ /// Construct a new mapper 213 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 213 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 213 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart213 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.chr_bank = ((addr >> 3) & 0x07) as u8;
+ self.prg_bank = ((addr >> 1) & 0x03) as u8;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 214 — Super Gun 20-in-1 multicart (address latch).
+//
+// Write $8000-$FFFF, ADDRESS-driven (data ignored):
+// CHR (8 KiB) = addr & 0x03
+// PRG (16 KiB, mirrored at $8000 and $C000) = (addr >> 2) & 0x03
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 214 (Super Gun 20-in-1 multicart).
+pub struct Multicart214 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Multicart214 {
+ /// Construct a new mapper 214 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB, or CHR-ROM (when present) is not a multiple of 8 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 214 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 214 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Multicart214 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // NROM-128: $8000-$BFFF mirrors $C000-$FFFF.
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ let off = (addr as usize - 0x8000) & (PRG_BANK_16K - 1);
+ self.prg_rom[bank * PRG_BANK_16K + off]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.chr_bank = (addr & 0x03) as u8;
+ self.prg_bank = ((addr >> 2) & 0x03) as u8;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 58 (multicart).
+pub struct Multicart58 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ prg32_mode: bool,
+ chr_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart58 {
+ /// Construct a new mapper 58 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 58 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 58 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ let horizontal_mirroring = mirroring == Mirroring::Horizontal;
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ prg32_mode: false,
+ chr_bank: 0,
+ horizontal_mirroring,
+ })
+ }
+}
+
+impl Mapper for Multicart58 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return 0;
+ }
+ if self.prg32_mode {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (((self.prg_bank & 0x06) >> 1) as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = (addr & 0x07) as u8;
+ self.prg32_mode = (addr & 0x40) == 0;
+ self.chr_bank = ((addr >> 3) & 0x07) as u8;
+ self.horizontal_mirroring = (addr & 0x80) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(5 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.prg32_mode));
+ out.push(self.chr_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 5 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.prg32_mode = data[2] != 0;
+ self.chr_bank = data[3];
+ self.horizontal_mirroring = data[4] != 0;
+ self.vram.copy_from_slice(&data[5..5 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 60 — reset-based 4-in-1 multicart.
+//
+// The active game is chosen by a reset counter (0..=3) that increments on each
+// console reset; the selected value drives a 16 KiB PRG bank + 8 KiB CHR bank
+// in lockstep. Reset-latch behaviour is host-driven (the bus/frontend would
+// have to pulse a reset hook we do not model in the no_std core), so we model
+// only the power-on bank (counter = 0). No IRQ.
+// ===========================================================================
+
+/// Mapper 60 (reset-based 4-in-1 multicart; power-on bank only).
+pub struct Multicart60 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Multicart60 {
+ /// Construct a new mapper 60 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 60 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 60 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ // Power-on selects game 0.
+ bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Multicart60 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // The reset counter drives a 16 KiB bank mirrored across the 32 KiB
+ // window (NROM-128-per-game).
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (self.bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, _addr: u16, _value: u8) {}
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 2 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.bank = data[1];
+ self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 231 — 20-in-1 multicart.
+//
+// Address-decoded register across $8000-$FFFF (data byte ignored). For the
+// absolute address A:
+// prgBank = ((A >> 5) & 0x01) | (A & 0x1E)
+// $8000 16 KiB bank = prgBank & 0x1E
+// $C000 16 KiB bank = prgBank
+// mirroring = bit 7 of A (1 = horizontal, 0 = vertical)
+// CHR is 8 KiB RAM. No IRQ.
+// ===========================================================================
+
+/// Mapper 231 (20-in-1 multicart).
+pub struct Multicart231 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank0: u8,
+ prg_bank1: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart231 {
+ /// Construct a new mapper 231 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 231 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ let horizontal_mirroring = mirroring == Mirroring::Horizontal;
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank0: 0,
+ prg_bank1: 0,
+ horizontal_mirroring,
+ })
+ }
+
+ fn read_prg(&self, bank: u8, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = (bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Multicart231 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(self.prg_bank0, addr),
+ 0xC000..=0xFFFF => self.read_prg(self.prg_bank1, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let prg_bank = ((((addr >> 5) & 0x01) | (addr & 0x1E)) & 0xFF) as u8;
+ self.prg_bank0 = prg_bank & 0x1E;
+ self.prg_bank1 = prg_bank;
+ self.horizontal_mirroring = (addr & 0x80) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank0);
+ out.push(self.prg_bank1);
+ out.push(u8::from(self.horizontal_mirroring));
+ 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 = 4 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank0 = data[1];
+ self.prg_bank1 = data[2];
+ self.horizontal_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 234 (Maxi 15 / `BNROM`-like multicart).
+pub struct Maxi15M234 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg0: u8,
+ reg1: u8,
+}
+
+impl Maxi15M234 {
+ /// Construct a new mapper 234 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 234 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 234 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg0: 0,
+ reg1: 0,
+ })
+ }
+
+ const fn update_state(&mut self) {
+ if (self.reg0 & 0x40) != 0 {
+ // NINA-03 mode.
+ self.reg1 &= 0x71;
+ } else {
+ // CNROM mode: only the low 2 bits of the reg1 CHR selector matter.
+ self.reg1 &= 0x31;
+ }
+ }
+
+ fn latch_access(&mut self, addr: u16, value: u8) {
+ if (0xFF80..=0xFF9F).contains(&addr) {
+ // The reg0 latch only fires while its low 6 bits are still zero.
+ if self.reg0.trailing_zeros() >= 6 {
+ self.reg0 = value;
+ self.update_state();
+ }
+ } else if (0xFFE8..=0xFFF8).contains(&addr) {
+ self.reg1 = value & 0x71;
+ self.update_state();
+ }
+ }
+
+ const fn prg_bank(&self) -> u8 {
+ if (self.reg0 & 0x40) != 0 {
+ (self.reg0 & 0x0E) | (self.reg1 & 0x01)
+ } else {
+ self.reg0 & 0x0F
+ }
+ }
+
+ const fn chr_bank(&self) -> u8 {
+ if (self.reg0 & 0x40) != 0 {
+ ((self.reg0 << 2) & 0x38) | ((self.reg1 >> 4) & 0x07)
+ } else {
+ ((self.reg0 << 2) & 0x3C) | ((self.reg1 >> 4) & 0x03)
+ }
+ }
+}
+
+impl Mapper for Maxi15M234 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank() as usize) % count;
+ let value = self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)];
+ // The latch windows live in the readable PRG space; reading them
+ // triggers the same latch as a write (with the data the CPU sees).
+ self.latch_access(addr, value);
+ value
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.latch_access(addr, value);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if (self.reg0 & 0x80) != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg0);
+ out.push(self.reg1);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg0 = data[1];
+ self.reg1 = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 225 (`ColorDreams` `72-in-1`).
+pub struct Multicart225 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// $5800-$5FFF 4-nibble scratch RAM (4 bytes, mirrored).
+ scratch: [u8; 4],
+ prg_bank: u8,
+ prg16_mode: bool,
+ chr_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart225 {
+ /// Construct a new mapper 225 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 225 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 225 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ scratch: [0; 4],
+ prg_bank: 0,
+ prg16_mode: false,
+ chr_bank: 0,
+ horizontal_mirroring: false,
+ })
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Multicart225 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The scratch RAM answers reads in $5800-$5FFF (mapped). The rest of
+ // $4020-$57FF stays open bus (the trait default); $6000-$FFFF PRG is mapped.
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x57FF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x5800..=0x5FFF => self.scratch[(addr & 0x03) as usize] & 0x0F,
+ 0x8000..=0xBFFF => {
+ let base = if self.prg16_mode {
+ self.prg_bank as usize
+ } else {
+ (self.prg_bank as usize) & !1
+ };
+ self.read_prg(base, addr)
+ }
+ 0xC000..=0xFFFF => {
+ let bank = if self.prg16_mode {
+ self.prg_bank as usize
+ } else {
+ ((self.prg_bank as usize) & !1) | 1
+ };
+ self.read_prg(bank, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x5800..=0x5FFF => self.scratch[(addr & 0x03) as usize] = value & 0x0F,
+ 0x8000..=0xFFFF => {
+ // nesdev iNES 225: the bank/mode are in the ADDRESS bits
+ // A~[.HMO PPPP PPCC CCCC]: CHR = A0..A5 (6 bits), PRG = A6..A11
+ // (6 bits), O (PRG mode) = A12 (1 = 16 KiB switchable,
+ // 0 = 32 KiB), M (mirroring) = A13 (1 = horizontal), H (outer
+ // high bit for both PRG and CHR) = A14.
+ let high = ((addr >> 14) & 0x01) as u8;
+ self.prg16_mode = (addr & 0x1000) != 0;
+ self.prg_bank = (high << 6) | (((addr >> 6) & 0x3F) as u8);
+ self.chr_bank = (high << 6) | ((addr & 0x3F) as u8);
+ self.horizontal_mirroring = (addr & 0x2000) != 0;
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + 4 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.prg16_mode));
+ out.push(self.chr_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.scratch);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 5 + 4 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.prg16_mode = data[2] != 0;
+ self.chr_bank = data[3];
+ self.horizontal_mirroring = data[4] != 0;
+ self.scratch.copy_from_slice(&data[5..9]);
+ self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 226 — 76-in-1 BMC.
+//
+// Two latch registers across $8000-$FFFF (the low address bit selects reg0 vs
+// reg1; the data byte carries the bank bits):
+// reg0 ($8000, even): bits 0-4 = PRG low, bit 5 = PRG high bit, bit 6 =
+// mirroring (1 = horizontal), bit 7 = 32/16 KiB mode.
+// reg1 ($8001, odd): bit 0 = PRG bit 6 (outer block).
+// The 32 KiB PRG bank = (reg1.bit0 << 6) | (reg0.bit5 << 5) | (reg0 & 0x1F).
+// In 16 KiB mode both halves use the same bank. CHR is 8 KiB RAM. No IRQ.
+// ===========================================================================
+
+/// Mapper 226 (`76-in-1` BMC).
+pub struct Multicart226 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg0: u8,
+ reg1: u8,
+}
+
+impl Multicart226 {
+ /// Construct a new mapper 226 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ _mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 226 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg0: 0,
+ reg1: 0,
+ })
+ }
+
+ /// 7-bit 16 KiB PRG bank index: low 6 bits from reg0, high bit from reg1.
+ const fn prg_bank(&self) -> usize {
+ let low = (self.reg0 & 0x3F) as usize;
+ let high = (self.reg1 & 0x01) as usize;
+ (high << 6) | low
+ }
+
+ /// PRG mode: reg0 bit 6 set = two 16 KiB banks; clear = one 32 KiB bank.
+ const fn is_16k(&self) -> bool {
+ (self.reg0 & 0x40) != 0
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Multicart226 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let bank = self.prg_bank();
+ if self.is_16k() {
+ // Both 16 KiB halves map the same selected bank.
+ self.read_prg(bank, addr)
+ } else {
+ // 32 KiB mode: the bank index addresses a 32 KiB page (its low
+ // bit is ignored); the high half is +1.
+ let base = bank & !1;
+ let bank16 = base | usize::from(addr >= 0xC000);
+ self.read_prg(bank16, addr)
+ }
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x8000..=0xFFFF if (addr & 0x01) == 0 => self.reg0 = value,
+ 0x8000..=0xFFFF => self.reg1 = value,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ // reg0 bit 7: 0 = horizontal, 1 = vertical.
+ if (self.reg0 & 0x80) != 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ }
+ }
+
+ 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(self.reg0);
+ out.push(self.reg1);
+ 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::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg0 = data[1];
+ self.reg1 = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 227 — 1200-in-1 BMC.
+//
+// Address-decoded register across $8000-$FFFF (Mesen2 Mapper227). For the
+// absolute write address A:
+// prg_bank = ((A >> 2) & 0x1F) | ((A & 0x100) >> 3) (6-bit 16 KiB index)
+// s_flag = (A & 0x01) (set: restrict / half-select)
+// prg_mode = (A >> 7) & 0x01 (set: NROM modes; clear: UNROM-like)
+// l_flag = (A >> 9) & 0x01 (set: fix $C000 to bank|0x07; clear: &0x38)
+// mirroring = (A & 0x02) -> 1 = horizontal, 0 = vertical
+// The two $8000/$C000 16 KiB windows are then composed per the Mesen2 mode
+// table. The old decode read bit 0 as a 32 KiB mode, mis-applied bit 7, and
+// IGNORED bit 9, so the fixed $C000 window pointed at the wrong bank and the
+// multicart menu never drew. CHR is 8 KiB RAM. No IRQ.
+// ===========================================================================
+
+/// Mapper 227 (`1200-in-1` BMC).
+#[allow(clippy::struct_excessive_bools)] // 4 independent decoded register flags
+pub struct Multicart227 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ s_flag: bool,
+ l_flag: bool,
+ prg_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart227 {
+ /// Construct a new mapper 227 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ _mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 227 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ s_flag: false,
+ l_flag: false,
+ prg_mode: false,
+ horizontal_mirroring: false,
+ })
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+
+ /// Compose the ($8000, $C000) 16 KiB bank pair from the decoded flags,
+ /// matching Mesen2 `Mapper227::WriteRegister`.
+ const fn prg_pages(&self) -> (usize, usize) {
+ let b = self.prg_bank as usize;
+ if self.prg_mode {
+ if self.s_flag {
+ (b & 0xFE, (b & 0xFE) | 1) // 32 KiB pair
+ } else {
+ (b, b) // NROM-128 (16 KiB mirrored)
+ }
+ } else {
+ let lo = if self.s_flag { b & 0x3E } else { b };
+ let hi = if self.l_flag { b | 0x07 } else { b & 0x38 };
+ (lo, hi)
+ }
+ }
+}
+
+impl Mapper for Multicart227 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let (p0, p1) = self.prg_pages();
+ match addr {
+ 0x8000..=0xBFFF => self.read_prg(p0, addr),
+ 0xC000..=0xFFFF => self.read_prg(p1, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let low = ((addr >> 2) & 0x1F) as u8;
+ let high = ((addr & 0x100) >> 3) as u8; // bit 8 -> bit 5 (0x20)
+ self.prg_bank = low | high;
+ self.s_flag = (addr & 0x01) != 0;
+ self.prg_mode = ((addr >> 7) & 0x01) != 0;
+ self.l_flag = ((addr >> 9) & 0x01) != 0;
+ self.horizontal_mirroring = (addr & 0x02) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(6 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.s_flag));
+ out.push(u8::from(self.l_flag));
+ out.push(u8::from(self.prg_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ 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 = 6 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.s_flag = data[2] != 0;
+ self.l_flag = data[3] != 0;
+ self.prg_mode = data[4] != 0;
+ self.horizontal_mirroring = data[5] != 0;
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 229 — 31-in-1 BMC.
+//
+// Address-decoded register across $8000-$FFFF. For the absolute address A:
+// When (A & 0x1E) == 0: a fixed 32 KiB NROM bank 0 (the menu).
+// Otherwise: a 16 KiB PRG bank pair = (A & 0x1F) on both $8000 and $C000?
+// The documented decode: $8000 = (A & 0x1F), $C000 = (A & 0x1F) (16 KiB,
+// same bank), CHR (8 KiB) bank = A & 0x0F, mirroring = (A & 0x20).
+// CHR is ROM. No IRQ.
+// ===========================================================================
+
+/// Mapper 229 (`31-in-1` BMC).
+pub struct Multicart229 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// Latched absolute address bits (low 6) used by the decode.
+ addr_latch: u8,
+ chr_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Multicart229 {
+ /// Construct a new mapper 229 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 229 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 229 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ addr_latch: 0,
+ chr_bank: 0,
+ horizontal_mirroring: false,
+ })
+ }
+
+ /// True when the latched address selects the fixed 32 KiB NROM menu bank.
+ const fn is_menu(&self) -> bool {
+ (self.addr_latch & 0x1E) == 0
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Multicart229 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if !(0x8000..=0xFFFF).contains(&addr) {
+ return 0;
+ }
+ if self.is_menu() {
+ // Fixed 32 KiB NROM bank 0.
+ let bank16 = usize::from(addr >= 0xC000);
+ self.read_prg(bank16, addr)
+ } else {
+ // 16 KiB bank from the latch, mirrored across both halves.
+ let bank = (self.addr_latch & 0x1F) as usize;
+ self.read_prg(bank, addr)
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.addr_latch = (addr & 0x3F) as u8;
+ self.chr_bank = (addr & 0x0F) as u8;
+ self.horizontal_mirroring = (addr & 0x20) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.addr_latch);
+ out.push(self.chr_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 4 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.addr_latch = data[1];
+ self.chr_bank = data[2];
+ self.horizontal_mirroring = data[3] != 0;
+ self.vram.copy_from_slice(&data[4..4 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 233 — 42-in-1 reset-based BMC.
+//
+// Address-decoded register across $8000-$FFFF. For the absolute address A:
+// PRG: bits 0-4 select a 16 KiB bank; bit 5 picks 32/16 KiB mode.
+// mirroring: bits 6-7 -> 0 = one-screen A, 1 = one-screen B, 2 = vertical,
+// 3 = horizontal.
+// A reset toggles a separate "outer block" line that selects the upper or lower
+// half of the ROM; that line is host-driven (the physical reset button), so we
+// model it as a fixed power-on `0`. CHR is 8 KiB RAM. No IRQ.
+// ===========================================================================
+
+/// Mapper 233 (`42-in-1` reset-based BMC).
+pub struct Multicart233 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// Reset-selected outer block (host-driven; fixed at power-on).
+ outer_block: u8,
+ prg_bank: u8,
+ /// reg bit 5: set = 16 KiB mode (bank mirrored to both halves), clear =
+ /// 32 KiB mode (the pair at bank>>1). puNES `prg_fix_233`.
+ mode_16k: bool,
+ mirror_mode: u8,
+}
+
+impl Multicart233 {
+ /// Construct a new mapper 233 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ _mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 233 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ outer_block: 0,
+ prg_bank: 0,
+ mode_16k: false,
+ mirror_mode: 0,
+ })
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Multicart233 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ // puNES prg_fix_233: bank = (reg & 0x1F) | reset. reg bit 5 set =>
+ // 16 KiB mode (the SAME bank mirrored to both halves); clear => 32 KiB
+ // mode (the pair at bank>>1). The previous code had the mode bit
+ // inverted (treating bit-5-set as 32 KiB), so the menu's expected bank
+ // never mapped and the multicart booted blank.
+ let bank16 = (self.prg_bank as usize) | ((self.outer_block as usize) << 5);
+ match addr {
+ 0x8000..=0xBFFF => {
+ let b = if self.mode_16k { bank16 } else { bank16 & !1 };
+ self.read_prg(b, addr)
+ }
+ 0xC000..=0xFFFF => {
+ let b = if self.mode_16k {
+ bank16
+ } else {
+ (bank16 & !1) | 1
+ };
+ self.read_prg(b, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, _addr: u16, value: u8) {
+ // puNES iNES 233: a $8000-$FFFF write carries the full register in the
+ // DATA byte. PPPPP (bits 0-4) = PRG page (combined with the reset outer
+ // line), bit 5 = 16 KiB mode select (set = 16 KiB mirrored both halves,
+ // clear = 32 KiB pair), MM (bits 6-7) = mirroring (0 = 1-screen A,
+ // 1 = vertical, 2 = horizontal, 3 = 1-screen B).
+ self.prg_bank = value & 0x1F;
+ self.mode_16k = (value & 0x20) != 0;
+ self.mirror_mode = (value >> 6) & 0x03;
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.mirror_mode {
+ 0 => Mirroring::SingleScreenA,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::Horizontal,
+ _ => Mirroring::SingleScreenB,
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(5 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.outer_block);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.mode_16k));
+ out.push(self.mirror_mode);
+ 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 = 5 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.outer_block = data[1];
+ self.prg_bank = data[2];
+ self.mode_16k = data[3] != 0;
+ // Mask to the valid 0..=3 range so a corrupt / hand-edited save state
+ // can never produce an out-of-range mirroring mode (adopted from the
+ // PR #116 Gemini review).
+ self.mirror_mode = data[4] & 0x03;
+ let mut cursor = 5;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Which discrete board the [`DiscreteMapper`] models.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiscreteBoard {
+ /// Mapper 46 (Color Dreams "Rumble Station" 15-in-1).
+ M46,
+ /// Mapper 51 (BMC 11-in-1).
+ M51,
+ /// Mapper 57 (BMC GK 6-in-1).
+ M57,
+ /// Mapper 104 (Codemasters Golden Five / Pegasus 5-in-1).
+ M104,
+ /// Mapper 120 (Tobidase Daisakusen FDS-conversion protection).
+ M120,
+ /// Mapper 290 (NTDEC Asder BMC-NTD-03).
+ M290,
+ /// Mapper 301 (BMC-8157 address-as-data multicart).
+ M301,
+}
+
+/// A discrete unlicensed/multicart board with a simple register surface.
+pub struct DiscreteMapper {
+ board: DiscreteBoard,
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ reg0: u8,
+ reg1: u8,
+ /// For 301 (address-as-data) the last-written address.
+ last_addr: u16,
+ mirroring: Mirroring,
+}
+
+impl DiscreteMapper {
+ fn new(
+ board: DiscreteBoard,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ id: u16,
+ ) -> Result {
+ 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()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ Ok(Self {
+ board,
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg0: 0,
+ reg1: 0,
+ last_addr: 0,
+ mirroring,
+ })
+ }
+
+ fn prg_16k(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+
+ fn prg_32k(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
+ }
+
+ fn prg_8k(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+
+ fn chr_8k_bank(&self) -> usize {
+ match self.board {
+ DiscreteBoard::M46 => {
+ ((self.reg0 as usize & 0xF0) >> 1) | ((self.reg1 as usize & 0x70) >> 4)
+ }
+ DiscreteBoard::M57 => {
+ ((self.reg0 as usize & 0x40) >> 3) | ((self.reg0 | self.reg1) as usize & 0x07)
+ }
+ _ => 0,
+ }
+ }
+}
+
+impl Mapper for DiscreteMapper {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match self.board {
+ DiscreteBoard::M46 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let bank = ((self.reg0 as usize & 0x0F) << 1) | (self.reg1 as usize & 0x01);
+ self.prg_32k(bank, addr)
+ } else {
+ 0
+ }
+ }
+ DiscreteBoard::M51 => {
+ let bank4 = (self.reg0 as usize) << 2;
+ match addr {
+ 0x6000..=0x7FFF => {
+ let b = if self.reg1 & 0x01 != 0 {
+ 0x23 | bank4
+ } else {
+ 0x2F | bank4
+ };
+ self.prg_8k(b, addr)
+ }
+ 0x8000..=0xBFFF if self.reg1 & 0x01 != 0 => self.prg_16k(bank4 >> 1, addr),
+ 0xC000..=0xFFFF if self.reg1 & 0x01 != 0 => {
+ self.prg_16k((bank4 >> 1) | 1, addr)
+ }
+ 0x8000..=0xBFFF => {
+ self.prg_16k((bank4 >> 1) | (self.reg1 as usize >> 1 & 0x01), addr)
+ }
+ 0xC000..=0xFFFF => self.prg_16k((bank4 >> 1) | 0x07, addr),
+ _ => 0,
+ }
+ }
+ DiscreteBoard::M57 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ if self.reg1 & 0x10 != 0 {
+ let b = ((self.reg1 as usize >> 5) & 0x06) >> 1;
+ self.prg_32k(b, addr)
+ } else {
+ let b16 = (self.reg1 as usize >> 5) & 0x07;
+ self.prg_16k(b16, addr)
+ }
+ } else {
+ 0
+ }
+ }
+ DiscreteBoard::M104 => match addr {
+ 0x8000..=0xBFFF => self.prg_16k(self.reg0 as usize, addr),
+ 0xC000..=0xFFFF => {
+ let high = (self.reg1 as usize & 0x70) | 0x0F;
+ self.prg_16k(high, addr)
+ }
+ _ => 0,
+ },
+ DiscreteBoard::M120 => match addr {
+ 0x6000..=0x7FFF => self.prg_8k(self.reg0 as usize, addr),
+ 0x8000..=0xFFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
+ let base = count.saturating_sub(4);
+ self.prg_8k(base + slot, addr)
+ }
+ _ => 0,
+ },
+ DiscreteBoard::M290 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_16k(self.reg0 as usize, addr)
+ } else {
+ 0
+ }
+ }
+ DiscreteBoard::M301 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let a = self.last_addr;
+ let inner = (a >> 2) & 0x07;
+ let outer128 = (a >> 5) & 0x03;
+ // A7 is the 256 KiB outer-bank select; without it any PRG
+ // image > 256 KiB can only reach its low half. Slot it
+ // between the 128 KiB (A5-A6) and 512 KiB (A8) selects.
+ let outer256 = (a >> 7) & 0x01;
+ let outer512 = (a >> 8) & 0x01;
+ let bank16 = (outer512 << 6) | (outer256 << 5) | (outer128 << 3) | inner;
+ self.prg_16k(bank16 as usize, addr)
+ } else {
+ 0
+ }
+ }
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ match self.board {
+ DiscreteBoard::M51 | DiscreteBoard::M120 => (0x4020..=0x5FFF).contains(&addr),
+ _ => (0x4020..=0x7FFF).contains(&addr),
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match self.board {
+ DiscreteBoard::M46 => {
+ if addr < 0x8000 {
+ self.reg0 = value;
+ } else {
+ self.reg1 = value;
+ }
+ }
+ DiscreteBoard::M51 => match addr {
+ 0x6000..=0x7FFF => self.reg1 = ((value >> 3) & 0x02) | ((value >> 1) & 0x01),
+ 0xC000..=0xDFFF => {
+ self.reg0 = value & 0x0F;
+ self.reg1 = ((value >> 3) & 0x02) | (self.reg1 & 0x01);
+ }
+ 0x8000..=0xFFFF => self.reg0 = value & 0x0F,
+ _ => {}
+ },
+ DiscreteBoard::M57 => match addr & 0x8800 {
+ 0x8000 => self.reg0 = value,
+ 0x8800 => self.reg1 = value,
+ _ => {}
+ },
+ DiscreteBoard::M104 => {
+ if addr >= 0xC000 {
+ self.reg0 = (self.reg0 & 0xF0) | (value & 0x0F);
+ } else if (0x8000..=0x9FFF).contains(&addr) && value & 0x08 != 0 {
+ self.reg0 = (self.reg0 & 0x0F) | ((value << 4) & 0x70);
+ self.reg1 = value;
+ }
+ }
+ DiscreteBoard::M120 => {
+ if addr == 0x41FF {
+ self.reg0 = value;
+ }
+ }
+ DiscreteBoard::M290 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let prg = ((addr >> 10) & 0x1E) as u8;
+ let chr = (((addr & 0x0300) >> 5) | (addr & 0x07)) as u8;
+ self.reg0 = if addr & 0x80 != 0 {
+ prg | ((addr >> 6) & 1) as u8
+ } else {
+ prg & 0xFE
+ };
+ self.reg1 = chr;
+ self.mirroring = if addr & 0x400 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ DiscreteBoard::M301 => {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.last_addr = addr;
+ self.mirroring = if addr & 0x02 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ let off = addr as usize & (self.chr.len() - 1);
+ self.chr[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(6 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg0);
+ out.push(self.reg1);
+ out.push((self.last_addr & 0xFF) as u8);
+ out.push((self.last_addr >> 8) as u8);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 6 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg0 = data[1];
+ self.reg1 = data[2];
+ self.last_addr = u16::from(data[3]) | (u16::from(data[4]) << 8);
+ self.mirroring = byte_to_mirroring(data[5], self.mirroring);
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+macro_rules! discrete_ctor {
+ ($fn_name:ident, $board:expr, $id:expr, $doc:expr) => {
+ #[doc = $doc]
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG/CHR size.
+ pub fn $fn_name(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ DiscreteMapper::new($board, prg_rom, chr_rom, mirroring, $id)
+ }
+ };
+}
+
+discrete_ctor!(
+ new_m46,
+ DiscreteBoard::M46,
+ 46,
+ "Mapper 46 (Color Dreams Rumble Station 15-in-1)."
+);
+discrete_ctor!(
+ new_m51,
+ DiscreteBoard::M51,
+ 51,
+ "Mapper 51 (BMC 11-in-1 multicart)."
+);
+discrete_ctor!(
+ new_m57,
+ DiscreteBoard::M57,
+ 57,
+ "Mapper 57 (BMC GK 6-in-1 multicart)."
+);
+discrete_ctor!(
+ new_m104,
+ DiscreteBoard::M104,
+ 104,
+ "Mapper 104 (Codemasters Golden Five / Pegasus 5-in-1)."
+);
+discrete_ctor!(
+ new_m120,
+ DiscreteBoard::M120,
+ 120,
+ "Mapper 120 (Tobidase Daisakusen FDS-conversion protection)."
+);
+discrete_ctor!(
+ new_m290,
+ DiscreteBoard::M290,
+ 290,
+ "Mapper 290 (NTDEC Asder BMC-NTD-03)."
+);
+discrete_ctor!(
+ new_m301,
+ DiscreteBoard::M301,
+ 301,
+ "Mapper 301 (BMC-8157 address-as-data multicart)."
+);
+
+/// Discrete NROM/UNROM 2-in-1 BMC multicart (mapper 204).
+pub struct Bmc204 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ /// 16 KiB PRG windows for $8000 and $C000.
+ prg0: usize,
+ prg1: usize,
+ /// 8 KiB CHR window.
+ chr8: usize,
+}
+
+impl Bmc204 {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 204)?;
+ if prg_rom.len() < PRG_BANK_16K {
+ return Err(MapperError::Invalid(format!(
+ "mapper 204 PRG-ROM size {} is smaller than one 16 KiB bank",
+ prg_rom.len()
+ )));
+ }
+ let (chr, chr_is_ram) = chr_or_ram(chr_rom);
+ let mut m = Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg0: 0,
+ prg1: 0,
+ chr8: 0,
+ };
+ // Power-on: WriteRegister(0x8000, 0).
+ m.write_addr(0x8000);
+ Ok(m)
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn chr_count_8k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_8K).max(1)
+ }
+
+ fn write_addr(&mut self, addr: u16) {
+ let bit_mask = (addr & 0x06) as usize;
+ let page = bit_mask
+ + if bit_mask == 0x06 {
+ 0
+ } else {
+ (addr & 0x01) as usize
+ };
+ self.prg0 = page;
+ self.prg1 = bit_mask
+ + if bit_mask == 0x06 {
+ 1
+ } else {
+ (addr & 0x01) as usize
+ };
+ self.chr8 = page;
+ self.mirroring = if addr & 0x10 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+
+ fn prg_byte(&self, slot16: usize, addr: u16) -> u8 {
+ let count = self.prg_count_16k();
+ let bank = slot16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Bmc204 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.prg_byte(self.prg0, addr),
+ 0xC000..=0xFFFF => self.prg_byte(self.prg1, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if addr >= 0x8000 {
+ self.write_addr(addr);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + 12 + 1 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&(self.prg0 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.prg1 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.chr8 as u32).to_le_bytes());
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + 12 + 1 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let rd = |c: usize| {
+ u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
+ };
+ self.prg0 = rd(1);
+ self.prg1 = rd(5);
+ self.chr8 = rd(9);
+ let mut c = 13;
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 204 (discrete NROM/UNROM 2-in-1 BMC multicart).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+pub fn new_m204(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Bmc204::new(prg_rom, chr_rom, mirroring)
+}
+
+// ===========================================================================
+// NtdecN625092 (mapper 221) — NTDEC N625092 multicart.
+//
+// $8000 latches a 16-bit "mode" from the written address; $C000 latches the
+// 3-bit inner PRG register. The outer bank is `(mode & 0xFC) >> 2`. When
+// `mode & 0x02` the board is in UNROM-style mode (a switchable $8000 + a fixed
+// $C000), with a NROM-256 sub-case when `mode & 0x0100`; otherwise both 16 KiB
+// windows mirror the same NROM bank. `mode & 0x01` flips the mirroring. CHR is a
+// single fixed 8 KiB window. Ported from Mesen2 Ntdec/Mapper221.h.
+// ===========================================================================
+
+/// TXC/BMC-11160 multicart (mapper 299).
+pub struct Bmc11160 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ /// 32 KiB PRG window.
+ prg32: usize,
+ /// 8 KiB CHR window.
+ chr8: usize,
+}
+
+impl Bmc11160 {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 299)?;
+ if prg_rom.len() < PRG_BANK_32K {
+ return Err(MapperError::Invalid(format!(
+ "mapper 299 PRG-ROM size {} is smaller than one 32 KiB bank",
+ prg_rom.len()
+ )));
+ }
+ let (chr, chr_is_ram) = chr_or_ram(chr_rom);
+ let mut m = Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg32: 0,
+ chr8: 0,
+ };
+ // Power-on (Reset): WriteRegister(0x8000, 0).
+ m.write_reg(0);
+ Ok(m)
+ }
+
+ fn prg_count_32k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_32K).max(1)
+ }
+
+ fn chr_count_8k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_8K).max(1)
+ }
+
+ fn write_reg(&mut self, value: u8) {
+ let bank = ((value >> 4) & 0x07) as usize;
+ self.prg32 = bank;
+ self.chr8 = (bank << 2) | (value as usize & 0x03);
+ self.mirroring = if value & 0x80 != 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ }
+}
+
+impl Mapper for Bmc11160 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => {
+ let count = self.prg_count_32k();
+ let bank = self.prg32 % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ self.write_reg(value);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + 8 + 1 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&(self.prg32 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.chr8 as u32).to_le_bytes());
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + 8 + 1 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let rd = |c: usize| {
+ u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
+ };
+ self.prg32 = rd(1);
+ self.chr8 = rd(5);
+ let mut c = 9;
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 299 (TXC/BMC-11160 multicart).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+pub fn new_m299(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ Bmc11160::new(prg_rom, chr_rom, mirroring)
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+ /// 8 KiB-banked PRG: byte 0 of each 8 KiB bank holds the bank index.
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ /// 32 KiB-banked PRG: byte 0 of each 32 KiB bank holds the bank index (all
+ /// other offsets are transparent).
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m15_mode0_two_16k_halves() {
+ // 8 16 KiB banks = 128 KiB.
+ let mut m = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ // mode 0 ($8000), prg_bank = 2, mirroring bit clear (vertical).
+ m.cpu_write(0x8000, 0b0000_0010);
+ assert_eq!(m.cpu_read(0x8000), 2); // low half = bank 2
+ assert_eq!(m.cpu_read(0xC000), 3); // high half = bank 2|1 = 3
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m15_mirroring_bit() {
+ let mut m = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ m.cpu_write(0x8000, 0b0100_0000); // bit 6 = horizontal
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m15_mode3_single_bank_mirrored() {
+ let mut m = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ // mode 3 ($8003), prg_bank = 5.
+ m.cpu_write(0x8003, 0b0000_0101);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xC000), 5); // 16 KiB mirrored across the window
+ }
+
+ #[test]
+ fn m15_chr_ram_write_protect() {
+ let mut m = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ // mode 0 -> protected.
+ m.cpu_write(0x8000, 0);
+ m.ppu_write(0x0000, 0xAB);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // mode 2 -> writable.
+ m.cpu_write(0x8002, 0);
+ m.ppu_write(0x0000, 0xCD);
+ assert_eq!(m.ppu_read(0x0000), 0xCD);
+ }
+
+ #[test]
+ fn m61_16k_mode_address_decode() {
+ // 16 16 KiB banks.
+ let mut m = Multicart61::new(synth_prg_16k(16), &[]).unwrap();
+ // Choose addr with A&0x0F = 3, A>>5&1 = 0 -> page = 6; A&0x10 set (16k);
+ // A&0x80 set (horizontal). addr = 0x8000 | 0x10 | 0x80 | 0x03 = 0x8093.
+ m.cpu_write(0x8093, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 6);
+ assert_eq!(m.cpu_read(0xC000), 6); // 16 KiB mirrored
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m61_32k_mode() {
+ let mut m = Multicart61::new(synth_prg_16k(16), &[]).unwrap();
+ // A&0x0F = 2, A>>5&1 = 0 -> page = 4; 32 KiB mode (A&0x10 clear).
+ // 32 KiB bank = page>>1 = 2. addr = 0x8000 | 0x02 = 0x8002.
+ m.cpu_write(0x8002, 0x00);
+ // 32 KiB bank 2 = 16 KiB banks 4 and 5.
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn m62_address_and_data_decode() {
+ let mut m =
+ Multicart62::new(synth_prg_16k(8), synth_chr_8k(256), Mirroring::Vertical).unwrap();
+ // prg_page = ((A&0x3F00)>>8) | (A&0x40); pick A bits so page small.
+ // A = 0x8000 | (0x01 << 8) | 0x20(16k mode) | 0x80(horiz) | 0x05(chr lo)
+ // prg_page = 0x01, 16k mode, horizontal, chr = (5<<2)|data&3.
+ let addr = 0x8000 | (0x01 << 8) | 0x20 | 0x80 | 0x05;
+ m.cpu_write(addr, 0x02); // data low 2 bits = 2
+ assert_eq!(m.cpu_read(0x8000), 1); // 16k bank 1
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // chr_bank = (5<<2)|2 = 22.
+ assert_eq!(m.ppu_read(0x0000), 22);
+ }
+
+ #[test]
+ fn m15_save_state_round_trips_mirroring() {
+ let mut m = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ m.cpu_write(0x8001, 0b0100_0101);
+ let blob = m.save_state();
+ let mut m2 = Multicart15::new(synth_prg_16k(8), &[]).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.current_mirroring(), m.current_mirroring());
+ }
+
+ #[test]
+ fn m200_address_latch() {
+ let mut m =
+ Multicart200::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // Write address with low bits = 3 and bit3 set (horizontal).
+ m.cpu_write(0x8000 | 0x0B, 0x00); // 0x0B = 0b1011: bank 3, H bit set
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // NROM-128: $8000 mirrors $C000.
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // bit3 clear -> vertical.
+ m.cpu_write(0x8000 | 0x02, 0x00);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ }
+
+ #[test]
+ fn m201_address_drives_prg_and_chr() {
+ let mut m =
+ Multicart201::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // addr low 3 bits = 0b011: PRG = 3 & 3 = 3, CHR = 3.
+ m.cpu_write(0x8000 | 0x03, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ // addr low 3 bits = 0b101: PRG = 5 & 3 = 1, CHR = 5.
+ m.cpu_write(0x8000 | 0x05, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ }
+
+ #[test]
+ fn m202_16k_mode() {
+ let mut m =
+ Multicart202::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // page = (addr>>1)&7. Pick page 3 -> addr bits 3..1 = 0b011 -> addr = 0b0110.
+ // O bits (bit3 and bit0): bit3 = 0, bit0 = 0 -> not both set -> 16k mode.
+ // mirroring = addr bit0 = 0 -> vertical.
+ m.cpu_write(0x8000 | 0b0110, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3); // mirrored in 16k mode
+ assert_eq!(m.ppu_read(0x0000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m202_32k_mode_and_mirroring() {
+ let mut m =
+ Multicart202::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // Both O bits set: addr bit3 = 1 and bit0 = 1.
+ // page = (addr>>1)&7. addr = 0b1001 | (page<<1). Pick page 2 -> page<<1 = 0b100.
+ // addr = 0b1101 = 0x0D: bit3=1, bit0=1 -> 32k mode. page = (0xD>>1)&7 = 6&7 = 6.
+ // Recompute to make page even/clear: choose addr = 0x09 (0b1001): page = (9>>1)&7 = 4.
+ // bit3=1, bit0=1 -> 32k. mirroring = bit0 = 1 -> horizontal.
+ m.cpu_write(0x8000 | 0x09, 0x00);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // 32k bank = (page>>1)<<1 = (4>>1)<<1 = 4. Bank 4 at $8000, bank 5 at $C000.
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn m203_data_latch() {
+ let mut m =
+ Multicart203::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // value PPPPPPCC: PRG = value>>2, CHR = value&3.
+ // 0b0000_1110 = 0x0E: PRG = 3, CHR = 2.
+ m.cpu_write(0x8000, 0x0E);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3); // mirrored
+ assert_eq!(m.ppu_read(0x0000), 2);
+ }
+
+ #[test]
+ fn m212_16k_mode_and_protection_read() {
+ let mut m =
+ Multicart212::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // 16k mode (bit14 clear). page = addr & 7 = 3, mirroring bit3 = 1 (H).
+ m.cpu_write(0x8000 | 0x0B, 0x00); // 0b1011: page 3, H bit set
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // Protection read: $6000 (addr&0x10 == 0) -> bit7 set.
+ assert_eq!(m.cpu_read(0x6000) & 0x80, 0x80);
+ assert_eq!(m.cpu_read(0x6010), 0x00);
+ }
+
+ #[test]
+ fn m212_32k_mode() {
+ let mut m =
+ Multicart212::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // bit14 set -> 32k mode. page = addr & 7 = 4. 32k bank = (4>>1)<<1 = 4.
+ m.cpu_write(0xC000 | 0x04, 0x00); // 0xC004: bit14 set, page 4
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn m213_address_latch() {
+ let mut m =
+ Multicart213::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // CHR = (addr>>3)&7, PRG = (addr>>1)&3.
+ // Pick CHR 5, PRG 2: addr bits: (5<<3)|(2<<1) = 0x28 | 0x04 = 0x2C.
+ m.cpu_write(0x8000 | 0x2C, 0x00);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ }
+
+ #[test]
+ fn m214_address_latch() {
+ let mut m =
+ Multicart214::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // CHR = addr & 3, PRG = (addr>>2)&3.
+ // Pick PRG 2, CHR 1: addr bits = (2<<2)|1 = 0x09.
+ m.cpu_write(0x8000 | 0x09, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 2); // mirrored
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn m58_address_decoded_banking() {
+ let mut m =
+ Multicart58::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // 16 KiB mode (bit 6 set): A = $8000 | (1<<6) | (CHR=2<<3) | (PRG=3).
+ let addr = 0x8000 | (1 << 6) | (0b010 << 3) | 0b011;
+ m.cpu_write(addr, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 3); // 16 KiB bank 3
+ assert_eq!(m.ppu_read(0x0000), 2); // CHR bank 2
+ // 32 KiB mode (bit 6 clear): PRG bank = (A&6)>>1. A low bits = 0b110 = 6
+ // -> 32 KiB bank (6&6)>>1 = 3.
+ let addr32 = 0x8000 | 0b110;
+ m.cpu_write(addr32, 0x00);
+ // synth_prg_16k(8) = 4 32 KiB banks; bank 3 offset 0 holds index 6.
+ assert_eq!(m.cpu_read(0x8000), 6);
+ }
+
+ #[test]
+ fn m58_save_state_round_trip() {
+ let mut m =
+ Multicart58::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ let addr = 0x8000 | (1 << 7) | (1 << 6) | (0b001 << 3) | 0b010;
+ m.cpu_write(addr, 0x00);
+ let blob = m.save_state();
+ let mut m2 =
+ Multicart58::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 2);
+ assert_eq!(m2.ppu_read(0x0000), 1);
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m60_power_on_bank_zero() {
+ let mut m =
+ Multicart60::new(synth_prg_16k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // Writes are ignored (reset-driven selection not modelled).
+ m.cpu_write(0x8000, 0xFF);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ }
+
+ #[test]
+ fn m60_save_state_round_trip() {
+ let mut m =
+ Multicart60::new(synth_prg_16k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x2000, 0x44);
+ let blob = m.save_state();
+ let mut m2 =
+ Multicart60::new(synth_prg_16k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x2000), 0x44);
+ }
+
+ #[test]
+ fn m231_dual_bank_address_decoded() {
+ let mut m = Multicart231::new(synth_prg_16k(32), &[], Mirroring::Vertical).unwrap();
+ // A bits: prgBank = ((A>>5)&1) | (A&0x1E). Pick A = $8000 | 0x14 (= 0b1_0100).
+ // A&0x1E = 0x14 = 20; (A>>5)&1 = 0. prgBank = 20.
+ // bank0 = 20 & 0x1E = 20; bank1 = 20.
+ let addr = 0x8000 | 0x14;
+ m.cpu_write(addr, 0x00);
+ assert_eq!(m.cpu_read(0x8000), 20);
+ assert_eq!(m.cpu_read(0xC000), 20);
+ // Set bit 5 (0x20): contributes 1 to bank1's LSB; A&0x1E unchanged.
+ let addr2 = 0x8000 | 0x20 | 0x14; // (A>>5)&1 = 1
+ m.cpu_write(addr2, 0x00);
+ // prgBank = 1 | 20 = 21; bank0 = 21 & 0x1E = 20; bank1 = 21.
+ assert_eq!(m.cpu_read(0x8000), 20);
+ assert_eq!(m.cpu_read(0xC000), 21);
+ }
+
+ #[test]
+ fn m231_save_state_round_trip() {
+ let mut m = Multicart231::new(synth_prg_16k(32), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000 | 0x80 | 0x04, 0x00); // horizontal, bank bits
+ m.ppu_write(0x0008, 0x66);
+ let blob = m.save_state();
+ let mut m2 = Multicart231::new(synth_prg_16k(32), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ assert_eq!(m2.ppu_read(0x0008), 0x66);
+ }
+
+ #[test]
+ fn m234_cnrom_mode_banks() {
+ let mut m =
+ Maxi15M234::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // reg0 latch (CNROM mode, bit6 clear). Write $FF80 with value 0x05:
+ // reg0 = 0x05 -> prgBank = reg0 & 0x0F = 5.
+ // chrBank = ((reg0<<2)&0x3C) | ((reg1>>4)&3) = (0x14) | 0 = 20.
+ m.cpu_write(0xFF80, 0x05);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ // chrBank = ((0x05<<2)&0x3C) = 0x14 = 20; 16-bank ROM wraps to 4.
+ assert_eq!(m.ppu_read(0x0000), 20 % 16);
+ // reg1 sets CHR low bits via $FFE8 (value 0x10 -> (0x10>>4)&3 = 1).
+ m.cpu_write(0xFFE8, 0x10);
+ // chrBank = 0x14 | 0x01 = 0x15 = 21; wraps to 5.
+ assert_eq!(m.ppu_read(0x0000), 0x15 % 16);
+ }
+
+ #[test]
+ fn m234_save_state_round_trip() {
+ let mut m =
+ Maxi15M234::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0xFF80, 0x83); // reg0: horizontal (bit7), prg = 3
+ m.cpu_write(0xFFE8, 0x20);
+ let blob = m.save_state();
+ let mut m2 =
+ Maxi15M234::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), 3);
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m225_address_decoded_and_scratch_ram() {
+ let mut m =
+ Multicart225::new(synth_prg_16k(16), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // nesdev decode A~[.HMO PPPP PPCC CCCC]: PRG = A6..A9, O(mode) = A10,
+ // M(mirror) = A11, H = A14. A = 0x8080: PRG = (0x80>>6)&0xF = 2; O = 0 ->
+ // 32K; M = 0 -> vertical; CHR = A&0x3F = 0.
+ m.cpu_write(0x8080, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ // Scratch RAM round-trips low nibble.
+ m.cpu_write(0x5800, 0xA9);
+ assert_eq!(m.cpu_read(0x5800), 0x09);
+ }
+
+ #[test]
+ fn m225_save_state_round_trip() {
+ let mut m =
+ Multicart225::new(synth_prg_16k(16), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x9180, 0); // some bank
+ m.cpu_write(0x5803, 0x05);
+ let blob = m.save_state();
+ let mut m2 =
+ Multicart225::new(synth_prg_16k(16), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.cpu_read(0x5803), 0x05);
+ }
+
+ #[test]
+ fn m226_two_regs_select_prg_and_mirror() {
+ let mut m = Multicart226::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ // reg0 (even): low bits = 3, bit6 = mirror H. value 0b0100_0011 = 0x43.
+ m.cpu_write(0x8000, 0x43);
+ // reg1 (odd): bit0 = 0.
+ m.cpu_write(0x8001, 0x00);
+ // 16K mode (reg0 bit7 = 0): bank 3 on both halves.
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m226_save_state_round_trip() {
+ let mut m = Multicart226::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x85); // 32K mode, low = 5
+ m.cpu_write(0x8001, 0x00);
+ m.ppu_write(0x0001, 0x66);
+ let blob = m.save_state();
+ let mut m2 = Multicart226::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0001), 0x66);
+ }
+
+ #[test]
+ fn m227_address_decoded_bank() {
+ let mut m = Multicart227::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ // A = 0x8008: prg_bank = (0x8008>>2)&0x1F = 2; s=(A&1)=0, prg_mode=
+ // (A>>7)&1=0, l=(A>>9)&1=0, mirror=(A&2)=0 -> V. UNROM-like, s=0,l=0:
+ // $8000 = bank 2, $C000 = bank & 0x38 = 0.
+ m.cpu_write(0x8008, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ // l_flag set (A bit 9 = 0x200): $C000 fixed to bank | 0x07.
+ // A = 0x8208: prg_bank still 2, l=1 -> $C000 = 2 | 7 = 7.
+ m.cpu_write(0x8208, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 7);
+ // prg_mode set without s (A bit 7 = 0x80): NROM-128, both halves = bank.
+ // A = 0x8088: prg_bank = (0x8088>>2)&0x1F = 2; prg_mode=1, s=0.
+ m.cpu_write(0x8088, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 2);
+ }
+
+ #[test]
+ fn m227_save_state_round_trip() {
+ let mut m = Multicart227::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x808B, 0); // prg_mode + s (32K pair) + A&2 -> H
+ m.ppu_write(0x0002, 0x33);
+ let blob = m.save_state();
+ let mut m2 = Multicart227::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0002), 0x33);
+ }
+
+ #[test]
+ fn m229_menu_bank_and_game_bank() {
+ let mut m =
+ Multicart229::new(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // A with low 5 bits zero -> menu (fixed NROM-32 bank 0).
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 1);
+ // A = 0x8003: latch = 3 (non-menu) -> 16K bank 3 on both halves.
+ m.cpu_write(0x8003, 0);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ }
+
+ #[test]
+ fn m229_save_state_round_trip() {
+ let mut m =
+ Multicart229::new(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8025, 0); // latch with chr + mirror H
+ let blob = m.save_state();
+ let mut m2 =
+ Multicart229::new(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.current_mirroring(), m.current_mirroring());
+ }
+
+ #[test]
+ fn m233_bank_and_mirror_modes() {
+ let mut m = Multicart233::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ // puNES: bit 5 SET = 16 KiB mode (one bank mirrored to both halves),
+ // bits 6-7 = mirroring. value 0xA5 = MM=10 (horizontal), bit5=1 (16K),
+ // bank = 0x05.
+ m.cpu_write(0x8000, 0xA5);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // bit 5 CLEAR = 32 KiB mode: the pair at (bank>>1)<<1. value 0x05 ->
+ // bank 5, 32K pair = banks 4 ($8000) / 5 ($C000).
+ m.cpu_write(0x8000, 0x05);
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xC000), 5);
+ }
+
+ #[test]
+ fn m233_save_state_round_trip() {
+ let mut m = Multicart233::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x26); // bit5=1 -> 16K mode, bank 6
+ m.ppu_write(0x0004, 0x88);
+ let blob = m.save_state();
+ let mut m2 = Multicart233::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0004), 0x88);
+ }
+
+ #[test]
+ fn m46_outer_inner_prg() {
+ let mut m = new_m46(synth_prg_32k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6000, 0x02); // outer.
+ m.cpu_write(0x8000, 0x01); // inner.
+ // bank = (2 << 1) | 1 = 5.
+ assert_eq!(m.cpu_read(0x8000), 5);
+ }
+
+ #[test]
+ fn m104_golden_five_blocks() {
+ let mut m = new_m104(synth_prg_16k(32), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x08 | 0x02); // block bits -> reg0 high = 0x20.
+ m.cpu_write(0xC000, 0x05);
+ assert_eq!(m.cpu_read(0x8000), 37 % 32); // inner ((0x20)|5) = 37.
+ assert_eq!(m.cpu_read(0xC000), 47 % 32); // high|0x0F = 47.
+ }
+
+ #[test]
+ fn m290_address_decoded_mirror() {
+ let mut m = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8400, 0); // bit 0x400 -> horizontal.
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m301_address_as_data_mirror() {
+ let mut m = new_m301(synth_prg_16k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8002, 0); // addr bit 1 -> horizontal.
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn discrete_save_state_round_trip() {
+ let mut m = new_m51(synth_prg_8k(64), Box::new([]), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x6000, 0x10);
+ m.cpu_write(0xC000, 0x05);
+ m.ppu_write(0x0020, 0x7F);
+ let blob = m.save_state();
+ let mut m2 = new_m51(synth_prg_8k(64), Box::new([]), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x0020), 0x7F);
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ }
+
+ fn prg(banks_8k: usize) -> Box<[u8]> {
+ // Fill each 8 KiB bank with its index so bank routing is observable.
+ let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
+ for (i, b) in v.chunks_mut(PRG_BANK_8K).enumerate() {
+ b.fill(i as u8);
+ }
+ v.into_boxed_slice()
+ }
+
+ fn chr(banks_8k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_8k * CHR_BANK_8K];
+ for (i, b) in v.chunks_mut(CHR_BANK_8K).enumerate() {
+ b.fill(i as u8);
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m299_load_state_rejects_truncated_and_bad_version() {
+ let m = new_m299(prg(8), chr(8), Mirroring::Horizontal).unwrap();
+ let mut s = m.save_state();
+ // Truncate.
+ let mut t = m.save_state();
+ t.pop();
+ let mut m2 = new_m299(prg(8), chr(8), Mirroring::Horizontal).unwrap();
+ assert!(matches!(
+ m2.load_state(&t),
+ Err(MapperError::Truncated { .. })
+ ));
+ // Bad version.
+ s[0] = 0xFF;
+ assert!(matches!(
+ m2.load_state(&s),
+ Err(MapperError::UnsupportedVersion(0xFF))
+ ));
+ }
+
+ #[test]
+ fn m204_address_decode_selects_prg_and_chr() {
+ let mut m = new_m204(prg(16), chr(8), Mirroring::Vertical).unwrap();
+ // bitMask = addr&6 = 0; page = 0 + (addr&1) = 0.
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 0); // prg1 = 0 + (addr&1) = 0
+ // addr 0x8007: bitMask = 6 -> page = 6+0 = 6; prg1 = 6+1 = 7.
+ m.cpu_write(0x8007, 0);
+ assert_eq!(m.cpu_read(0x8000), 12, "16k page 6 -> 8k bank 12");
+ }
+
+ #[test]
+ fn m204_distinct_halves_in_bitmask6_mode() {
+ let mut m = new_m204(prg(16), chr(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8006, 0); // bitMask 6: prg0=6, prg1=7 (16 KiB pages)
+ // 16 KiB page 6 => 8 KiB bank 12 at $8000.
+ assert_eq!(m.cpu_read(0x8000), 12);
+ // 16 KiB page 7 => 8 KiB bank 14 at $C000.
+ assert_eq!(m.cpu_read(0xC000), 14);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m204_mirroring_bit() {
+ let mut m = new_m204(prg(4), chr(2), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8010, 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m299_value_decode_selects_prg_chr_mirror() {
+ let mut m = new_m299(prg(8 * 4), chr(32), Mirroring::Horizontal).unwrap();
+ // 32 KiB PRG: 4 banks of 32 KiB (32 8-KiB banks / 4). value 0x10:
+ // bank = (0x10>>4)&7 = 1; chr8 = (1<<2)|0 = 4; bit7 clear => horizontal.
+ m.cpu_write(0x8000, 0x10);
+ assert_eq!(m.cpu_read(0x8000), 4, "32k bank 1 -> 8k bank 4");
+ assert_eq!(m.ppu_read(0x0000), 4, "chr 8k bank 4");
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m299_chr_low_bits_and_mirror() {
+ let mut m = new_m299(prg(8 * 2), chr(16), Mirroring::Horizontal).unwrap();
+ // value 0x83: bank = 0; chr8 = (0<<2)|3 = 3; bit7 set => vertical.
+ m.cpu_write(0xFFFF, 0x83);
+ assert_eq!(m.cpu_read(0x8000), 0, "32k bank 0");
+ assert_eq!(m.ppu_read(0x0000), 3, "chr 8k bank 3");
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m204_m299_save_load_round_trip() {
+ // m204
+ let mut a = new_m204(prg(16), chr(8), Mirroring::Vertical).unwrap();
+ a.cpu_write(0x8006, 0);
+ let s = a.save_state();
+ let mut b = new_m204(prg(16), chr(8), Mirroring::Horizontal).unwrap();
+ b.load_state(&s).unwrap();
+ assert_eq!(a.cpu_read(0xC000), b.cpu_read(0xC000));
+ assert_eq!(a.current_mirroring(), b.current_mirroring());
+
+ // m299
+ let mut a = new_m299(prg(8 * 4), chr(32), Mirroring::Horizontal).unwrap();
+ a.cpu_write(0x8000, 0x91);
+ let s = a.save_state();
+ let mut b = new_m299(prg(8 * 4), chr(32), Mirroring::Horizontal).unwrap();
+ b.load_state(&s).unwrap();
+ assert_eq!(a.cpu_read(0x8000), b.cpu_read(0x8000));
+ assert_eq!(a.ppu_read(0x0000), b.ppu_read(0x0000));
+ assert_eq!(a.current_mirroring(), b.current_mirroring());
+ }
+
+ #[test]
+ fn m204_m299_bad_prg_size_is_rejected() {
+ // 100 bytes is not a multiple of 8 KiB.
+ assert!(
+ new_m204(
+ vec![0u8; 100].into_boxed_slice(),
+ chr(1),
+ Mirroring::Vertical
+ )
+ .is_err()
+ );
+ assert!(
+ new_m299(
+ vec![0u8; 100].into_boxed_slice(),
+ chr(1),
+ Mirroring::Vertical
+ )
+ .is_err()
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/nsf.rs b/crates/rustynes-mappers/src/nsf.rs
index 57d8964d..dd5f5606 100644
--- a/crates/rustynes-mappers/src/nsf.rs
+++ b/crates/rustynes-mappers/src/nsf.rs
@@ -751,7 +751,7 @@ impl Mapper for NsfMapper {
}
}
- fn mix_audio(&mut self) -> i16 {
+ fn mix_audio(&mut self) -> i32 {
self.exp_audio.as_ref().map_or(0, NsfExpansion::mix)
}
diff --git a/crates/rustynes-mappers/src/nsf_expansion.rs b/crates/rustynes-mappers/src/nsf_expansion.rs
index 37887c39..bf6e5ee0 100644
--- a/crates/rustynes-mappers/src/nsf_expansion.rs
+++ b/crates/rustynes-mappers/src/nsf_expansion.rs
@@ -6,12 +6,12 @@
//!
//! | Bit | Chip | Synth core (reused verbatim) |
//! |-----|---------------|--------------------------------------------|
-//! | 0 | VRC6 | [`crate::sprint3`] `Vrc6Pulse` / `Vrc6Saw` |
+//! | 0 | VRC6 | [`crate::m024_vrc6`] `Vrc6Pulse` / `Vrc6Saw` |
//! | 1 | VRC7 (OPLL) | [`rustynes_apu::Opll`] |
//! | 2 | FDS | [`crate::fds`] `FdsAudio` |
-//! | 3 | MMC5 | [`crate::mmc5`] `Mmc5Audio` |
-//! | 4 | Namco 163 | [`crate::sprint3`] `Namco163Audio` |
-//! | 5 | Sunsoft 5B | [`crate::sprint3`] `Sunsoft5BAudio` |
+//! | 3 | MMC5 | [`crate::m005_mmc5`] `Mmc5Audio` |
+//! | 4 | Namco 163 | [`crate::m019_namco163`] `Namco163Audio` |
+//! | 5 | Sunsoft 5B | [`crate::m069_sunsoft_fme7`] `Sunsoft5BAudio` |
//!
//! This module does **not** reimplement any synthesis. It is a thin router:
//! it owns instances of the existing cores, forwards the NSF register-window
@@ -42,8 +42,10 @@
)]
use crate::fds::FdsAudio;
-use crate::mmc5::{MMC5_MIX_BIAS, MMC5_PCM_SCALE, MMC5_PULSE_SCALE, Mmc5Audio};
-use crate::sprint3::{Namco163Audio, Sunsoft5BAudio, VRC6_MIX_SCALE, Vrc6Pulse, Vrc6Saw};
+use crate::m005_mmc5::{MMC5_MIX_BIAS, MMC5_PCM_SCALE, MMC5_PULSE_SCALE, Mmc5Audio};
+use crate::m019_namco163::Namco163Audio;
+use crate::m024_vrc6::{VRC6_MIX_SCALE, Vrc6Pulse, Vrc6Saw};
+use crate::m069_sunsoft_fme7::Sunsoft5BAudio;
use alloc::boxed::Box;
use alloc::vec::Vec;
@@ -57,7 +59,7 @@ const EXP_5B: u8 = 0x20;
/// VRC6 audio sub-state: the two pulse channels + sawtooth + the `$9003`
/// global control byte (halt + frequency-scale shift). Mirrors the live
-/// state the [`crate::sprint3`] `Vrc6` mapper keeps; the clock/output math
+/// state the [`crate::m024_vrc6`] `Vrc6` mapper keeps; the clock/output math
/// is reused verbatim from that mapper's channel cores.
#[derive(Default)]
struct Vrc6Exp {
@@ -304,6 +306,7 @@ impl Vrc7Exp {
}
#[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
fn clock(&mut self) {}
#[cfg(feature = "mapper-audio")]
@@ -312,6 +315,7 @@ impl Vrc7Exp {
}
#[cfg(not(feature = "mapper-audio"))]
+ #[allow(clippy::unused_self)]
fn mix(&self) -> i16 {
0
}
@@ -444,8 +448,16 @@ impl NsfExpansion {
}
}
- /// Sum every present chip's output into one signed sample.
- pub(crate) fn mix(&self) -> i16 {
+ /// Sum every live expansion chip into one sample.
+ ///
+ /// **`i32`, and no longer clamped, as of v2.2.3 (A1).** It used to return
+ /// `i16` and `clamp` into it, which was harmless while every chip fitted —
+ /// but the calibrated Sunsoft 5B reaches ~104 k at full scale (three tones
+ /// at volume 15), so an NSF 5B tune would have CLIPPED where the identical
+ /// cartridge 5B path does not. The whole point of `nsf_expansion` is that
+ /// an NSF tune sounds bit-for-bit like the cartridge, so the clamp had to
+ /// go with the widening rather than silently diverge the two paths.
+ pub(crate) fn mix(&self) -> i32 {
let mut sum: i32 = 0;
if let Some(c) = self.vrc6.as_ref() {
sum += i32::from(c.mix());
@@ -463,9 +475,11 @@ impl NsfExpansion {
sum += i32::from(c.mix());
}
if let Some(c) = self.s5b.as_ref() {
- sum += i32::from(c.mix());
+ // No conversion: `Sunsoft5BAudio::mix` returns i32 as of v2.2.3 (A1),
+ // because the calibrated 5B level overflows i16 at full scale.
+ sum += c.mix();
}
- sum.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
+ sum
}
/// Serialize the NSF save-state expansion tail: a single presence byte that
diff --git a/crates/rustynes-mappers/src/ntdec.rs b/crates/rustynes-mappers/src/ntdec.rs
new file mode 100644
index 00000000..f97935b1
--- /dev/null
+++ b/crates/rustynes-mappers/src/ntdec.rs
@@ -0,0 +1,1564 @@
+//! NTDEC boards decoded from the address bus: mappers 63 and 174.
+//!
+//! NTDEC's multicart designs consistently push the bank selection into the
+//! *address* of the write rather than its data -- the cartridge decodes which
+//! address in `$8000-$FFFF` was touched and banks accordingly. That costs
+//! nothing in discrete logic (the address lines are already there) and needs
+//! no data-bus buffer, which is why so many cheap multicarts work this way.
+//!
+//! NTDEC's later ASIC-based boards are in this module too as they are added;
+//! see also `sachen_8259.rs` for the comparable Sachen family.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_2K: usize = 0x0800;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const CHR_BANK_1K: usize = 0x0400;
+
+const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+const fn mirroring_to_byte(m: Mirroring) -> u8 {
+ match m {
+ Mirroring::Horizontal => 0,
+ Mirroring::Vertical => 1,
+ Mirroring::SingleScreenA => 2,
+ Mirroring::SingleScreenB => 3,
+ Mirroring::FourScreen => 4,
+ Mirroring::MapperControlled => 5,
+ }
+}
+
+/// Mapper 63 (NTDEC `0324` multicart).
+pub struct Ntdec63 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ prg32_mode: bool,
+ horizontal_mirroring: bool,
+}
+
+impl Ntdec63 {
+ /// Construct a new mapper 63 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ _mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 63 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ prg32_mode: true,
+ horizontal_mirroring: false,
+ })
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Ntdec63 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => {
+ let base = (self.prg_bank as usize) & if self.prg32_mode { !1 } else { !0 };
+ self.read_prg(base, addr)
+ }
+ 0xC000..=0xFFFF => {
+ let base = self.prg_bank as usize;
+ let bank = if self.prg32_mode {
+ (base & !1) | 1
+ } else {
+ base
+ };
+ self.read_prg(bank, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = ((addr >> 2) & 0x3F) as u8;
+ self.prg32_mode = (addr & 0x02) == 0;
+ self.horizontal_mirroring = (addr & 0x01) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(4 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.prg32_mode));
+ out.push(u8::from(self.horizontal_mirroring));
+ 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 = 4 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.prg32_mode = data[2] != 0;
+ self.horizontal_mirroring = data[3] != 0;
+ let mut cursor = 4;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 174 (NTDEC `5-in-1`).
+pub struct Ntdec174 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ prg16_mode: bool,
+ chr_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Ntdec174 {
+ /// Construct a new mapper 174 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 174 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 174 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ prg16_mode: false,
+ chr_bank: 0,
+ horizontal_mirroring: false,
+ })
+ }
+
+ fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = bank16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for Ntdec174 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => {
+ let base = if self.prg16_mode {
+ self.prg_bank as usize
+ } else {
+ (self.prg_bank as usize) & !1
+ };
+ self.read_prg(base, addr)
+ }
+ 0xC000..=0xFFFF => {
+ let bank = if self.prg16_mode {
+ self.prg_bank as usize
+ } else {
+ ((self.prg_bank as usize) & !1) | 1
+ };
+ self.read_prg(bank, addr)
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = ((addr >> 4) & 0x07) as u8;
+ self.prg16_mode = (addr & 0x80) != 0;
+ self.chr_bank = ((addr >> 1) & 0x07) as u8;
+ self.horizontal_mirroring = (addr & 0x01) != 0;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(5 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.prg16_mode));
+ out.push(self.chr_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 5 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.prg16_mode = data[2] != 0;
+ self.chr_bank = data[3];
+ self.horizontal_mirroring = data[4] != 0;
+ self.vram.copy_from_slice(&data[5..5 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 40 (NTDEC 2722, *SMB2J* pirate).
+pub struct Ntdec2722M40 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ switch_bank: u8,
+ irq_enabled: bool,
+ irq_counter: u16,
+ irq_pending: bool,
+ mirroring: Mirroring,
+}
+
+impl Ntdec2722M40 {
+ /// Construct a new mapper 40 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ _chr_rom: &[u8],
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 40 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ switch_bank: 0,
+ irq_enabled: false,
+ irq_counter: 0,
+ irq_pending: false,
+ mirroring,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+}
+
+impl Mapper for Ntdec2722M40 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x6000..=0x7FFF => self.read_prg(6, addr),
+ 0x8000..=0x9FFF => self.read_prg(4, addr),
+ 0xA000..=0xBFFF => self.read_prg(5, addr),
+ 0xC000..=0xDFFF => self.read_prg(self.switch_bank as usize, addr),
+ 0xE000..=0xFFFF => self.read_prg(7, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // $6000-$FFFF is mapped PRG; the $4020-$5FFF window is open bus.
+ (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x8000..=0x9FFF => {
+ // IRQ disable + acknowledge; counter held in reset.
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ self.irq_counter = 0;
+ }
+ 0xA000..=0xBFFF => self.irq_enabled = true,
+ 0xE000..=0xFFFF => self.switch_bank = value & 0x07,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ if !self.irq_enabled {
+ return;
+ }
+ // 12-bit M2 counter; asserts (and holds) at 4096.
+ if self.irq_counter >= 0x1000 {
+ self.irq_pending = true;
+ } else {
+ self.irq_counter += 1;
+ if self.irq_counter >= 0x1000 {
+ self.irq_pending = true;
+ }
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.irq_pending = false;
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(6 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.switch_bank);
+ out.push(u8::from(self.irq_enabled));
+ out.push((self.irq_counter & 0xFF) as u8);
+ out.push((self.irq_counter >> 8) as u8);
+ out.push(u8::from(self.irq_pending));
+ 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 = 6 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.switch_bank = data[1];
+ self.irq_enabled = data[2] != 0;
+ self.irq_counter = u16::from(data[3]) | (u16::from(data[4]) << 8);
+ self.irq_pending = data[5] != 0;
+ let mut cursor = 6;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ self.chr_ram
+ .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 81 — NTDEC Super Gun (CNROM-like with a wider PRG select).
+//
+// A single $8000-$FFFF register; the written byte carries:
+// bits 2-3 : 16 KiB PRG bank at $8000 (the $C000 half is fixed to the last).
+// bits 0-1 : 8 KiB CHR bank.
+// Mirroring is header-fixed. No IRQ.
+// ===========================================================================
+
+/// Mapper 81 (NTDEC Super Gun).
+pub struct Ntdec81 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Ntdec81 {
+ /// Construct a new mapper 81 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 81 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 81 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Ntdec81 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ match addr {
+ 0x8000..=0xBFFF => {
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ 0xC000..=0xFFFF => {
+ let last = count - 1;
+ self.prg_rom[last * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.prg_bank = (value >> 2) & 0x03;
+ self.chr_bank = value & 0x03;
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Mapper 112 (NTDEC ASDER / Huang-1).
+pub struct NtdecAsder112 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ reg_index: u8,
+ prg_banks: [u8; 2],
+ // chr[0..2] = 2 KiB selects; chr[2..6] = 1 KiB selects.
+ chr_regs: [u8; 6],
+ horizontal_mirroring: bool,
+}
+
+impl NtdecAsder112 {
+ /// Construct a new mapper 112 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 8 KiB or CHR-ROM is empty / 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_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 112 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 112 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ reg_index: 0,
+ prg_banks: [0, 1],
+ chr_regs: [0; 6],
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+
+ fn read_prg(&self, bank: usize, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
+ let bank = bank % count;
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+
+ fn read_chr(&self, addr: u16) -> u8 {
+ let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let bank1k = match addr {
+ 0x0000..=0x07FF => (self.chr_regs[0] as usize & !1) + ((addr as usize >> 10) & 1),
+ 0x0800..=0x0FFF => (self.chr_regs[1] as usize & !1) + ((addr as usize >> 10) & 1),
+ 0x1000..=0x13FF => self.chr_regs[2] as usize,
+ 0x1400..=0x17FF => self.chr_regs[3] as usize,
+ 0x1800..=0x1BFF => self.chr_regs[4] as usize,
+ _ => self.chr_regs[5] as usize,
+ };
+ let bank = bank1k % count1k;
+ self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
+ }
+}
+
+impl Mapper for NtdecAsder112 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
+ match addr {
+ 0x8000..=0x9FFF => self.read_prg(self.prg_banks[0] as usize, addr),
+ 0xA000..=0xBFFF => self.read_prg(self.prg_banks[1] as usize, addr),
+ 0xC000..=0xDFFF => self.read_prg(last - 1, addr),
+ 0xE000..=0xFFFF => self.read_prg(last, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xE001 {
+ 0x8000 => self.reg_index = value & 0x07,
+ 0xA000 => match self.reg_index {
+ 0 => self.prg_banks[0] = value,
+ 1 => self.prg_banks[1] = value,
+ 2 => self.chr_regs[0] = value,
+ 3 => self.chr_regs[1] = value,
+ 4 => self.chr_regs[2] = value,
+ 5 => self.chr_regs[3] = value,
+ 6 => self.chr_regs[4] = value,
+ _ => self.chr_regs[5] = value,
+ },
+ 0xE000 => self.horizontal_mirroring = (value & 0x01) != 0,
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(11 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.reg_index);
+ out.extend_from_slice(&self.prg_banks);
+ out.extend_from_slice(&self.chr_regs);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 11 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg_index = data[1];
+ self.prg_banks.copy_from_slice(&data[2..4]);
+ self.chr_regs.copy_from_slice(&data[4..10]);
+ self.horizontal_mirroring = data[10] != 0;
+ self.vram.copy_from_slice(&data[11..11 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
+fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.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.len()
+ )));
+ }
+ Ok(())
+}
+
+/// Allocate the CHR slice, falling back to an 8 KiB CHR-RAM bank when the ROM
+/// ships no CHR-ROM. Returns `(chr, is_ram)`.
+fn chr_or_ram(chr_rom: Box<[u8]>) -> (Box<[u8]>, bool) {
+ if chr_rom.is_empty() {
+ (vec![0u8; CHR_BANK_8K].into_boxed_slice(), true)
+ } else {
+ (chr_rom, false)
+ }
+}
+
+// ===========================================================================
+// NtdecTc112 (mapper 193) — NTDEC TC-112 (*Fighting Hero*).
+//
+// PRG: 8 KiB pages. The last three 8 KiB windows ($A000/$C000/$E000) are fixed
+// to the final three banks; $8000 is the one switchable window (register 3).
+// CHR: 2 KiB pages. Register 0 selects a paired 2 KiB window into the first two
+// slots ($0000 + $0800), register 1 the third ($1000), register 2 the fourth
+// ($1800). Registers live at $6000-$7FFF (addr & 3). Ported from Mesen2
+// Ntdec/NtdecTc112.h.
+// ===========================================================================
+
+/// NTDEC TC-112 (mapper 193).
+pub struct NtdecTc112 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ /// Switchable 8 KiB PRG window for $8000.
+ prg0: usize,
+ /// 2 KiB CHR windows for $0000/$0800/$1000/$1800.
+ chr2: [usize; 4],
+}
+
+impl NtdecTc112 {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 193)?;
+ let (chr, chr_is_ram) = chr_or_ram(chr_rom);
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ prg0: 0,
+ chr2: [0; 4],
+ })
+ }
+
+ fn prg_count_8k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_8K).max(1)
+ }
+
+ fn chr_count_2k(&self) -> usize {
+ (self.chr.len() / CHR_BANK_2K).max(1)
+ }
+}
+
+impl Mapper for NtdecTc112 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // The final three 8 KiB windows are fixed to the last three banks.
+ 0x8000..=0xFFFF => {
+ let count = self.prg_count_8k();
+ let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
+ let bank = match slot {
+ 0 => self.prg0 % count,
+ // `saturating_sub` guards a malformed sub-4-bank PRG image
+ // (the subtraction would otherwise underflow + panic).
+ _ => count.saturating_sub(4 - slot) % count, // last-3 fixed window
+ };
+ self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x6000..=0x7FFF).contains(&addr) {
+ match addr & 0x03 {
+ 0 => {
+ // Paired 2 KiB CHR select into slots 0 and 1.
+ self.chr2[0] = (value >> 1) as usize;
+ self.chr2[1] = (value >> 1) as usize + 1;
+ }
+ 1 => self.chr2[2] = (value >> 1) as usize,
+ 2 => self.chr2[3] = (value >> 1) as usize,
+ _ => self.prg0 = value as usize,
+ }
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + 4 + 16 + 1 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&(self.prg0 as u32).to_le_bytes());
+ for c in &self.chr2 {
+ out.extend_from_slice(&(*c as u32).to_le_bytes());
+ }
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + 4 + 16 + 1 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let rd = |c: usize| {
+ u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
+ };
+ let mut c = 1;
+ self.prg0 = rd(c);
+ c += 4;
+ for s in &mut self.chr2 {
+ *s = rd(c);
+ c += 4;
+ }
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 193 (NTDEC TC-112, *Fighting Hero*).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+pub fn new_m193(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ NtdecTc112::new(prg_rom, chr_rom, mirroring)
+}
+
+// ===========================================================================
+// Bmc204 (mapper 204) — discrete NROM/UNROM 2-in-1 BMC multicart.
+//
+// The written *address* low bits select the layout: `bitMask = addr & 0x06`
+// gives the 16 KiB PRG block, and (when bitMask != 0x06) `addr & 1` picks the
+// inner half. Both PRG windows ($8000 + $C000) and the 8 KiB CHR window track
+// the decoded page; `addr & 0x10` flips the mirroring. Ported from Mesen2
+// Unlicensed/Mapper204.h.
+// ===========================================================================
+
+/// NTDEC N625092 multicart (mapper 221).
+pub struct NtdecN625092 {
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ mode: u16,
+ prg_reg: u8,
+ /// 16 KiB PRG windows for $8000 and $C000.
+ prg0: usize,
+ prg1: usize,
+}
+
+impl NtdecN625092 {
+ fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_prg(&prg_rom, 221)?;
+ if prg_rom.len() < PRG_BANK_16K {
+ return Err(MapperError::Invalid(format!(
+ "mapper 221 PRG-ROM size {} is smaller than one 16 KiB bank",
+ prg_rom.len()
+ )));
+ }
+ let (chr, chr_is_ram) = chr_or_ram(chr_rom);
+ let mut m = Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ mode: 0,
+ prg_reg: 0,
+ prg0: 0,
+ prg1: 0,
+ };
+ m.update_state();
+ Ok(m)
+ }
+
+ fn prg_count_16k(&self) -> usize {
+ (self.prg_rom.len() / PRG_BANK_16K).max(1)
+ }
+
+ fn update_state(&mut self) {
+ let outer = ((self.mode & 0xFC) >> 2) as usize;
+ let reg = self.prg_reg as usize;
+ if self.mode & 0x02 != 0 {
+ if self.mode & 0x0100 != 0 {
+ // NROM-256 sub-case: switchable low + fixed (outer | 7) high.
+ self.prg0 = outer | reg;
+ self.prg1 = outer | 0x07;
+ } else {
+ // UNROM 2x16 KiB aligned pair (SelectPrgPage2x): the inner reg
+ // (masked to even) selects a 32 KiB-aligned window.
+ let b = outer | (reg & 0x06);
+ self.prg0 = b;
+ self.prg1 = b | 1;
+ }
+ } else {
+ // NROM: both windows mirror the same bank.
+ self.prg0 = outer | reg;
+ self.prg1 = outer | reg;
+ }
+ self.mirroring = if self.mode & 0x01 != 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ };
+ }
+
+ fn prg_byte(&self, slot16: usize, addr: u16) -> u8 {
+ let count = self.prg_count_16k();
+ let bank = slot16 % count;
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for NtdecN625092 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xBFFF => self.prg_byte(self.prg0, addr),
+ 0xC000..=0xFFFF => self.prg_byte(self.prg1, addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ match addr & 0xC000 {
+ 0x8000 => {
+ self.mode = addr;
+ self.update_state();
+ }
+ 0xC000 => {
+ self.prg_reg = (addr & 0x07) as u8;
+ self.update_state();
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(1 + 2 + 1 + 8 + 1 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.mode.to_le_bytes());
+ out.push(self.prg_reg);
+ out.extend_from_slice(&(self.prg0 as u32).to_le_bytes());
+ out.extend_from_slice(&(self.prg1 as u32).to_le_bytes());
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 1 + 2 + 1 + 8 + 1 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let rd = |c: usize| {
+ u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
+ };
+ self.mode = u16::from_le_bytes([data[1], data[2]]);
+ self.prg_reg = data[3];
+ self.prg0 = rd(4);
+ self.prg1 = rd(8);
+ let mut c = 12;
+ self.mirroring = byte_to_mirroring(data[c], self.mirroring);
+ c += 1;
+ self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
+ c += self.vram.len();
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+/// Mapper 221 (NTDEC N625092 multicart).
+///
+/// # Errors
+/// [`MapperError::Invalid`] on a bad PRG size.
+pub fn new_m221(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+) -> Result {
+ NtdecN625092::new(prg_rom, chr_rom, mirroring)
+}
+
+// ===========================================================================
+// Bmc11160 (mapper 299) — TXC/BMC-11160 multicart.
+//
+// One value-decoded $8000-$FFFF register: bits 4-6 select a 32 KiB PRG bank,
+// the 8 KiB CHR bank is `(bank << 2) | (value & 0x03)`, and bit 7 flips the
+// mirroring (set => vertical). Ported from Mesen2 Txc/Bmc11160.h.
+// ===========================================================================
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+ /// 1 KiB-banked CHR: byte 0 of each 1 KiB bank holds the bank index.
+ fn synth_chr_1k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_1K];
+ for b in 0..banks {
+ v[b * CHR_BANK_1K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
+ for b in 0..banks {
+ v[b * PRG_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m63_address_decoded_bank_and_mode() {
+ let mut m = Ntdec63::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ // 32 KiB mode: A & 2 == 0. bank = (A>>2)&0x3F. Choose A=0x8008 ->
+ // (0x8008>>2)&0x3F = 0x02 -> bank 2; (A&2)==0 -> 32K; (A&1)==0 -> V.
+ m.cpu_write(0x8008, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 3); // 32K high half = bank|1
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m63_save_state_round_trip() {
+ let mut m = Ntdec63::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8009, 0); // A&1 == 1 -> horizontal
+ m.ppu_write(0x0010, 0x12);
+ let blob = m.save_state();
+ let mut m2 = Ntdec63::new(synth_prg_16k(16), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ assert_eq!(m2.ppu_read(0x0010), 0x12);
+ }
+
+ #[test]
+ fn m174_address_decoded_prg_chr_mirror() {
+ let mut m = Ntdec174::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // A = 0x8020: prg = (0x20>>4)&7 = 2; (A&0x80)==0 -> 32K; chr =
+ // (0x20>>1)&7 = 0; (A&1)==0 -> V.
+ m.cpu_write(0x8020, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m174_save_state_round_trip() {
+ let mut m = Ntdec174::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x80A3, 0); // 16K mode + chr select + horizontal
+ let blob = m.save_state();
+ let mut m2 = Ntdec174::new(synth_prg_16k(8), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.current_mirroring(), m.current_mirroring());
+ }
+
+ #[test]
+ fn m40_fixed_layout_and_switchable_window() {
+ let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
+ // Fixed banks.
+ assert_eq!(m.cpu_read(0x8000), 4);
+ assert_eq!(m.cpu_read(0xA000), 5);
+ assert_eq!(m.cpu_read(0xE000), 7);
+ // Switch $C000 to bank 3.
+ m.cpu_write(0xE000, 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ }
+
+ #[test]
+ fn m40_irq_fires_after_enable() {
+ let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
+ assert!(!m.irq_pending());
+ m.cpu_write(0xA000, 0); // enable
+ for _ in 0..0x1000 {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending());
+ m.cpu_write(0x8000, 0); // disable + ack
+ assert!(!m.irq_pending());
+ }
+
+ #[test]
+ fn m40_save_state_round_trip() {
+ let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
+ m.cpu_write(0xE000, 2);
+ m.cpu_write(0xA000, 0);
+ m.notify_cpu_cycle();
+ m.ppu_write(0x0005, 0x9A);
+ let blob = m.save_state();
+ let mut m2 = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xC000), 2);
+ assert_eq!(m2.ppu_read(0x0005), 0x9A);
+ }
+
+ #[test]
+ fn m81_prg_and_chr_select() {
+ let mut m = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // PRG bits 2-3 = 2, CHR bits 0-1 = 1. value = (2<<2)|1 = 0x09.
+ m.cpu_write(0x8000, 0x09);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // $C000 fixed to last (7).
+ assert_eq!(m.cpu_read(0xC000), 7);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn m81_save_state_round_trip() {
+ let mut m = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0x06);
+ let blob = m.save_state();
+ let mut m2 = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ }
+
+ #[test]
+ fn m112_indexed_prg_chr_and_mirroring() {
+ let mut m =
+ NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ // Select reg 0 (PRG $8000) -> bank 3.
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0xA000, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Select reg 1 (PRG $A000) -> bank 2.
+ m.cpu_write(0x8000, 1);
+ m.cpu_write(0xA000, 2);
+ assert_eq!(m.cpu_read(0xA000), 2);
+ // Mirroring register.
+ m.cpu_write(0xE000, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // Fixed last two.
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+
+ #[test]
+ fn m112_save_state_round_trip() {
+ let mut m =
+ NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0xA000, 5);
+ m.cpu_write(0xE000, 1);
+ let blob = m.save_state();
+ let mut m2 =
+ NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ fn prg(banks_8k: usize) -> Box<[u8]> {
+ // Fill each 8 KiB bank with its index so bank routing is observable.
+ let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
+ for (i, b) in v.chunks_mut(PRG_BANK_8K).enumerate() {
+ b.fill(i as u8);
+ }
+ v.into_boxed_slice()
+ }
+
+ fn chr(banks_8k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_8k * CHR_BANK_8K];
+ for (i, b) in v.chunks_mut(CHR_BANK_8K).enumerate() {
+ b.fill(i as u8);
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m193_last_three_prg_windows_are_fixed() {
+ // 8 banks of 8 KiB. The last three 8 KiB windows must be fixed to the
+ // last three banks (5,6,7) regardless of the switchable $8000 select.
+ let mut m = new_m193(prg(8), chr(4), Mirroring::Vertical).unwrap();
+ // $8000 defaults to bank 0.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xA000), 5, "$A000 fixed to last-3");
+ assert_eq!(m.cpu_read(0xC000), 6, "$C000 fixed to last-3");
+ assert_eq!(m.cpu_read(0xE000), 7, "$E000 fixed to last-3");
+ // Register 3 selects the switchable $8000 8 KiB window.
+ m.cpu_write(0x6003, 3);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ // Fixed windows are unaffected.
+ assert_eq!(m.cpu_read(0xE000), 7);
+ }
+
+ #[test]
+ fn m193_chr_registers_select_2k_windows() {
+ let mut m = new_m193(prg(2), chr(4), Mirroring::Vertical).unwrap();
+ // reg0: paired 2 KiB select into slots 0+1. value 4 => (4>>1)=2 / 3.
+ m.cpu_write(0x6000, 4);
+ // chr bank N (2 KiB) of a 4x8KiB image => 16 2-KiB banks; bank 2 lives in
+ // 8 KiB CHR bank 0 (banks 0..3) so its byte == 0.
+ assert_eq!(m.ppu_read(0x0000), 0); // slot0 -> 2k bank 2 -> 8k bank0
+ // reg1 -> slot2 ($1000); reg2 -> slot3 ($1800).
+ m.cpu_write(0x6001, 8); // (8>>1)=4 -> 2k bank4 -> 8k bank1
+ assert_eq!(m.ppu_read(0x1000), 1);
+ m.cpu_write(0x6002, 12); // (12>>1)=6 -> 2k bank6 -> 8k bank1
+ assert_eq!(m.ppu_read(0x1800), 1);
+ }
+
+ #[test]
+ fn m193_chr_ram_when_no_chr_rom() {
+ let mut m = new_m193(prg(2), Box::new([]), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x0123, 0xAB);
+ assert_eq!(m.ppu_read(0x0123), 0xAB);
+ }
+
+ #[test]
+ fn m221_nrom_mode_mirrors_both_windows() {
+ let mut m = new_m221(prg(16), chr(1), Mirroring::Vertical).unwrap();
+ // mode = $8000 (mode&2 == 0 => NROM); outer = 0; prg_reg default 0.
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0xC003, 0); // inner reg = 3
+ // NROM: both windows == outer|reg == 3.
+ assert_eq!(m.cpu_read(0x8000), 6, "16k page 3 -> 8k bank 6");
+ assert_eq!(m.cpu_read(0xC000), 6);
+ }
+
+ #[test]
+ fn m221_unrom_nrom256_subcase() {
+ let mut m = new_m221(prg(16), chr(1), Mirroring::Vertical).unwrap();
+ // mode bits: set bit1 (UNROM) and bit8 (NROM-256 sub-case).
+ // addr = 0x8000 | 0x0102 = 0x8102.
+ m.cpu_write(0x8102, 0);
+ m.cpu_write(0xC002, 0); // inner reg = 2
+ // outer = (0x0102 & 0xFC) >> 2 = 0x00. prg0 = 0|2 = 2; prg1 = 0|7 = 7.
+ assert_eq!(m.cpu_read(0x8000), 4, "16k page 2 -> 8k bank 4");
+ assert_eq!(m.cpu_read(0xC000), 14, "16k page 7 -> 8k bank 14");
+ }
+
+ #[test]
+ fn m221_mirroring_bit() {
+ let mut m = new_m221(prg(4), chr(1), Mirroring::Horizontal).unwrap();
+ m.cpu_write(0x8001, 0); // mode&1 set => horizontal
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0x8000, 0); // mode&1 clear => vertical
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
+
+ #[test]
+ fn m193_m221_save_load_round_trip() {
+ // m193
+ let mut a = new_m193(prg(8), chr(4), Mirroring::Vertical).unwrap();
+ a.cpu_write(0x6003, 5);
+ a.cpu_write(0x6000, 6);
+ let s = a.save_state();
+ let mut b = new_m193(prg(8), chr(4), Mirroring::Vertical).unwrap();
+ b.load_state(&s).unwrap();
+ assert_eq!(a.cpu_read(0x8000), b.cpu_read(0x8000));
+ assert_eq!(a.ppu_read(0x0000), b.ppu_read(0x0000));
+
+ // m221
+ let mut a = new_m221(prg(16), chr(1), Mirroring::Vertical).unwrap();
+ a.cpu_write(0x8102, 0);
+ a.cpu_write(0xC002, 0);
+ let s = a.save_state();
+ let mut b = new_m221(prg(16), chr(1), Mirroring::Horizontal).unwrap();
+ b.load_state(&s).unwrap();
+ assert_eq!(a.cpu_read(0x8000), b.cpu_read(0x8000));
+ assert_eq!(a.cpu_read(0xC000), b.cpu_read(0xC000));
+ }
+
+ #[test]
+ fn m193_m221_bad_prg_size_is_rejected() {
+ // 100 bytes is not a multiple of 8 KiB.
+ assert!(
+ new_m193(
+ vec![0u8; 100].into_boxed_slice(),
+ chr(1),
+ Mirroring::Vertical
+ )
+ .is_err()
+ );
+ assert!(
+ new_m221(
+ vec![0u8; 100].into_boxed_slice(),
+ chr(1),
+ Mirroring::Vertical
+ )
+ .is_err()
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/sachen_8259.rs b/crates/rustynes-mappers/src/sachen_8259.rs
new file mode 100644
index 00000000..e976e9ae
--- /dev/null
+++ b/crates/rustynes-mappers/src/sachen_8259.rs
@@ -0,0 +1,570 @@
+//! Sachen 8259 ASIC (mapper 137 and the wider 8259 A/B/C/D family).
+//!
+//! Sachen's reusable bank-select ASIC, and a step up from the company's
+//! discrete boards in `sachen_discrete.rs`: instead of a single latch it
+//! implements an address-then-data register protocol through the
+//! `$4100-$5FFF` window, with eight internal registers. The four die
+//! revisions differ only in how the CHR bank bits are shuffled on the way
+//! out, which is why the variants share one implementation and differ by a
+//! bit-permutation rather than by separate decode paths.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_2K: usize = 0x0800;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
+ match b {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => fallback,
+ }
+}
+
+const fn mirroring_to_byte(m: Mirroring) -> u8 {
+ match m {
+ Mirroring::Horizontal => 0,
+ Mirroring::Vertical => 1,
+ Mirroring::SingleScreenA => 2,
+ Mirroring::SingleScreenB => 3,
+ Mirroring::FourScreen => 4,
+ Mirroring::MapperControlled => 5,
+ }
+}
+
+/// Mapper 137 (Sachen 8259D).
+pub struct Sachen8259M137 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ cmd: u8,
+ chr_banks: [u8; 4],
+ chr_outer: u8,
+ prg_bank: u8,
+ horizontal_mirroring: bool,
+}
+
+impl Sachen8259M137 {
+ /// Construct a new mapper 137 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is empty / not a multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 2 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 137 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_2K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 137 CHR-ROM size {} is not a non-zero multiple of 2 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ cmd: 0,
+ chr_banks: [0; 4],
+ chr_outer: 0,
+ prg_bank: 0,
+ horizontal_mirroring: mirroring == Mirroring::Horizontal,
+ })
+ }
+
+ fn read_chr(&self, addr: u16) -> u8 {
+ let count2k = (self.chr_rom.len() / CHR_BANK_2K).max(1);
+ let slot = (addr as usize >> 11) & 0x03;
+ let bank = (self.chr_banks[slot] as usize | ((self.chr_outer as usize) << 4)) % count2k;
+ self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)]
+ }
+}
+
+impl Mapper for Sachen8259M137 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // $4100/$4101 are write-only registers; the rest of $4020-$5FFF is open
+ // bus. $8000-$FFFF is mapped PRG.
+ (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x4100 => self.cmd = value & 0x07,
+ 0x4101 => match self.cmd {
+ 0..=3 => self.chr_banks[self.cmd as usize] = value & 0x07,
+ 4 => self.chr_outer = value & 0x07,
+ 5 => self.prg_bank = value & 0x07,
+ 7 => self.horizontal_mirroring = (value & 0x01) != 0,
+ _ => {}
+ },
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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) {
+ let off = nametable_offset(addr, self.current_mirroring());
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.horizontal_mirroring {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(9 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.cmd);
+ out.extend_from_slice(&self.chr_banks);
+ out.push(self.chr_outer);
+ out.push(self.prg_bank);
+ out.push(u8::from(self.horizontal_mirroring));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 9 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.cmd = data[1];
+ self.chr_banks.copy_from_slice(&data[2..6]);
+ self.chr_outer = data[6];
+ self.prg_bank = data[7];
+ self.horizontal_mirroring = data[8] != 0;
+ self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// Which Sachen 8259 variant (CHR shift + OR constants).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Sachen8259Variant {
+ /// 8259A (mapper 141): shift 1, CHR-OR [1, 0, 1].
+ A,
+ /// 8259B (mapper 138): shift 0, CHR-OR [0, 0, 0].
+ B,
+ /// 8259C (mapper 139): shift 2, CHR-OR [1, 2, 3].
+ C,
+}
+
+impl Sachen8259Variant {
+ const fn shift(self) -> u8 {
+ match self {
+ Self::A => 1,
+ Self::B => 0,
+ Self::C => 2,
+ }
+ }
+ const fn chr_or(self) -> [usize; 3] {
+ match self {
+ Self::A => [1, 0, 1],
+ Self::B => [0, 0, 0],
+ Self::C => [1, 2, 3],
+ }
+ }
+}
+
+/// Sachen 8259 A/B/C (mappers 141 / 138 / 139). 32 KiB PRG + 2 KiB CHR banks.
+pub struct Sachen8259 {
+ variant: Sachen8259Variant,
+ prg_rom: Box<[u8]>,
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ vram: Box<[u8]>,
+ regs: [u8; 8],
+ current_reg: u8,
+ mirroring: Mirroring,
+}
+
+const CHR_2K: usize = 0x0800;
+
+impl Sachen8259 {
+ /// Construct a Sachen 8259 A/B/C board.
+ ///
+ /// # Errors
+ /// [`MapperError::Invalid`] on a bad PRG/CHR size.
+ pub fn new(
+ variant: Sachen8259Variant,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
+ return Err(MapperError::Invalid(format!(
+ "Sachen 8259 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else {
+ if !chr_rom.len().is_multiple_of(CHR_2K) {
+ return Err(MapperError::Invalid(format!(
+ "Sachen 8259 CHR-ROM size {} is not a multiple of 2 KiB",
+ chr_rom.len()
+ )));
+ }
+ chr_rom
+ };
+ Ok(Self {
+ variant,
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; 4 * NAMETABLE_SIZE].into_boxed_slice(),
+ regs: [0; 8],
+ current_reg: 0,
+ mirroring,
+ })
+ }
+
+ fn update_mirroring(&mut self) {
+ let simple = self.regs[7] & 0x01 == 0x01;
+ self.mirroring = match (self.regs[7] >> 1) & 0x03 {
+ 0 => Mirroring::Vertical,
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::SingleScreenB,
+ _ => Mirroring::SingleScreenA,
+ };
+ if simple {
+ self.mirroring = Mirroring::Vertical;
+ }
+ }
+
+ /// Resolve the 2 KiB CHR bank for slot 0..=3.
+ fn chr_bank(&self, slot: usize) -> usize {
+ let simple = self.regs[7] & 0x01 == 0x01;
+ let shift = self.variant.shift();
+ let chr_or = self.variant.chr_or();
+ let chr_high = (self.regs[4] as usize) << 3;
+ match slot {
+ 0 => (chr_high | self.regs[0] as usize) << shift,
+ 1 => ((chr_high | self.regs[if simple { 0 } else { 1 }] as usize) << shift) | chr_or[0],
+ 2 => ((chr_high | self.regs[if simple { 0 } else { 2 }] as usize) << shift) | chr_or[1],
+ _ => ((chr_high | self.regs[if simple { 0 } else { 3 }] as usize) << shift) | chr_or[2],
+ }
+ }
+}
+
+impl Mapper for Sachen8259 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.regs[5] as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ (0x4020..=0x7FFF).contains(&addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xC101 {
+ 0x4100 => self.current_reg = value & 0x07,
+ 0x4101 => {
+ self.regs[(self.current_reg & 0x07) as usize] = value & 0x07;
+ self.update_mirroring();
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ let off = addr as usize & (self.chr.len() - 1);
+ self.chr[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(11 + self.vram.len() + chr_ram);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.current_reg);
+ out.extend_from_slice(&self.regs);
+ out.push(mirroring_to_byte(self.mirroring));
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let expected = 11 + self.vram.len() + chr_ram;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.current_reg = data[1];
+ self.regs.copy_from_slice(&data[2..10]);
+ self.mirroring = byte_to_mirroring(data[10], self.mirroring);
+ let mut cursor = 11;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr
+ .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_2k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_2K];
+ for b in 0..banks {
+ v[b * CHR_BANK_2K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m137_command_data_chr_and_prg() {
+ let mut m =
+ Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ // cmd 5 -> PRG 32 KiB bank 2.
+ m.cpu_write(0x4100, 5);
+ m.cpu_write(0x4101, 2);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // cmd 0 -> CHR slot 0 = bank 3.
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4101, 3);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ // cmd 7 -> horizontal mirroring.
+ m.cpu_write(0x4100, 7);
+ m.cpu_write(0x4101, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m137_save_state_round_trip() {
+ let mut m =
+ Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4100, 5);
+ m.cpu_write(0x4101, 1);
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4101, 2);
+ let blob = m.save_state();
+ let mut m2 =
+ Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ }
+
+ #[test]
+ fn sachen8259_prg_and_reg_protocol() {
+ let mut m = Sachen8259::new(
+ Sachen8259Variant::B,
+ synth_prg_32k(4),
+ synth_chr_2k(16),
+ Mirroring::Vertical,
+ )
+ .unwrap();
+ m.cpu_write(0x4100, 5);
+ m.cpu_write(0x4101, 2);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ m.cpu_write(0x4100, 7);
+ m.cpu_write(0x4101, 2); // reg7 = 2 -> mirroring bits (2>>1)&3 == 1 -> horizontal.
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn sachen8259_variants_differ_by_shift() {
+ let mut b = Sachen8259::new(
+ Sachen8259Variant::B,
+ synth_prg_32k(2),
+ synth_chr_2k(16),
+ Mirroring::Vertical,
+ )
+ .unwrap();
+ b.cpu_write(0x4100, 0);
+ b.cpu_write(0x4101, 1);
+ let mut a = Sachen8259::new(
+ Sachen8259Variant::A,
+ synth_prg_32k(2),
+ synth_chr_2k(16),
+ Mirroring::Vertical,
+ )
+ .unwrap();
+ a.cpu_write(0x4100, 0);
+ a.cpu_write(0x4101, 1);
+ assert_eq!(b.ppu_read(0x0000), 1); // shift 0.
+ assert_eq!(a.ppu_read(0x0000), 2); // shift 1.
+ }
+
+ #[test]
+ fn sachen8259_save_state_round_trip() {
+ let mut m = Sachen8259::new(
+ Sachen8259Variant::C,
+ synth_prg_32k(4),
+ synth_chr_2k(32),
+ Mirroring::Vertical,
+ )
+ .unwrap();
+ m.cpu_write(0x4100, 5);
+ m.cpu_write(0x4101, 3);
+ m.cpu_write(0x4100, 4);
+ m.cpu_write(0x4101, 1);
+ let blob = m.save_state();
+ let mut m2 = Sachen8259::new(
+ Sachen8259Variant::C,
+ synth_prg_32k(4),
+ synth_chr_2k(32),
+ Mirroring::Vertical,
+ )
+ .unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
+ assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
+ }
+}
diff --git a/crates/rustynes-mappers/src/sachen_discrete.rs b/crates/rustynes-mappers/src/sachen_discrete.rs
new file mode 100644
index 00000000..89975e22
--- /dev/null
+++ b/crates/rustynes-mappers/src/sachen_discrete.rs
@@ -0,0 +1,1605 @@
+//! Sachen discrete boards addressed in the `$4100-$5FFF` expansion window:
+//! mappers 133, 145 and 146.
+//!
+//! Sachen's unlicensed boards consistently decode on address line A8 rather
+//! than in the `$8000-$FFFF` ROM window -- a way of avoiding bus conflicts
+//! without gating logic, since nothing else drives the bus down there. The
+//! three differ only in which bits of the written byte become the CHR select:
+//! mapper 145 takes bit 7 alone, and mapper 146 is electrically the same board
+//! as AVE's `NINA-03` (mapper 79), which is why its decode mirrors
+//! `ave_nina.rs`.
+//!
+//! Sachen's later 8259 ASIC family is a different design; see
+//! `sachen_8259.rs`.
+//!
+//! A best-effort (Tier-2) board: register-decode correctness verified against
+//! the `GeraNES` reference (`ref-proj/GeraNES/src/GeraNES/Mappers/Mapper0NN.h`)
+//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
+//! is direct slice indexing and every bank select wraps with `% count`, so a
+//! register write can never index out of bounds -- required for the `#![no_std]`
+//! chip stack, which cannot afford a panic on a register access.
+//!
+//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
+//! and `docs/mappers.md` §Mapper coverage matrix.
+
+#![allow(
+ clippy::bool_to_int_with_if,
+ clippy::cast_lossless,
+ clippy::cast_possible_truncation,
+ clippy::doc_markdown,
+ clippy::match_same_arms,
+ clippy::missing_const_for_fn,
+ clippy::similar_names,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines,
+ clippy::unreadable_literal
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperError};
+use alloc::{boxed::Box, vec::Vec};
+use alloc::{format, vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const NAMETABLE_SIZE_U16: u16 = 0x0400;
+
+const SAVE_STATE_VERSION: u8 = 1;
+
+// ---------------------------------------------------------------------------
+// Shared nametable helper (mirrors the one in the other simple-mapper modules).
+// ---------------------------------------------------------------------------
+
+const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ let physical = mirroring.physical_bank(table);
+ physical * NAMETABLE_SIZE + local
+}
+
+/// Mapper 133 (Sachen 3009).
+pub struct Sachen133 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Sachen133 {
+ /// Construct a new mapper 133 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 133 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 133 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Sachen133 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x4100..=0x5FFF).contains(&addr) && (addr & 0x0100) != 0 {
+ self.prg_bank = (value >> 2) & 0x01;
+ self.chr_bank = value & 0x03;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 145 — Sachen SA-72007.
+//
+// A single CHR-bank bit (the high data bit) is decoded when the address
+// satisfies (absolute & 0x4100) == 0x4100, in BOTH the $4100 register window
+// and the $6000 save-RAM window:
+// CHR (8 KiB) = (value >> 7) & 0x01
+// PRG is a fixed 32 KiB (bank 0). Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 145 (Sachen `SA-72007`).
+pub struct Sachen145 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Sachen145 {
+ /// Construct a new mapper 145 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is empty / not a multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ // Real SA-72007 dumps (e.g. "Sidewinder") are 16 KiB PRG / NROM-128-style
+ // — the fixed bank is simply mirrored across the 32 KiB CPU window. Accept
+ // any non-zero 16 KiB multiple (16 KiB mirrors; 32 KiB maps 1:1).
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 145 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 145 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+}
+
+impl Mapper for Sachen145 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Fixed bank 0, mirrored across the 32 KiB window for sub-32 KiB PRG.
+ self.prg_rom[(addr as usize - 0x8000) % self.prg_rom.len()]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // CHR bank decoded when (addr & 0x4100) == 0x4100 in both the register
+ // ($4100-$5FFF) and save-RAM ($6000-$7FFF) windows.
+ if (0x4100..=0x7FFF).contains(&addr) && (addr & 0x4100) == 0x4100 {
+ self.chr_bank = (value >> 7) & 0x01;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 2 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.chr_bank = data[1];
+ self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 146 — Sachen (mapper-79-equivalent behaviour).
+//
+// Identical decode to NINA-03 (mapper 79) but Sachen wired the register into
+// the $4100-$5FFF window decoded on A8 AND aliased into the $6000-$7FFF
+// save-RAM window (offset by $2000). The byte selects:
+// PRG (32 KiB) = (value >> 3) & 0x01
+// CHR (8 KiB) = value & 0x07
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 146 (Sachen, `NINA-03`/mapper-79-equivalent behaviour).
+pub struct Sachen146 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Sachen146 {
+ /// Construct a new mapper 146 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 146 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 146 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ const fn apply(&mut self, value: u8) {
+ self.prg_bank = (value >> 3) & 0x01;
+ self.chr_bank = value & 0x07;
+ }
+}
+
+impl Mapper for Sachen146 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // $4100-$5FFF on A8, and the $6000-$7FFF save-RAM alias.
+ if ((0x4100..=0x5FFF).contains(&addr) && (addr & 0x0100) != 0)
+ || (0x6000..=0x7FFF).contains(&addr)
+ {
+ self.apply(value);
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+/// The TXC JV001 scrambling-accumulator chip (mapper 147). Distinct from the
+/// non-JV001 `TxcChip` in `txc.rs` (different register/output bit positions).
+/// Ported bit-for-bit from puNES `JV001.c` / `mapper_147.c`.
+#[derive(Clone, Copy)]
+struct Jv001Chip {
+ accumulator: u8,
+ inverter: u8,
+ staging: u8,
+ output: u8,
+ increase: bool,
+ /// Full 0x00/0xFF mask (NOT a bool) — puNES stores `0xFF * (value & 1)` and
+ /// hard-resets it to 0xFF, so the very first handshake read inverts.
+ invert: u8,
+}
+
+impl Default for Jv001Chip {
+ fn default() -> Self {
+ Self {
+ accumulator: 0,
+ inverter: 0,
+ staging: 0,
+ output: 0,
+ increase: false,
+ // Hard-reset state (puNES init_JV001): invert latched high.
+ invert: 0xFF,
+ }
+ }
+}
+
+impl Jv001Chip {
+ /// The value the chip returns on a $4100 read (the protection handshake).
+ /// puNES: `((inverter ^ invert) & 0xF0) | (accumulator & 0x0F)`.
+ const fn read(self) -> u8 {
+ ((self.inverter ^ self.invert) & 0xF0) | (self.accumulator & 0x0F)
+ }
+
+ /// `absolute` is the full CPU address; `value` the written byte (already
+ /// mapper-147-pre-scrambled by the caller). Mirrors puNES
+ /// `extcl_cpu_wr_mem_JV001`.
+ const fn write(&mut self, absolute: u16, value: u8) {
+ if absolute < 0x8000 {
+ match absolute & 0x0103 {
+ 0x0100 => {
+ self.accumulator = if self.increase {
+ self.accumulator.wrapping_add(1)
+ } else {
+ (self.accumulator & 0xF0) | ((self.staging ^ self.invert) & 0x0F)
+ };
+ }
+ 0x0101 => self.invert = if value & 0x01 != 0 { 0xFF } else { 0x00 },
+ 0x0102 => {
+ self.staging = value & 0x0F;
+ self.inverter = value & 0xF0;
+ }
+ 0x0103 => self.increase = (value & 0x01) != 0,
+ _ => {}
+ }
+ } else {
+ // A $8000-$FFFF access refreshes the bank-output latch.
+ self.output = (self.inverter & 0xF0) | (self.accumulator & 0x0F);
+ }
+ }
+}
+
+/// Mapper 147 (Sachen 3018 / TXC `JV001`).
+pub struct Sachen3018M147 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ jv001: Jv001Chip,
+ mirroring: Mirroring,
+}
+
+impl Sachen3018M147 {
+ /// Construct a new mapper 147 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 147 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 147 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ jv001: Jv001Chip::default(),
+ mirroring,
+ })
+ }
+
+ /// PRG 32 KiB bank from the chip output latch (puNES `prg_fix_jv001_147`).
+ const fn prg_bank(&self) -> usize {
+ (((self.jv001.output & 0x20) >> 4) | (self.jv001.output & 0x01)) as usize
+ }
+
+ /// CHR 8 KiB bank from the chip output latch (puNES `chr_fix_jv001_147`).
+ const fn chr_bank(&self) -> usize {
+ ((self.jv001.output & 0x1E) >> 1) as usize
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = self.prg_bank() % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = self.chr_bank() % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Sachen3018M147 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // The JV001 protection register answers reads at $4100 (decoded on
+ // A0/A1 == 0). Everything else in $4020-$5FFF is open bus.
+ !((0x4100..=0x5FFF).contains(&addr) && (addr & 0x0103) == 0x0100)
+ && (0x4020..=0x5FFF).contains(&addr)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // JV001 protection handshake read. The mapper-147 board post-
+ // scrambles the chip read: ((v & 0x3F) << 2) | ((v & 0xC0) >> 6)
+ // (puNES extcl_cpu_rd_mem_147).
+ 0x4100..=0x5FFF if (addr & 0x0103) == 0x0100 => {
+ let v = self.jv001.read();
+ ((v & 0x3F) << 2) | ((v & 0xC0) >> 6)
+ }
+ 0x8000..=0xFFFF => self.read_prg(addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ // The mapper-147 board pre-scrambles every write to the JV001:
+ // ((value & 0x03) << 6) | ((value & 0xFC) >> 2) (puNES
+ // extcl_cpu_wr_mem_147).
+ let scramble = |v: u8| ((v & 0x03) << 6) | ((v & 0xFC) >> 2);
+ match addr {
+ 0x4100..=0x5FFF => self.jv001.write(addr, scramble(value)),
+ 0x8000..=0xFFFF => {
+ // Bus conflict in the PRG window; the write refreshes the latch.
+ let effective = value & self.read_prg(addr);
+ self.jv001.write(addr, scramble(effective));
+ }
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ // 6 JV001 fields (accumulator, inverter, staging, output, increase, invert).
+ let mut out = Vec::with_capacity(7 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.jv001.accumulator);
+ out.push(self.jv001.inverter);
+ out.push(self.jv001.staging);
+ out.push(self.jv001.output);
+ out.push(u8::from(self.jv001.increase));
+ out.push(self.jv001.invert); // full 0x00/0xFF mask
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 7 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.jv001.accumulator = data[1];
+ self.jv001.inverter = data[2];
+ self.jv001.staging = data[3];
+ self.jv001.output = data[4];
+ self.jv001.increase = data[5] != 0;
+ self.jv001.invert = data[6]; // full 0x00/0xFF mask
+ let mut cursor = 7;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 148 — Sachen SA-008-A / Tengen 800008.
+//
+// The mapper-79 bit layout (`.... PCCC`: CHR = bits 0-2, PRG = bit 3) moved
+// into the $8000-$FFFF window, introducing bus conflicts:
+// PRG (32 KiB) = (value >> 3) & 0x01
+// CHR (8 KiB) = value & 0x07
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 148 (Sachen `SA-008-A` / Tengen 800008).
+pub struct Sachen148 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ prg_bank: u8,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Sachen148 {
+ /// Construct a new mapper 148 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 148 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 148 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ prg_bank: 0,
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Sachen148 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.read_prg(addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict.
+ let effective = value & self.read_prg(addr);
+ self.prg_bank = (effective >> 3) & 0x01;
+ self.chr_bank = effective & 0x07;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(3 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.prg_bank);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 3 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.prg_bank = data[1];
+ self.chr_bank = data[2];
+ let mut cursor = 3;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 149 — Sachen SA-0036.
+//
+// CNROM-like: fixed 32 KiB PRG, switchable 8 KiB CHR. The CHR bank is a single
+// bit in bit 7 of the value written to $8000-$FFFF, with bus conflicts:
+// CHR (8 KiB) = (value >> 7) & 0x01
+// Mirroring header-fixed; no IRQ.
+// ===========================================================================
+
+/// Mapper 149 (Sachen `SA-0036`).
+pub struct Sachen149 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_bank: u8,
+ mirroring: Mirroring,
+}
+
+impl Sachen149 {
+ /// Construct a new mapper 149 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 149 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 149 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_bank: 0,
+ mirroring,
+ })
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ // Fixed first 32 KiB bank.
+ self.prg_rom[addr as usize - 0x8000]
+ }
+}
+
+impl Mapper for Sachen149 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ self.read_prg(addr)
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if (0x8000..=0xFFFF).contains(&addr) {
+ // Bus conflict.
+ let effective = value & self.read_prg(addr);
+ self.chr_bank = (effective >> 7) & 0x01;
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(2 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.chr_bank);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 2 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.chr_bank = data[1];
+ self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
+ Ok(())
+ }
+}
+
+// ===========================================================================
+// Mapper 150 — Sachen SA-015 / SA-630 (UNL-Sachen-74LS374N).
+//
+// An eight-register ASIC at $4100 (register index, write) / $4101
+// (register data, read+write). Both decode on the $C101 mask: A8 selects
+// index ($4100) vs. data ($4101). Each register holds 3 bits and is fully
+// readable (Shogi Gakuen checks this as protection). Banking is derived from
+// the registers:
+// PRG (32 KiB) = reg[5] & 0x03
+// CHR (8 KiB) = ((reg[4] & 0x01) << 2) | (reg[6] & 0x03)
+// mirroring (reg[7] >> 1) & 0x03:
+// 0: custom S0-S0-S0-S1 (lower-right unique)
+// 1: Horizontal
+// 2: Vertical
+// 3: Single-screen A
+// Reads at $4101 return (open_bus & 0xF8) | (reg[index] & 0x07); we approximate
+// open bus with 0 (the protected program only inspects the low 3 bits).
+// Writes are also accepted via the $6000-$7FFF mirror (addr | 0x1000). No IRQ.
+// ===========================================================================
+
+/// Mapper 150 (Sachen `SA-015`/`SA-630`, `UNL-Sachen-74LS374N`).
+pub struct Sachen150 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ chr_is_ram: bool,
+ current_register: u8,
+ reg: [u8; 8],
+}
+
+impl Sachen150 {
+ /// Construct a new mapper 150 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 KiB.
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 150 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr_rom: Box<[u8]> = if chr_is_ram {
+ vec![0u8; CHR_BANK_8K].into_boxed_slice()
+ } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ chr_rom
+ } else {
+ return Err(MapperError::Invalid(format!(
+ "mapper 150 CHR-ROM size {} is not a multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ };
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ chr_is_ram,
+ current_register: 0,
+ reg: [0u8; 8],
+ })
+ }
+
+ // puNES prg_fix_150 / chr_fix_150 (the non-243 branch):
+ // PRG 32 KiB = reg[5] | (reg[2] & 0x01)
+ // CHR 8 KiB = (reg[2] << 3) | ((reg[4] & 0x01) << 2) | (reg[6] & 0x03)
+ // The old decode masked PRG to reg[5] & 0x03 (dropping reg[2].0) and omitted
+ // the reg[2]<<3 CHR term, so both banks resolved wrong -> blank/garbled.
+ const fn prg_bank(&self) -> u8 {
+ self.reg[5] | (self.reg[2] & 0x01)
+ }
+
+ const fn chr_bank(&self) -> u8 {
+ ((self.reg[2] & 0x01) << 3) | ((self.reg[4] & 0x01) << 2) | (self.reg[6] & 0x03)
+ }
+
+ /// Mirroring selector value `(reg[7] >> 1) & 0x03`.
+ const fn mirror_sel(&self) -> u8 {
+ (self.reg[7] >> 1) & 0x03
+ }
+
+ const fn write_register(&mut self, addr: u16, value: u8) {
+ match addr & 0x0101 {
+ 0x0100 => self.current_register = value & 0x07,
+ 0x0101 => self.reg[(self.current_register & 0x07) as usize] = value & 0x07,
+ _ => {}
+ }
+ }
+
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ let bank = (self.prg_bank() as usize) % count;
+ self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank() as usize) % count;
+ bank * CHR_BANK_8K + addr as usize
+ }
+}
+
+impl Mapper for Sachen150 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ // $4100-$5FFF has the readable protection register at $4101 (decoded
+ // on A8); $4020-$40FF and $4200+ without A8 are open bus.
+ (0x4020..=0x5FFF).contains(&addr) && (addr & 0x0101) != 0x0101
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x4100..=0x5FFF if (addr & 0x0101) == 0x0101 => {
+ // Open-bus high 5 bits approximated as 0.
+ self.reg[(self.current_register & 0x07) as usize] & 0x07
+ }
+ 0x8000..=0xFFFF => self.read_prg(addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x4100..=0x5FFF => self.write_register(addr, value),
+ // $6000-$7FFF mirror: the ASIC sees these as register writes at
+ // (addr + 0x1000) per the SaveRAM-mapped register path.
+ 0x6000..=0x7FFF => self.write_register(addr.wrapping_add(0x1000), value),
+ _ => {}
+ }
+ }
+
+ fn ppu_read(&mut 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 {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off] = value;
+ }
+ }
+ 0x2000..=0x3EFF => {
+ let off = self.resolve_nametable(addr);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.mirror_sel() {
+ 1 => Mirroring::Horizontal,
+ 2 => Mirroring::Vertical,
+ 3 => Mirroring::SingleScreenA,
+ // 0 = custom S0-S0-S0-S1; report as MapperControlled (the PPU
+ // routes through our resolve_nametable for that case).
+ _ => Mirroring::MapperControlled,
+ }
+ }
+
+ #[allow(clippy::cast_possible_truncation)]
+ fn nametable_address(&self, addr: u16) -> u16 {
+ // CIRAM offset is always < 0x800, so the truncation is a no-op.
+ self.resolve_nametable(addr) as u16
+ }
+
+ fn save_state(&self) -> Vec {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let mut out = Vec::with_capacity(2 + 8 + self.vram.len() + chr_extra);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.current_register);
+ out.extend_from_slice(&self.reg);
+ out.extend_from_slice(&self.vram);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr_rom);
+ }
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let chr_extra = if self.chr_is_ram {
+ self.chr_rom.len()
+ } else {
+ 0
+ };
+ let expected = 2 + 8 + self.vram.len() + chr_extra;
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.current_register = data[1];
+ let mut cursor = 2;
+ self.reg.copy_from_slice(&data[cursor..cursor + 8]);
+ cursor += 8;
+ self.vram
+ .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
+ cursor += self.vram.len();
+ if self.chr_is_ram {
+ self.chr_rom
+ .copy_from_slice(&data[cursor..cursor + self.chr_rom.len()]);
+ }
+ Ok(())
+ }
+}
+
+impl Sachen150 {
+ /// Resolve a nametable address to a CIRAM offset (`0..0x800`), applying the
+ /// custom S0-S0-S0-S1 layout for mirroring selector 0 and the standard
+ /// layouts otherwise.
+ const fn resolve_nametable(&self, addr: u16) -> usize {
+ let local = (addr as usize) & (NAMETABLE_SIZE - 1);
+ match self.mirror_sel() {
+ // Custom S0-S0-S0-S1: tables 0/1/2 -> bank 0, table 3 -> bank 1.
+ 0 => {
+ let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as usize;
+ let physical = if table == 3 { 1 } else { 0 };
+ physical * NAMETABLE_SIZE + local
+ }
+ 1 => nametable_offset(addr, Mirroring::Horizontal),
+ 3 => nametable_offset(addr, Mirroring::SingleScreenA),
+ // selector 2 (vertical) and any stray value default to vertical.
+ _ => nametable_offset(addr, Mirroring::Vertical),
+ }
+ }
+}
+
+/// Mapper 143 (Sachen `TCA01`).
+pub struct SachenTca01M143 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+}
+
+impl SachenTca01M143 {
+ /// Construct a new mapper 143 board.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 143 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 143 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr_rom.len()
+ )));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ })
+ }
+
+ /// $8000-$BFFF -> first 16 KiB bank; $C000-$FFFF -> second 16 KiB bank
+ /// (which equals the first on a 16 KiB image, after the modulo wrap).
+ fn read_prg(&self, addr: u16) -> u8 {
+ let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ let bank = if addr < 0xC000 { 0 } else { 1 % count };
+ self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
+ }
+}
+
+impl Mapper for SachenTca01M143 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ // The protection register answers reads across the whole $4020-$5FFF
+ // window (mapped). $8000-$FFFF PRG-ROM stays mapped (the trait default) —
+ // a `!(...)` here would wrongly open-bus the program ROM + reset vector, so
+ // the board never boots. There is no open-bus hole to carve out, so this
+ // returns false for everything the board answers.
+ fn cpu_read_unmapped(&self, _addr: u16) -> bool {
+ false
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ // Sachen TCA01 protection (puNES extcl_cpu_rd_mem_143): the chip
+ // only answers when A8 is set, returning (~addr & 0x3F) in the low
+ // 6 bits and leaving the top 2 bits as open bus. The old decode
+ // answered across the WHOLE window with a hardcoded bit 6, so the
+ // game's `(~addr & 0x3F)` protection compare failed -> blank boot.
+ // The high 2 open-bus bits are approximated from the address high
+ // byte (the most-recently-driven bus value in this read).
+ 0x4100..=0x5FFF if addr & 0x0100 != 0 => {
+ ((!addr & 0x3F) as u8) | ((addr >> 8) as u8 & 0xC0)
+ }
+ 0x4100..=0x5FFF if addr >= 0x5000 => 0xFF,
+ 0x8000..=0xFFFF => self.read_prg(addr),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, _addr: u16, _value: u8) {}
+
+ fn ppu_read(&mut 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 {
+ let off = nametable_offset(addr, self.mirroring);
+ self.vram[off] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(1 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 1 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::Truncated {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.vram.copy_from_slice(&data[1..=self.vram.len()]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+ /// 16 KiB-banked PRG: byte 0 of each 16 KiB bank holds the bank index.
+ fn synth_prg_16k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
+ for b in 0..banks {
+ v[b * PRG_BANK_16K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_prg_32k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
+ for b in 0..banks {
+ v[b * PRG_BANK_32K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ fn synth_chr_8k(banks: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks * CHR_BANK_8K];
+ for b in 0..banks {
+ v[b * CHR_BANK_8K] = b as u8;
+ }
+ v.into_boxed_slice()
+ }
+
+ #[test]
+ fn m133_register_on_a8() {
+ let mut m = Sachen133::new(synth_prg_32k(2), synth_chr_8k(4), Mirroring::Vertical).unwrap();
+ // value: PRG = (v>>2)&1, CHR = v&3. 0b0000_0111 -> PRG 1, CHR 3.
+ m.cpu_write(0x4100, 0b0000_0111);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 3);
+ // A8 clear -> no latch.
+ m.cpu_write(0x4200, 0b0000_0000);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ }
+
+ #[test]
+ fn m145_chr_from_data_bit7() {
+ let mut m = Sachen145::new(synth_prg_32k(1), synth_chr_8k(2), Mirroring::Vertical).unwrap();
+ // Default CHR bank 0.
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // (addr & 0x4100) == 0x4100 -> $4100 qualifies. Bit 7 set -> CHR 1.
+ m.cpu_write(0x4100, 0x80);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ // Also decoded in the $6000 save-RAM window ($6100 has 0x4100 bits).
+ m.cpu_write(0x6100, 0x00);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ // PRG is fixed 32 KiB bank 0.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ }
+
+ #[test]
+ fn m146_like_nina03() {
+ let mut m = Sachen146::new(synth_prg_32k(2), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // value: PRG = (v>>3)&1, CHR = v&7. 0b0000_1101 -> PRG 1, CHR 5.
+ m.cpu_write(0x4100, 0b0000_1101);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // Save-RAM alias also latches.
+ m.cpu_write(0x6000, 0b0000_0010); // PRG 0, CHR 2
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.ppu_read(0x0000), 2);
+ }
+
+ #[test]
+ fn m147_jv001_protection_read_and_bank_decode() {
+ // Ported from puNES JV001.c / mapper_147.c. The board pre-scrambles
+ // writes ((v&3)<<6)|((v&0xFC)>>2) and post-scrambles reads
+ // ((v&0x3F)<<2)|((v&0xC0)>>6); the chip resets with invert=0xFF.
+ let mut m =
+ Sachen3018M147::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ // Disable inversion so the handshake is a clean staging->accumulator
+ // copy: write 1 to $4101 (scrambled 0x01 -> 0x40, bit 0 == 0 -> invert
+ // off). puNES: invert = 0xFF * (value & 1); 0x40 & 1 == 0 -> 0x00.
+ m.cpu_write(0x4101, 0x01);
+ // $4102 <- value V: staging = scramble(V)&0x0F, inverter = scramble(V)&0xF0.
+ // Pick V = 0x14: scramble = ((0x14&3)<<6)|((0x14&0xFC)>>2) = 0|0x05 = 0x05
+ // -> staging 0x05, inverter 0x00.
+ m.cpu_write(0x4102, 0x14);
+ // $4100 latch (increase off, invert off): accumulator =
+ // (0 & 0xF0) | ((staging ^ 0) & 0x0F) = 0x05.
+ m.cpu_write(0x4100, 0x00);
+ // Handshake read: chip = ((inverter ^ invert) & 0xF0) | (acc & 0x0F)
+ // = (0x00 & 0xF0) | 0x05 = 0x05; board post-scramble = 0x05<<2 = 0x14.
+ assert_eq!(m.cpu_read(0x4100), 0x14);
+ // Refresh the bank-output latch (a $8000+ access): output =
+ // (inverter & 0xF0) | (acc & 0x0F) = 0x05.
+ // PRG = ((out&0x20)>>4)|(out&1) = 0|1 = 1; CHR = (out&0x1E)>>1 = (4)>>1 = 2.
+ m.cpu_write(0x8000, 0xFF); // bus conflict with PRG byte 0 (==0) -> 0
+ // The $8000 write refreshes output from acc/inverter (0x05), not the
+ // ANDed data; bank decode below reflects that.
+ assert_eq!(m.cpu_read(0x8000), 1); // PRG bank 1 of 4 -> byte 0 = 1
+ assert_eq!(m.ppu_read(0x0000), 2); // CHR bank 2 of 8 -> byte 0 = 2
+ }
+
+ #[test]
+ fn m147_save_state_round_trip() {
+ let mut m =
+ Sachen3018M147::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x4101, 0x01);
+ m.cpu_write(0x4102, 0x14);
+ m.cpu_write(0x4100, 0x00);
+ m.cpu_write(0x8000, 0x00); // refresh output latch
+ let prg = m.cpu_read(0x8000);
+ let chr = m.ppu_read(0x0000);
+ let blob = m.save_state();
+ let mut m2 =
+ Sachen3018M147::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0x8000), prg);
+ assert_eq!(m2.ppu_read(0x0000), chr);
+ }
+
+ #[test]
+ fn m148_latch_selects_prg_and_chr_with_conflict() {
+ // PRG all-0xFF except offset 0, so the in-window write sees 0xFF.
+ let mut m =
+ Sachen148::new(synth_prg_32k(2), synth_chr_8k(8), Mirroring::Horizontal).unwrap();
+ // value .... PCCC: PRG = bit3, CHR = bits 0-2.
+ // Write at $8001 (PRG byte 0xFF -> no masking): 0b0000_1101 -> PRG 1, CHR 5.
+ m.cpu_write(0x8001, 0b0000_1101);
+ assert_eq!(m.cpu_read(0x8000), 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ }
+
+ #[test]
+ fn m149_chr_bit_in_bit7() {
+ let mut m = Sachen149::new(synth_prg_32k(1), synth_chr_8k(2), Mirroring::Vertical).unwrap();
+ // Write at $8001 (PRG byte 0xFF -> no masking): bit7 set -> CHR 1.
+ m.cpu_write(0x8001, 0x80);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ // PRG is fixed bank 0.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ // bit7 clear -> CHR 0.
+ m.cpu_write(0x8001, 0x00);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ }
+
+ #[test]
+ fn m150_register_protocol_and_banking() {
+ let mut m = Sachen150::new(synth_prg_32k(4), synth_chr_8k(8)).unwrap();
+ // Select register 5 (PRG), write value 2 -> PRG bank 2.
+ m.cpu_write(0x4100, 5); // index
+ m.cpu_write(0x4101, 2); // data
+ assert_eq!(m.cpu_read(0x8000), 2);
+ // Register 6 = CHR low 2 bits; register 4 bit0 = CHR bit2.
+ // Set reg6 = 0b01, reg4 = 1 -> CHR = (1<<2)|1 = 5.
+ m.cpu_write(0x4100, 6);
+ m.cpu_write(0x4101, 0b001);
+ m.cpu_write(0x4100, 4);
+ m.cpu_write(0x4101, 1);
+ assert_eq!(m.ppu_read(0x0000), 5);
+ // Registers are readable (protection).
+ m.cpu_write(0x4100, 6);
+ assert_eq!(m.cpu_read(0x4101), 0b001);
+ }
+
+ #[test]
+ fn m150_mirroring_modes() {
+ let mut m = Sachen150::new(synth_prg_32k(1), synth_chr_8k(1)).unwrap();
+ // reg7 mirroring sel = (reg7 >> 1) & 3.
+ m.cpu_write(0x4100, 7);
+ m.cpu_write(0x4101, 1 << 1); // sel 1 -> horizontal
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0x4101, 2 << 1); // sel 2 -> vertical
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ m.cpu_write(0x4101, 3 << 1); // sel 3 -> single-screen A
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ // sel 0 -> custom S0-S0-S0-S1; table 3 maps to bank 1.
+ m.cpu_write(0x4101, 0);
+ assert_eq!(m.current_mirroring(), Mirroring::MapperControlled);
+ // table 0 ($2000) -> bank 0; table 3 ($2C00) -> bank 1.
+ m.ppu_write(0x2000, 0xAA);
+ m.ppu_write(0x2C00, 0xBB);
+ assert_eq!(m.ppu_read(0x2000), 0xAA);
+ assert_eq!(m.ppu_read(0x2C00), 0xBB);
+ // table 1 ($2400) shares bank 0 with table 0 in this custom mode.
+ assert_eq!(m.ppu_read(0x2400), 0xAA);
+ }
+
+ #[test]
+ fn m143_protection_read_and_nrom() {
+ let mut m =
+ SachenTca01M143::new(synth_prg_16k(1), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ // Protection answers only with A8 set: low 6 bits = ~addr & 0x3F, top
+ // 2 bits = open bus (approximated from the address high byte). puNES.
+ let addr = 0x4100u16;
+ assert_eq!(
+ m.cpu_read(addr),
+ ((!addr & 0x3F) as u8) | ((addr >> 8) as u8 & 0xC0)
+ );
+ // A8 clear, addr >= $5000 -> $FF.
+ assert_eq!(m.cpu_read(0x5000), 0xFF);
+ assert!(!m.cpu_read_unmapped(0x4100));
+ // NROM-128: $8000 and $C000 read the same (only) 16 KiB bank (index 0).
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 0);
+ }
+
+ #[test]
+ fn m143_save_state_round_trip() {
+ let mut m =
+ SachenTca01M143::new(synth_prg_16k(1), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m.ppu_write(0x2000, 0x33);
+ let blob = m.save_state();
+ let mut m2 =
+ SachenTca01M143::new(synth_prg_16k(1), synth_chr_8k(1), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.ppu_read(0x2000), 0x33);
+ }
+}
diff --git a/crates/rustynes-mappers/src/sprint10.rs b/crates/rustynes-mappers/src/sprint10.rs
deleted file mode 100644
index db837f4c..00000000
--- a/crates/rustynes-mappers/src/sprint10.rs
+++ /dev/null
@@ -1,2302 +0,0 @@
-//! Sprint 10 discrete-logic / multicart / pirate mappers (v1.5.0 "Lens"
-//! Workstream F mapper-breadth continuation).
-//!
-//! A best-effort (Tier-2) batch of small pirate / unlicensed / multicart
-//! boards documented concretely on the nesdev wiki (and ported in reference
-//! emulators such as `Mesen2` / `GeraNES` / `puNES`). Like
-//! `sprint5`..`sprint9`, banking math is translated into direct slice
-//! indexing and bank selects wrap with `% count`, so a register write can
-//! never index out of bounds (no panics on register access — required for the
-//! `#![no_std]` chip stack).
-//!
-//! Most boards here are hook-free ([`MapperCaps::NONE`]); two carry a simple
-//! IRQ:
-//!
-//! - **Mapper 12** is intentionally *not* here (it needs an MMC3-style A12 IRQ
-//! and lives with the MMC3 family); the IRQ boards in this batch use a
-//! CPU-cycle (M2) counter instead, which is the simpler hook.
-//!
-//! Boards implemented here:
-//!
-//! - **Mapper 40** (NTDEC 2722, *Super Mario Bros. 2J* pirate): fixed PRG
-//! layout with one switchable 8 KiB window at `$C000`, an enable-gated M2
-//! IRQ that fires `4096` CPU cycles after being armed (CPU-cycle hook).
-//! - **Mapper 81** (NTDEC Super Gun, CNROM-like): a single `$8000-$FFFF`
-//! register, PRG bits 2-3 (16 KiB) + CHR bits 0-1 (8 KiB); header mirroring.
-//! - **Mapper 95** (NAMCOT-3425, *Dragon Buster*): an MMC3-subset register
-//! port whose CHR bank-1 register's high bit drives single-screen mirroring.
-//! - **Mapper 112** (NTDEC ASDER / Huang-1): an indexed `$8000`/`$A000`
-//! register port (no A12 IRQ) selecting two 8 KiB PRG + 8/2 KiB CHR banks
-//! plus a `$E000` mirroring register.
-//! - **Mapper 137** (Sachen 8259D): a `$4100/$4101` command/data protection
-//! board — 32 KiB fixed PRG + four 2 KiB CHR banks with a simple bank-mode.
-//! - **Mapper 156** (DIS23C01 DAOU / Open Corp): separate low/high CHR-bank
-//! registers (`$C000-$C003`/`$C008-$C00B`), a 16 KiB PRG register (`$C010`),
-//! and an explicit one-screen mirroring register (`$C014`).
-//! - **Mapper 162** (Waixing FS304, *San Guo Zhi II*): four `$5000-$5FFF`
-//! nibble registers compose a 32 KiB PRG bank select; 8 KiB CHR-RAM.
-//! - **Mapper 178** (Waixing / educational): a `$4800-$4803` register block
-//! (PRG mode + bank + mirroring) plus 8 KiB work-RAM at `$6000`; CHR-RAM.
-//! - **Mapper 244** (Decathlon): a `$8065-$80FF` address-decoded multicart —
-//! PRG bits 3-5, CHR bits 0-2 from the low address byte; CHR-ROM.
-//! - **Mapper 250** (Nitra, *Time Diver Avenger*): an MMC3-register-compatible
-//! board where the register index/value is carried in the *address* bits
-//! (`A0-A7`) rather than the data byte; CPU-cycle (M2) IRQ counter.
-
-use crate::cartridge::Mirroring;
-use crate::mapper::{Mapper, MapperCaps, MapperError};
-use alloc::{boxed::Box, vec::Vec};
-use alloc::{format, vec};
-
-const PRG_BANK_8K: usize = 0x2000;
-const PRG_BANK_16K: usize = 0x4000;
-const PRG_BANK_32K: usize = 0x8000;
-const CHR_BANK_1K: usize = 0x0400;
-const CHR_BANK_2K: usize = 0x0800;
-const CHR_BANK_8K: usize = 0x2000;
-const NAMETABLE_SIZE: usize = 0x0400;
-const NAMETABLE_SIZE_U16: u16 = 0x0400;
-
-const SAVE_STATE_VERSION: u8 = 1;
-
-// ---------------------------------------------------------------------------
-// Shared nametable helper (mirrors the one in the other simple-mapper modules).
-// ---------------------------------------------------------------------------
-
-const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
- let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
- let local = (addr as usize) & (NAMETABLE_SIZE - 1);
- let physical = mirroring.physical_bank(table);
- physical * NAMETABLE_SIZE + local
-}
-
-// ===========================================================================
-// Mapper 40 — NTDEC 2722 (Super Mario Bros. 2J pirate conversion).
-//
-// PRG layout is fixed except for one switchable window:
-// $6000-$7FFF -> 8 KiB bank 6 (a copy of PRG bank 6; some dumps use it as
-// the "intro" bank — modelled as bank 6 of the image).
-// $8000-$9FFF -> fixed bank 4
-// $A000-$BFFF -> fixed bank 5
-// $C000-$DFFF -> switchable 8 KiB bank (low 3 bits of any $E000-$FFFF write)
-// $E000-$FFFF -> fixed bank 7
-// Registers (data ignored; address-decoded):
-// $8000-$9FFF : IRQ disable + acknowledge (counter held in reset).
-// $A000-$BFFF : IRQ enable (counter starts counting M2 cycles).
-// $E000-$FFFF : select the $C000 8 KiB bank (value & 0x07).
-// The IRQ counter is a 12-bit M2 counter: once enabled it counts up and, when
-// it reaches 4096 (0x1000), asserts the IRQ and holds. CHR is 8 KiB RAM.
-// ===========================================================================
-
-/// Mapper 40 (NTDEC 2722, *SMB2J* pirate).
-pub struct Ntdec2722M40 {
- prg_rom: Box<[u8]>,
- chr_ram: Box<[u8]>,
- vram: Box<[u8]>,
- switch_bank: u8,
- irq_enabled: bool,
- irq_counter: u16,
- irq_pending: bool,
- mirroring: Mirroring,
-}
-
-impl Ntdec2722M40 {
- /// Construct a new mapper 40 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 8 KiB.
- pub fn new(
- prg_rom: Box<[u8]>,
- _chr_rom: &[u8],
- mirroring: Mirroring,
- ) -> Result {
- if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 40 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
- prg_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- switch_bank: 0,
- irq_enabled: false,
- irq_counter: 0,
- irq_pending: false,
- mirroring,
- })
- }
-
- fn read_prg(&self, bank: usize, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
- let bank = bank % count;
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
-}
-
-impl Mapper for Ntdec2722M40 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::CYCLE_IRQ
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- match addr {
- 0x6000..=0x7FFF => self.read_prg(6, addr),
- 0x8000..=0x9FFF => self.read_prg(4, addr),
- 0xA000..=0xBFFF => self.read_prg(5, addr),
- 0xC000..=0xDFFF => self.read_prg(self.switch_bank as usize, addr),
- 0xE000..=0xFFFF => self.read_prg(7, addr),
- _ => 0,
- }
- }
-
- fn cpu_read_unmapped(&self, addr: u16) -> bool {
- // $6000-$FFFF is mapped PRG; the $4020-$5FFF window is open bus.
- (0x4020..=0x5FFF).contains(&addr)
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr {
- 0x8000..=0x9FFF => {
- // IRQ disable + acknowledge; counter held in reset.
- self.irq_enabled = false;
- self.irq_pending = false;
- self.irq_counter = 0;
- }
- 0xA000..=0xBFFF => self.irq_enabled = true,
- 0xE000..=0xFFFF => self.switch_bank = value & 0x07,
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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 {
- 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
- 0x2000..=0x3EFF => {
- let off = nametable_offset(addr, self.mirroring);
- self.vram[off] = value;
- }
- _ => {}
- }
- }
-
- fn notify_cpu_cycle(&mut self) {
- if !self.irq_enabled {
- return;
- }
- // 12-bit M2 counter; asserts (and holds) at 4096.
- if self.irq_counter >= 0x1000 {
- self.irq_pending = true;
- } else {
- self.irq_counter += 1;
- if self.irq_counter >= 0x1000 {
- self.irq_pending = true;
- }
- }
- }
-
- fn irq_pending(&self) -> bool {
- self.irq_pending
- }
-
- fn irq_acknowledge(&mut self) {
- self.irq_pending = false;
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.mirroring
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(6 + self.vram.len() + self.chr_ram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.switch_bank);
- out.push(u8::from(self.irq_enabled));
- out.push((self.irq_counter & 0xFF) as u8);
- out.push((self.irq_counter >> 8) as u8);
- out.push(u8::from(self.irq_pending));
- 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 = 6 + self.vram.len() + self.chr_ram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.switch_bank = data[1];
- self.irq_enabled = data[2] != 0;
- self.irq_counter = u16::from(data[3]) | (u16::from(data[4]) << 8);
- self.irq_pending = data[5] != 0;
- let mut cursor = 6;
- self.vram
- .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
- cursor += self.vram.len();
- self.chr_ram
- .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 81 — NTDEC Super Gun (CNROM-like with a wider PRG select).
-//
-// A single $8000-$FFFF register; the written byte carries:
-// bits 2-3 : 16 KiB PRG bank at $8000 (the $C000 half is fixed to the last).
-// bits 0-1 : 8 KiB CHR bank.
-// Mirroring is header-fixed. No IRQ.
-// ===========================================================================
-
-/// Mapper 81 (NTDEC Super Gun).
-pub struct Ntdec81 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- prg_bank: u8,
- chr_bank: u8,
- mirroring: Mirroring,
-}
-
-impl Ntdec81 {
- /// Construct a new mapper 81 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 16 KiB or CHR-ROM is empty / not a multiple of 8 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 81 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 81 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- prg_bank: 0,
- chr_bank: 0,
- mirroring,
- })
- }
-}
-
-impl Mapper for Ntdec81 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- match addr {
- 0x8000..=0xBFFF => {
- let bank = (self.prg_bank as usize) % count;
- self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
- }
- 0xC000..=0xFFFF => {
- let last = count - 1;
- self.prg_rom[last * PRG_BANK_16K + (addr as usize & 0x3FFF)]
- }
- _ => 0,
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- if (0x8000..=0xFFFF).contains(&addr) {
- self.prg_bank = (value >> 2) & 0x03;
- self.chr_bank = value & 0x03;
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.mirroring);
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.mirroring
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(3 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.prg_bank);
- out.push(self.chr_bank);
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 3 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.prg_bank = data[1];
- self.chr_bank = data[2];
- self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 95 — NAMCOT-3425 (Dragon Buster).
-//
-// An MMC3-subset register port at $8000 (index) / $8001 (data), but with no
-// A12 IRQ and no PRG/CHR mode bits. The eight register slots map like MMC3's
-// banking-only subset:
-// index 0/1 -> 2 KiB CHR at $0000 / $0800
-// index 2..5 -> 1 KiB CHR at $1000 / $1400 / $1800 / $1C00
-// index 6/7 -> 8 KiB PRG at $8000 / $A000 ($C000/$E000 fixed to last two)
-// The board's distinctive feature: bit 5 of the value written to CHR register
-// 0 (and 1) drives one-screen nametable selection (A on 0, B on 1) for that
-// half of the screen; we model the simpler whole-screen single-screen select
-// derived from CHR reg 0 bit 5, which is what the documented Dragon Buster
-// decode uses. CHR is ROM.
-// ===========================================================================
-
-/// Mapper 95 (`NAMCOT-3425`, *Dragon Buster*).
-pub struct Namcot3425M95 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- reg_index: u8,
- prg_banks: [u8; 2],
- // chr[0],chr[1] are 2 KiB selects; chr[2..6] are 1 KiB selects.
- chr_regs: [u8; 6],
- one_screen_b: bool,
-}
-
-impl Namcot3425M95 {
- /// Construct a new mapper 95 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 8 KiB or CHR-ROM is empty / 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_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 95 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
- return Err(MapperError::Invalid(format!(
- "mapper 95 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- reg_index: 0,
- prg_banks: [0, 1],
- chr_regs: [0; 6],
- one_screen_b: false,
- })
- }
-
- fn read_prg(&self, bank: usize, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
- let bank = bank % count;
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
-
- fn read_chr(&self, addr: u16) -> u8 {
- let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
- // Resolve the 1 KiB bank for this CHR address.
- let bank1k = match addr {
- 0x0000..=0x07FF => (self.chr_regs[0] as usize & !1) + ((addr as usize >> 10) & 1),
- 0x0800..=0x0FFF => (self.chr_regs[1] as usize & !1) + ((addr as usize >> 10) & 1),
- 0x1000..=0x13FF => self.chr_regs[2] as usize,
- 0x1400..=0x17FF => self.chr_regs[3] as usize,
- 0x1800..=0x1BFF => self.chr_regs[4] as usize,
- _ => self.chr_regs[5] as usize,
- };
- let bank = bank1k % count1k;
- self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
- }
-}
-
-impl Mapper for Namcot3425M95 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
- match addr {
- 0x8000..=0x9FFF => self.read_prg(self.prg_banks[0] as usize, addr),
- 0xA000..=0xBFFF => self.read_prg(self.prg_banks[1] as usize, addr),
- 0xC000..=0xDFFF => self.read_prg(last - 1, addr),
- 0xE000..=0xFFFF => self.read_prg(last, addr),
- _ => 0,
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr {
- 0x8000..=0x9FFF if addr & 1 == 0 => self.reg_index = value & 0x07,
- 0x8000..=0x9FFF => match self.reg_index {
- 0 => {
- self.chr_regs[0] = value & 0x3F;
- // CHR reg 0 bit 5 drives one-screen select on this board.
- self.one_screen_b = (value & 0x20) != 0;
- }
- 1 => self.chr_regs[1] = value & 0x3F,
- 2..=5 => self.chr_regs[self.reg_index as usize] = value & 0x3F,
- 6 => self.prg_banks[0] = value,
- _ => self.prg_banks[1] = value,
- },
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- if self.one_screen_b {
- Mirroring::SingleScreenB
- } else {
- Mirroring::SingleScreenA
- }
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(11 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.reg_index);
- out.extend_from_slice(&self.prg_banks);
- out.extend_from_slice(&self.chr_regs);
- out.push(u8::from(self.one_screen_b));
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 11 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.reg_index = data[1];
- self.prg_banks.copy_from_slice(&data[2..4]);
- self.chr_regs.copy_from_slice(&data[4..10]);
- self.one_screen_b = data[10] != 0;
- self.vram.copy_from_slice(&data[11..11 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 112 — NTDEC ASDER / Huang-1.
-//
-// An indexed register port (no A12 IRQ — distinct from the MMC3 it resembles):
-// $8000 : register index (bits 0-2).
-// $A000 : register data.
-// $C000 : CHR high bits / outer (modelled as an outer CHR bank add).
-// $E000 : mirroring (bit 0: 0 = vertical, 1 = horizontal).
-// Register slots:
-// 0 -> PRG bank at $8000 (8 KiB)
-// 1 -> PRG bank at $A000 (8 KiB)
-// 2 -> CHR 2 KiB at $0000
-// 3 -> CHR 2 KiB at $0800
-// 4..7 -> CHR 1 KiB at $1000/$1400/$1800/$1C00
-// $C000/$E000 are fixed to the last two 8 KiB PRG banks. CHR is ROM.
-// ===========================================================================
-
-/// Mapper 112 (NTDEC ASDER / Huang-1).
-pub struct NtdecAsder112 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- reg_index: u8,
- prg_banks: [u8; 2],
- // chr[0..2] = 2 KiB selects; chr[2..6] = 1 KiB selects.
- chr_regs: [u8; 6],
- horizontal_mirroring: bool,
-}
-
-impl NtdecAsder112 {
- /// Construct a new mapper 112 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 8 KiB or CHR-ROM is empty / 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_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 112 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
- return Err(MapperError::Invalid(format!(
- "mapper 112 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- reg_index: 0,
- prg_banks: [0, 1],
- chr_regs: [0; 6],
- horizontal_mirroring: mirroring == Mirroring::Horizontal,
- })
- }
-
- fn read_prg(&self, bank: usize, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
- let bank = bank % count;
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
-
- fn read_chr(&self, addr: u16) -> u8 {
- let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
- let bank1k = match addr {
- 0x0000..=0x07FF => (self.chr_regs[0] as usize & !1) + ((addr as usize >> 10) & 1),
- 0x0800..=0x0FFF => (self.chr_regs[1] as usize & !1) + ((addr as usize >> 10) & 1),
- 0x1000..=0x13FF => self.chr_regs[2] as usize,
- 0x1400..=0x17FF => self.chr_regs[3] as usize,
- 0x1800..=0x1BFF => self.chr_regs[4] as usize,
- _ => self.chr_regs[5] as usize,
- };
- let bank = bank1k % count1k;
- self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
- }
-}
-
-impl Mapper for NtdecAsder112 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
- match addr {
- 0x8000..=0x9FFF => self.read_prg(self.prg_banks[0] as usize, addr),
- 0xA000..=0xBFFF => self.read_prg(self.prg_banks[1] as usize, addr),
- 0xC000..=0xDFFF => self.read_prg(last - 1, addr),
- 0xE000..=0xFFFF => self.read_prg(last, addr),
- _ => 0,
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr & 0xE001 {
- 0x8000 => self.reg_index = value & 0x07,
- 0xA000 => match self.reg_index {
- 0 => self.prg_banks[0] = value,
- 1 => self.prg_banks[1] = value,
- 2 => self.chr_regs[0] = value,
- 3 => self.chr_regs[1] = value,
- 4 => self.chr_regs[2] = value,
- 5 => self.chr_regs[3] = value,
- 6 => self.chr_regs[4] = value,
- _ => self.chr_regs[5] = value,
- },
- 0xE000 => self.horizontal_mirroring = (value & 0x01) != 0,
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- if self.horizontal_mirroring {
- Mirroring::Horizontal
- } else {
- Mirroring::Vertical
- }
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(11 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.reg_index);
- out.extend_from_slice(&self.prg_banks);
- out.extend_from_slice(&self.chr_regs);
- out.push(u8::from(self.horizontal_mirroring));
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 11 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.reg_index = data[1];
- self.prg_banks.copy_from_slice(&data[2..4]);
- self.chr_regs.copy_from_slice(&data[4..10]);
- self.horizontal_mirroring = data[10] != 0;
- self.vram.copy_from_slice(&data[11..11 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 137 — Sachen 8259D.
-//
-// A $4100/$4101 command/data protection-style board (the 8259 family). $4100
-// latches a 3-bit command index; $4101 supplies the data for that command:
-// cmd 0..3 : CHR 2 KiB bank selects (slots 0..3 at $0000/$0800/$1000/$1800).
-// cmd 4 : (high CHR bits — modelled as an outer CHR add; we keep it as a
-// stored register that biases all CHR slots).
-// cmd 5 : PRG 32 KiB bank select (low bits).
-// cmd 7 : mirroring / mode (bit 0: 0 = vertical, 1 = horizontal).
-// CHR is ROM, four 2 KiB banks. The 8259D variant uses straight 2 KiB CHR
-// slots (8259A/B/C reorder the low CHR address lines; that reorder is omitted
-// here as it does not affect the register-decode contract).
-// ===========================================================================
-
-/// Mapper 137 (Sachen 8259D).
-pub struct Sachen8259M137 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- cmd: u8,
- chr_banks: [u8; 4],
- chr_outer: u8,
- prg_bank: u8,
- horizontal_mirroring: bool,
-}
-
-impl Sachen8259M137 {
- /// Construct a new mapper 137 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is empty / not a multiple of
- /// 32 KiB or CHR-ROM is empty / not a multiple of 2 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_32K) {
- return Err(MapperError::Invalid(format!(
- "mapper 137 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_2K) {
- return Err(MapperError::Invalid(format!(
- "mapper 137 CHR-ROM size {} is not a non-zero multiple of 2 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- cmd: 0,
- chr_banks: [0; 4],
- chr_outer: 0,
- prg_bank: 0,
- horizontal_mirroring: mirroring == Mirroring::Horizontal,
- })
- }
-
- fn read_chr(&self, addr: u16) -> u8 {
- let count2k = (self.chr_rom.len() / CHR_BANK_2K).max(1);
- let slot = (addr as usize >> 11) & 0x03;
- let bank = (self.chr_banks[slot] as usize | ((self.chr_outer as usize) << 4)) % count2k;
- self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)]
- }
-}
-
-impl Mapper for Sachen8259M137 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- if (0x8000..=0xFFFF).contains(&addr) {
- let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
- let bank = (self.prg_bank as usize) % count;
- self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
- } else {
- 0
- }
- }
-
- fn cpu_read_unmapped(&self, addr: u16) -> bool {
- // $4100/$4101 are write-only registers; the rest of $4020-$5FFF is open
- // bus. $8000-$FFFF is mapped PRG.
- (0x4020..=0x5FFF).contains(&addr)
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr {
- 0x4100 => self.cmd = value & 0x07,
- 0x4101 => match self.cmd {
- 0..=3 => self.chr_banks[self.cmd as usize] = value & 0x07,
- 4 => self.chr_outer = value & 0x07,
- 5 => self.prg_bank = value & 0x07,
- 7 => self.horizontal_mirroring = (value & 0x01) != 0,
- _ => {}
- },
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- if self.horizontal_mirroring {
- Mirroring::Horizontal
- } else {
- Mirroring::Vertical
- }
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(9 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.cmd);
- out.extend_from_slice(&self.chr_banks);
- out.push(self.chr_outer);
- out.push(self.prg_bank);
- out.push(u8::from(self.horizontal_mirroring));
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 9 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.cmd = data[1];
- self.chr_banks.copy_from_slice(&data[2..6]);
- self.chr_outer = data[6];
- self.prg_bank = data[7];
- self.horizontal_mirroring = data[8] != 0;
- self.vram.copy_from_slice(&data[9..9 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 156 — DIS23C01 DAOU (Open Corp / Daou Infosys).
-//
-// Separate low/high CHR-bank register banks plus a 16 KiB PRG register and an
-// explicit one-screen mirroring register, all decoded in the $C000-$C014
-// window:
-// $C000-$C003 : CHR low bits for 1 KiB slots 0..3.
-// $C004-$C007 : CHR low bits for 1 KiB slots 4..7.
-// $C008-$C00B : CHR high bits for slots 0..3.
-// $C00C-$C00F : CHR high bits for slots 4..7.
-// $C010 : 16 KiB PRG bank at $8000 ($C000 half fixed to last).
-// $C014 : mirroring (bit 0: 0 = SingleScreenA, 1 = SingleScreenB).
-// CHR is ROM (eight 1 KiB slots). No IRQ.
-// ===========================================================================
-
-/// Mapper 156 (DIS23C01 DAOU).
-pub struct Daou156 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- prg_bank: u8,
- // 8 low nibbles + 8 high nibbles, composed into a 1 KiB bank per slot.
- chr_lo: [u8; 8],
- chr_hi: [u8; 8],
- mirroring: Mirroring,
-}
-
-impl Daou156 {
- /// Construct a new mapper 156 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 16 KiB or CHR-ROM is empty / 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 156 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
- return Err(MapperError::Invalid(format!(
- "mapper 156 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- prg_bank: 0,
- chr_lo: [0; 8],
- chr_hi: [0; 8],
- // DAOU/DIS23C01 powers on single-screen (nametable A) per Mesen2
- // InitMapper; the $C014 register flips it to H/V at runtime.
- mirroring: Mirroring::SingleScreenA,
- })
- }
-
- fn read_chr(&self, addr: u16) -> u8 {
- let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
- let slot = (addr as usize >> 10) & 0x07;
- let bank = ((self.chr_lo[slot] as usize) | ((self.chr_hi[slot] as usize) << 8)) % count1k;
- self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
- }
-}
-
-impl Mapper for Daou156 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- match addr {
- 0x8000..=0xBFFF => {
- let bank = (self.prg_bank as usize) % count;
- self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
- }
- 0xC000..=0xFFFF => {
- let last = count - 1;
- self.prg_rom[last * PRG_BANK_16K + (addr as usize & 0x3FFF)]
- }
- _ => 0,
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr {
- // $C000-$C00F: 16 CHR-bank-nibble registers. Mesen2 decodes the
- // 1 KiB slot as (addr & 0x03) + (addr >= 0xC008 ? 4 : 0) and selects
- // the low/high nibble array by bit 2 (0x04) — NOT a flat lo[0..8] /
- // hi[0..8] split. The old flat decode wrote the wrong slot's nibble,
- // so CHR banks resolved to garbage → blank/garbled boot.
- 0xC000..=0xC00F => {
- let slot = ((addr & 0x03) + if addr >= 0xC008 { 4 } else { 0 }) as usize;
- if addr & 0x04 != 0 {
- self.chr_hi[slot] = value;
- } else {
- self.chr_lo[slot] = value;
- }
- }
- 0xC010 => self.prg_bank = value,
- // $C014: 0 = vertical, 1 = horizontal (Mesen2). The old code mapped
- // this to a single-screen A/B toggle, which never matched the game's
- // expected nametable layout.
- 0xC014 => {
- self.mirroring = if value & 0x01 != 0 {
- Mirroring::Horizontal
- } else {
- Mirroring::Vertical
- };
- }
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.mirroring
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(19 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.prg_bank);
- out.extend_from_slice(&self.chr_lo);
- out.extend_from_slice(&self.chr_hi);
- out.push(match self.mirroring {
- Mirroring::Horizontal => 0,
- Mirroring::Vertical => 1,
- Mirroring::SingleScreenB => 2,
- _ => 3, // SingleScreenA (power-on default) + any other
- });
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 19 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.prg_bank = data[1];
- self.chr_lo.copy_from_slice(&data[2..10]);
- self.chr_hi.copy_from_slice(&data[10..18]);
- self.mirroring = match data[18] {
- 0 => Mirroring::Horizontal,
- 1 => Mirroring::Vertical,
- 2 => Mirroring::SingleScreenB,
- _ => Mirroring::SingleScreenA,
- };
- self.vram.copy_from_slice(&data[19..19 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 162 — Waixing FS304 (San Guo Zhi II, and similar Waixing RPGs).
-//
-// Four registers in the $5000-$5FFF window (index = address bits 8-9) compose a
-// 32 KiB PRG-ROM bank select from individual A15-A20 bits, with a mode selector
-// in $5300 (NESdev INES_Mapper_162):
-// regs[0]=$5000: A18..A17 = bits 3..2; A16 = bit 1 (when $5300.2=1);
-// A15 = bit 0 (when $5300.2=1 and $5300.0=1).
-// regs[1]=$5100: A15 = bit 1 (when $5300.0=0).
-// regs[2]=$5200: A20..A19 = bits 1..0.
-// regs[3]=$5300: bit 2 = A16 mode, bit 0 = A15 mode.
-// Because reset clears all registers, games boot in 32 KiB bank #2 (A16=1,
-// A15=0) — the OLD decode booted bank 0 instead, so the reset vector read the
-// wrong bank and the game hung/blanked. CHR is 8 KiB RAM, mirroring header-
-// fixed. No IRQ.
-// ===========================================================================
-
-/// Mapper 162 (Waixing FS304).
-pub struct WaixingFs304M162 {
- prg_rom: Box<[u8]>,
- chr_ram: Box<[u8]>,
- vram: Box<[u8]>,
- /// 8 KiB battery-backed PRG-RAM at CPU $6000-$7FFF. The Waixing RPGs read
- /// it during boot; without it they hang on a blank frame.
- prg_ram: Box<[u8]>,
- regs: [u8; 4],
- mirroring: Mirroring,
-}
-
-impl WaixingFs304M162 {
- /// Construct a new mapper 162 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 32 KiB.
- pub fn new(
- prg_rom: Box<[u8]>,
- _chr_rom: &[u8],
- mirroring: Mirroring,
- ) -> Result {
- if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
- return Err(MapperError::Invalid(format!(
- "mapper 162 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
- prg_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- prg_ram: vec![0u8; PRG_BANK_8K].into_boxed_slice(),
- regs: [0; 4],
- mirroring,
- })
- }
-
- const fn prg_bank(&self) -> usize {
- let r0 = self.regs[0] as usize;
- let r1 = self.regs[1] as usize;
- let r2 = self.regs[2] as usize;
- let r3 = self.regs[3] as usize;
- let a = (r3 >> 2) & 1; // $5300.2 — A16 mode
- let b = r3 & 1; // $5300.0 — A15 mode
- let a16 = if a == 0 { 1 } else { (r0 >> 1) & 1 };
- let a15 = if b == 0 {
- (r1 >> 1) & 1
- } else if a == 0 {
- 1
- } else {
- r0 & 1
- };
- let a17 = (r0 >> 2) & 1;
- let a18 = (r0 >> 3) & 1;
- let a19 = r2 & 1;
- let a20 = (r2 >> 1) & 1;
- a15 | (a16 << 1) | (a17 << 2) | (a18 << 3) | (a19 << 4) | (a20 << 5)
- }
-}
-
-impl Mapper for WaixingFs304M162 {
- fn sram(&self) -> &[u8] {
- &self.prg_ram
- }
- fn sram_mut(&mut self) -> &mut [u8] {
- &mut self.prg_ram
- }
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- match addr {
- 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize],
- 0x8000..=0xFFFF => {
- let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
- let bank = self.prg_bank() % count;
- self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
- }
- _ => 0,
- }
- }
-
- fn cpu_read_unmapped(&self, addr: u16) -> bool {
- // $5000-$5FFF carries the write-only register block; the rest of
- // $4020-$5FFF is open bus. $6000-$7FFF is PRG-RAM and $8000-$FFFF is
- // mapped PRG.
- (0x4020..=0x5FFF).contains(&addr)
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- if (0x6000..=0x7FFF).contains(&addr) {
- self.prg_ram[(addr - 0x6000) as usize] = value;
- } else if (0x5000..=0x5FFF).contains(&addr) {
- let idx = ((addr >> 8) & 0x03) as usize;
- self.regs[idx] = value;
- }
- }
-
- fn ppu_read(&mut 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 {
- 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
- 0x2000..=0x3EFF => {
- let off = nametable_offset(addr, self.mirroring);
- self.vram[off] = value;
- }
- _ => {}
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.mirroring
- }
-
- fn save_state(&self) -> Vec {
- let mut out =
- Vec::with_capacity(5 + self.vram.len() + self.chr_ram.len() + self.prg_ram.len());
- out.push(SAVE_STATE_VERSION);
- out.extend_from_slice(&self.regs);
- out.extend_from_slice(&self.vram);
- out.extend_from_slice(&self.chr_ram);
- out.extend_from_slice(&self.prg_ram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 5 + self.vram.len() + self.chr_ram.len() + self.prg_ram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.regs.copy_from_slice(&data[1..5]);
- let mut cursor = 5;
- self.vram
- .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
- cursor += self.vram.len();
- self.chr_ram
- .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
- cursor += self.chr_ram.len();
- self.prg_ram
- .copy_from_slice(&data[cursor..cursor + self.prg_ram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 178 — Waixing / San Guo Zhong Chen (educational series).
-//
-// A $4800-$4803 register block plus 8 KiB work-RAM at $6000 (NESdev
-// INES_Mapper_178 / Waixing FS305):
-// $4800 : bit 0 = mirroring (0 = vertical, 1 = horizontal),
-// bits 1-2 = PRG banking mode
-// 0 = NROM-256 / BNROM (32 KiB switchable)
-// 1 = UNROM (16 KiB switchable at $8000, fixed-111b at $C000)
-// 2 = NROM-128 (16 KiB mirrored)
-// 3 = UNROM variant ($C000 = inner|1 instead of all-ones).
-// $4801 : bits 0-2 = inner PRG bank (PRG A16..A14, i.e. 16 KiB units).
-// $4802 : outer PRG bank (PRG A17+).
-// $4803 : PRG-RAM bank (stored only; the staged games use a single 8 KiB).
-// 16 KiB bank = (reg2 << 3) | (reg1 & 0x07). The OLD code read bit 0 of $4800
-// as the PRG mode (it is the MIRRORING bit) and bit 1 as mirroring (it is a
-// PRG-mode bit) — the two were swapped, and the bank composition masked wrong,
-// so educational titles booted the wrong bank and blanked. CHR is 8 KiB RAM.
-// ===========================================================================
-
-/// Mapper 178 (Waixing educational series).
-pub struct Waixing178 {
- prg_rom: Box<[u8]>,
- chr_ram: Box<[u8]>,
- vram: Box<[u8]>,
- prg_ram: Box<[u8]>,
- regs: [u8; 4],
-}
-
-impl Waixing178 {
- /// Construct a new mapper 178 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 16 KiB.
- pub fn new(
- prg_rom: Box<[u8]>,
- _chr_rom: &[u8],
- _mirroring: Mirroring,
- ) -> Result {
- if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
- return Err(MapperError::Invalid(format!(
- "mapper 178 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
- prg_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- prg_ram: vec![0u8; PRG_BANK_8K].into_boxed_slice(),
- regs: [0; 4],
- })
- }
-
- /// Composed inner+outer 16 KiB bank: outer ($4802) shifted past the 3-bit
- /// inner ($4801 bits 0-2).
- const fn prg_base16(&self) -> usize {
- ((self.regs[2] as usize) << 3) | (self.regs[1] as usize & 0x07)
- }
-
- /// PRG banking mode from $4800 bits 1-2 (0..=3).
- const fn prg_mode(&self) -> u8 {
- (self.regs[0] >> 1) & 0x03
- }
-
- fn read_prg(&self, bank16: usize, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- let bank = bank16 % count;
- self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
- }
-}
-
-impl Mapper for Waixing178 {
- fn sram(&self) -> &[u8] {
- &self.prg_ram
- }
- fn sram_mut(&mut self) -> &mut [u8] {
- &mut self.prg_ram
- }
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- match addr {
- 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize],
- 0x8000..=0xBFFF => {
- let base = self.prg_base16();
- // $8000: NROM-256/BNROM (mode 0) presents the even bank of the
- // 32 KiB pair; every other mode switches a 16 KiB bank directly.
- let bank = if self.prg_mode() == 0 {
- base & !1
- } else {
- base
- };
- self.read_prg(bank, addr)
- }
- 0xC000..=0xFFFF => {
- let base = self.prg_base16();
- let bank = match self.prg_mode() {
- // NROM-256 / BNROM: high half of the 32 KiB pair.
- 0 => (base & !1) | 1,
- // UNROM: $C000 fixed to the last bank of the outer block
- // (inner bits = 111b).
- 1 => (base & !0x07) | 0x07,
- // NROM-128: 16 KiB mirrored.
- 2 => base,
- // UNROM variant: $C000 = inner | 1.
- _ => base | 1,
- };
- self.read_prg(bank, addr)
- }
- _ => 0,
- }
- }
-
- fn cpu_read_unmapped(&self, addr: u16) -> bool {
- // $4800-$4803 are write-only registers; $6000-$FFFF is mapped
- // (work-RAM + PRG). The remaining $4020-$5FFF window is open bus.
- (0x4020..=0x5FFF).contains(&addr)
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match addr {
- 0x4800..=0x4803 => self.regs[(addr - 0x4800) as usize] = value,
- 0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize] = value,
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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 {
- 0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
- 0x2000..=0x3EFF => {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- _ => {}
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- // $4800 bit 0: 0 = vertical, 1 = horizontal.
- if (self.regs[0] & 0x01) != 0 {
- Mirroring::Horizontal
- } else {
- Mirroring::Vertical
- }
- }
-
- fn save_state(&self) -> Vec {
- let mut out =
- Vec::with_capacity(5 + self.prg_ram.len() + self.vram.len() + self.chr_ram.len());
- out.push(SAVE_STATE_VERSION);
- out.extend_from_slice(&self.regs);
- out.extend_from_slice(&self.prg_ram);
- 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 = 5 + self.prg_ram.len() + self.vram.len() + self.chr_ram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.regs.copy_from_slice(&data[1..5]);
- let mut cursor = 5;
- self.prg_ram
- .copy_from_slice(&data[cursor..cursor + self.prg_ram.len()]);
- cursor += self.prg_ram.len();
- self.vram
- .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
- cursor += self.vram.len();
- self.chr_ram
- .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 244 — Decathlon (Mega Soft).
-//
-// A $8000-$FFFF data-decoded multicart. The bank select is carried in the
-// written DATA byte (not the address) through two scramble LUTs, with bit 3
-// selecting CHR vs PRG:
-// value & 0x08 != 0 -> CHR 8 KiB bank = LUT_CHR[(value>>4)&7][value&7].
-// else -> PRG 32 KiB bank = LUT_PRG[(value>>4)&3][value&3].
-// CHR is ROM, mirroring header-fixed. No IRQ.
-// ===========================================================================
-
-/// Mapper 244 (Decathlon).
-pub struct Decathlon244 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- prg_bank: u8,
- chr_bank: u8,
- mirroring: Mirroring,
-}
-
-impl Decathlon244 {
- /// Construct a new mapper 244 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 32 KiB or CHR-ROM is empty / not a multiple of 8 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_32K) {
- return Err(MapperError::Invalid(format!(
- "mapper 244 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 244 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- prg_bank: 0,
- chr_bank: 0,
- mirroring,
- })
- }
-}
-
-impl Mapper for Decathlon244 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::NONE
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- if (0x8000..=0xFFFF).contains(&addr) {
- let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
- let bank = (self.prg_bank as usize) % count;
- self.prg_rom[bank * PRG_BANK_32K + (addr as usize & 0x7FFF)]
- } else {
- 0
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- // Mapper 244 decodes the written DATA byte (not the address) through two
- // scramble LUTs, selecting CHR vs PRG by bit 3:
- // value & 0x08 != 0 -> CHR 8 KiB = LUT_CHR[(value>>4)&7][value&7]
- // else -> PRG 32 KiB = LUT_PRG[(value>>4)&3][value&3]
- // The old code ignored the data byte and decoded address bits with no
- // scramble, so it banked to the wrong PRG/CHR and the menu never drew.
- // (Mesen2 Mapper244 / puNES mapper_244 carry the identical tables.)
- const LUT_PRG: [[u8; 4]; 4] = [[0, 1, 2, 3], [3, 2, 1, 0], [0, 2, 1, 3], [3, 1, 2, 0]];
- const LUT_CHR: [[u8; 8]; 8] = [
- [0, 1, 2, 3, 4, 5, 6, 7],
- [0, 2, 1, 3, 4, 6, 5, 7],
- [0, 1, 4, 5, 2, 3, 6, 7],
- [0, 4, 1, 5, 2, 6, 3, 7],
- [0, 4, 2, 6, 1, 5, 3, 7],
- [0, 2, 4, 6, 1, 3, 5, 7],
- [7, 6, 5, 4, 3, 2, 1, 0],
- [7, 6, 5, 4, 3, 2, 1, 0],
- ];
- if (0x8000..=0xFFFF).contains(&addr) {
- if value & 0x08 != 0 {
- self.chr_bank = LUT_CHR[((value >> 4) & 0x07) as usize][(value & 0x07) as usize];
- } else {
- self.prg_bank = LUT_PRG[((value >> 4) & 0x03) as usize][(value & 0x03) as usize];
- }
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.mirroring);
- self.vram[off] = value;
- }
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.mirroring
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(3 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.prg_bank);
- out.push(self.chr_bank);
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 3 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.prg_bank = data[1];
- self.chr_bank = data[2];
- self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
- Ok(())
- }
-}
-
-// ===========================================================================
-// Mapper 250 — Nitra (Time Diver Avenger).
-//
-// An MMC3-register-compatible board, but the register index/value normally
-// carried in the data byte is instead carried in the *address* bits A0-A7,
-// and the data byte is ignored. The effective MMC3 write is:
-// reg select ($8000-$9FFE, even) : index = A0-A7.
-// reg data ($8001-$9FFF, odd) : value = A0-A7.
-// mirroring ($A000-$BFFE, even) : A0.
-// The board provides the MMC3 banking subset (two 8 KiB PRG + the fixed-last
-// layout + 2 KiB/1 KiB CHR slots) plus a CPU-cycle (M2) IRQ counter modelled
-// like the VRC-style 8-bit reload counter. CHR is ROM.
-// ===========================================================================
-
-/// Mapper 250 (Nitra, *Time Diver Avenger*).
-// Independent banking / mode / IRQ flags; grouping them would obscure the
-// MMC3-equivalent register decode for no gain (mirrors `MapperCaps`).
-#[allow(clippy::struct_excessive_bools)]
-pub struct Nitra250 {
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- vram: Box<[u8]>,
- reg_index: u8,
- bank_regs: [u8; 8],
- prg_mode: bool,
- chr_mode: bool,
- horizontal_mirroring: bool,
- irq_latch: u8,
- irq_counter: u8,
- irq_reload: bool,
- irq_enabled: bool,
- irq_pending: bool,
-}
-
-impl Nitra250 {
- /// Construct a new mapper 250 board.
- ///
- /// # Errors
- ///
- /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
- /// 8 KiB or CHR-ROM is empty / 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_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper 250 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
- prg_rom.len()
- )));
- }
- if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
- return Err(MapperError::Invalid(format!(
- "mapper 250 CHR-ROM size {} is not a non-zero multiple of 1 KiB",
- chr_rom.len()
- )));
- }
- Ok(Self {
- prg_rom,
- chr_rom,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- reg_index: 0,
- bank_regs: [0; 8],
- prg_mode: false,
- chr_mode: false,
- horizontal_mirroring: mirroring == Mirroring::Horizontal,
- irq_latch: 0,
- irq_counter: 0,
- irq_reload: false,
- irq_enabled: false,
- irq_pending: false,
- })
- }
-
- fn read_prg(&self, bank: usize, addr: u16) -> u8 {
- let count = (self.prg_rom.len() / PRG_BANK_8K).max(1);
- let bank = bank % count;
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
-
- fn prg_bank_for(&self, addr: u16) -> usize {
- let last = (self.prg_rom.len() / PRG_BANK_8K).max(1) - 1;
- let r6 = self.bank_regs[6] as usize;
- let r7 = self.bank_regs[7] as usize;
- match (self.prg_mode, addr) {
- (false, 0x8000..=0x9FFF) | (true, 0xC000..=0xDFFF) => r6,
- (false, 0xC000..=0xDFFF) | (true, 0x8000..=0x9FFF) => last - 1,
- (_, 0xA000..=0xBFFF) => r7,
- _ => last,
- }
- }
-
- fn read_chr(&self, addr: u16) -> u8 {
- let count1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
- // MMC3-style: chr_mode swaps the two 2 KiB and four 1 KiB regions.
- let region = (addr >> 10) & 0x07;
- let region = if self.chr_mode { region ^ 0x04 } else { region };
- let bank1k = match region {
- 0 => self.bank_regs[0] as usize & !1,
- 1 => (self.bank_regs[0] as usize & !1) + 1,
- 2 => self.bank_regs[1] as usize & !1,
- 3 => (self.bank_regs[1] as usize & !1) + 1,
- 4 => self.bank_regs[2] as usize,
- 5 => self.bank_regs[3] as usize,
- 6 => self.bank_regs[4] as usize,
- _ => self.bank_regs[5] as usize,
- };
- let bank = bank1k % count1k;
- self.chr_rom[bank * CHR_BANK_1K + (addr as usize & 0x03FF)]
- }
-}
-
-impl Mapper for Nitra250 {
- fn caps(&self) -> MapperCaps {
- MapperCaps::CYCLE_IRQ
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- if (0x8000..=0xFFFF).contains(&addr) {
- let bank = self.prg_bank_for(addr);
- self.read_prg(bank, addr)
- } else {
- 0
- }
- }
-
- fn cpu_write(&mut self, addr: u16, _value: u8) {
- // The MMC3-equivalent "data" is the low byte of the address (A0-A7); the
- // MMC3 even/odd register line is carried by A10 (bit 10 of the address),
- // not A8 — Mesen2 MMC3_250 decodes `(addr & 0xE000) | ((addr & 0x0400)
- // >> 10)`. A8 left the bank-select / mirroring writes mis-routed, so the
- // reset vector landed in the wrong PRG bank → blank boot.
- let value = (addr & 0x00FF) as u8;
- let odd = (addr & 0x0400) != 0;
- match addr & 0xE000 {
- 0x8000 => {
- if odd {
- self.bank_regs[self.reg_index as usize] = value;
- } else {
- self.reg_index = value & 0x07;
- self.prg_mode = (value & 0x40) != 0;
- self.chr_mode = (value & 0x80) != 0;
- }
- }
- 0xA000 => {
- if !odd {
- self.horizontal_mirroring = (value & 0x01) != 0;
- }
- }
- 0xC000 => {
- if odd {
- self.irq_reload = true;
- } else {
- self.irq_latch = value;
- }
- }
- 0xE000 => {
- if odd {
- self.irq_enabled = true;
- } else {
- self.irq_enabled = false;
- self.irq_pending = false;
- }
- }
- _ => {}
- }
- }
-
- fn ppu_read(&mut 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) {
- let off = nametable_offset(addr, self.current_mirroring());
- self.vram[off] = value;
- }
- }
-
- fn notify_cpu_cycle(&mut self) {
- // A simple 8-bit M2 reload counter (Nitra wires the MMC3 IRQ to M2 on
- // this board rather than to A12). On reload or zero, reload from latch;
- // otherwise decrement, asserting at the 1->0 transition when enabled.
- if self.irq_reload || self.irq_counter == 0 {
- self.irq_counter = self.irq_latch;
- self.irq_reload = false;
- } else {
- self.irq_counter -= 1;
- if self.irq_counter == 0 && self.irq_enabled {
- self.irq_pending = true;
- }
- }
- }
-
- fn irq_pending(&self) -> bool {
- self.irq_pending
- }
-
- fn irq_acknowledge(&mut self) {
- self.irq_pending = false;
- }
-
- fn current_mirroring(&self) -> Mirroring {
- if self.horizontal_mirroring {
- Mirroring::Horizontal
- } else {
- Mirroring::Vertical
- }
- }
-
- fn save_state(&self) -> Vec {
- let mut out = Vec::with_capacity(18 + self.vram.len());
- out.push(SAVE_STATE_VERSION);
- out.push(self.reg_index);
- out.extend_from_slice(&self.bank_regs);
- out.push(u8::from(self.prg_mode));
- out.push(u8::from(self.chr_mode));
- out.push(u8::from(self.horizontal_mirroring));
- out.push(self.irq_latch);
- out.push(self.irq_counter);
- out.push(u8::from(self.irq_reload));
- out.push(u8::from(self.irq_enabled));
- out.push(u8::from(self.irq_pending));
- out.extend_from_slice(&self.vram);
- out
- }
-
- fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let expected = 18 + self.vram.len();
- if data.len() != expected {
- return Err(MapperError::Truncated {
- expected,
- got: data.len(),
- });
- }
- if data[0] != SAVE_STATE_VERSION {
- return Err(MapperError::UnsupportedVersion(data[0]));
- }
- self.reg_index = data[1] & 0x07;
- self.bank_regs.copy_from_slice(&data[2..10]);
- self.prg_mode = data[10] != 0;
- self.chr_mode = data[11] != 0;
- self.horizontal_mirroring = data[12] != 0;
- self.irq_latch = data[13];
- self.irq_counter = data[14];
- self.irq_reload = data[15] != 0;
- self.irq_enabled = data[16] != 0;
- self.irq_pending = data[17] != 0;
- self.vram.copy_from_slice(&data[18..18 + self.vram.len()]);
- Ok(())
- }
-}
-
-#[cfg(test)]
-#[allow(clippy::cast_possible_truncation)]
-mod tests {
- use super::*;
-
- /// 32 KiB-banked PRG: byte 0 of each 32 KiB bank holds the bank index.
- fn synth_prg_32k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
- for b in 0..banks {
- v[b * PRG_BANK_32K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- /// 16 KiB-banked PRG: byte 0 of each 16 KiB bank holds the bank index.
- fn synth_prg_16k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
- for b in 0..banks {
- v[b * PRG_BANK_16K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- /// 8 KiB-banked PRG: byte 0 of each 8 KiB bank holds the bank index.
- fn synth_prg_8k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
- for b in 0..banks {
- v[b * PRG_BANK_8K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- /// 8 KiB-banked CHR: byte 0 of each 8 KiB bank holds the bank index.
- fn synth_chr_8k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0u8; banks * CHR_BANK_8K];
- for b in 0..banks {
- v[b * CHR_BANK_8K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- /// 1 KiB-banked CHR: byte 0 of each 1 KiB bank holds the bank index.
- fn synth_chr_1k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0u8; banks * CHR_BANK_1K];
- for b in 0..banks {
- v[b * CHR_BANK_1K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- /// 2 KiB-banked CHR: byte 0 of each 2 KiB bank holds the bank index.
- fn synth_chr_2k(banks: usize) -> Box<[u8]> {
- let mut v = vec![0u8; banks * CHR_BANK_2K];
- for b in 0..banks {
- v[b * CHR_BANK_2K] = b as u8;
- }
- v.into_boxed_slice()
- }
-
- // --- Mapper 40 ---------------------------------------------------------
-
- #[test]
- fn m40_fixed_layout_and_switchable_window() {
- let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
- // Fixed banks.
- assert_eq!(m.cpu_read(0x8000), 4);
- assert_eq!(m.cpu_read(0xA000), 5);
- assert_eq!(m.cpu_read(0xE000), 7);
- // Switch $C000 to bank 3.
- m.cpu_write(0xE000, 3);
- assert_eq!(m.cpu_read(0xC000), 3);
- }
-
- #[test]
- fn m40_irq_fires_after_enable() {
- let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
- assert!(!m.irq_pending());
- m.cpu_write(0xA000, 0); // enable
- for _ in 0..0x1000 {
- m.notify_cpu_cycle();
- }
- assert!(m.irq_pending());
- m.cpu_write(0x8000, 0); // disable + ack
- assert!(!m.irq_pending());
- }
-
- #[test]
- fn m40_save_state_round_trip() {
- let mut m = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
- m.cpu_write(0xE000, 2);
- m.cpu_write(0xA000, 0);
- m.notify_cpu_cycle();
- m.ppu_write(0x0005, 0x9A);
- let blob = m.save_state();
- let mut m2 = Ntdec2722M40::new(synth_prg_8k(8), &[], Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0xC000), 2);
- assert_eq!(m2.ppu_read(0x0005), 0x9A);
- }
-
- // --- Mapper 81 ---------------------------------------------------------
-
- #[test]
- fn m81_prg_and_chr_select() {
- let mut m = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
- // PRG bits 2-3 = 2, CHR bits 0-1 = 1. value = (2<<2)|1 = 0x09.
- m.cpu_write(0x8000, 0x09);
- assert_eq!(m.cpu_read(0x8000), 2);
- // $C000 fixed to last (7).
- assert_eq!(m.cpu_read(0xC000), 7);
- assert_eq!(m.ppu_read(0x0000), 1);
- }
-
- #[test]
- fn m81_save_state_round_trip() {
- let mut m = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000, 0x06);
- let blob = m.save_state();
- let mut m2 = Ntdec81::new(synth_prg_16k(8), synth_chr_8k(4), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
- }
-
- // --- Mapper 95 ---------------------------------------------------------
-
- #[test]
- fn m95_prg_select_and_one_screen() {
- let mut m =
- Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- // PRG reg 6 -> $8000.
- m.cpu_write(0x8000, 6);
- m.cpu_write(0x8001, 3);
- assert_eq!(m.cpu_read(0x8000), 3);
- // CHR reg 0, value with bit 5 set -> one-screen B.
- m.cpu_write(0x8000, 0);
- m.cpu_write(0x8001, 0x20);
- assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
- // $C000/$E000 fixed to last two (6,7).
- assert_eq!(m.cpu_read(0xC000), 6);
- assert_eq!(m.cpu_read(0xE000), 7);
- }
-
- #[test]
- fn m95_save_state_round_trip() {
- let mut m =
- Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000, 7);
- m.cpu_write(0x8001, 4);
- m.cpu_write(0x8000, 0);
- m.cpu_write(0x8001, 0x20);
- let blob = m.save_state();
- let mut m2 =
- Namcot3425M95::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0xA000), m.cpu_read(0xA000));
- assert_eq!(m2.current_mirroring(), Mirroring::SingleScreenB);
- }
-
- // --- Mapper 112 --------------------------------------------------------
-
- #[test]
- fn m112_indexed_prg_chr_and_mirroring() {
- let mut m =
- NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- // Select reg 0 (PRG $8000) -> bank 3.
- m.cpu_write(0x8000, 0);
- m.cpu_write(0xA000, 3);
- assert_eq!(m.cpu_read(0x8000), 3);
- // Select reg 1 (PRG $A000) -> bank 2.
- m.cpu_write(0x8000, 1);
- m.cpu_write(0xA000, 2);
- assert_eq!(m.cpu_read(0xA000), 2);
- // Mirroring register.
- m.cpu_write(0xE000, 1);
- assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
- // Fixed last two.
- assert_eq!(m.cpu_read(0xE000), 7);
- }
-
- #[test]
- fn m112_save_state_round_trip() {
- let mut m =
- NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000, 0);
- m.cpu_write(0xA000, 5);
- m.cpu_write(0xE000, 1);
- let blob = m.save_state();
- let mut m2 =
- NtdecAsder112::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
- }
-
- // --- Mapper 137 --------------------------------------------------------
-
- #[test]
- fn m137_command_data_chr_and_prg() {
- let mut m =
- Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
- // cmd 5 -> PRG 32 KiB bank 2.
- m.cpu_write(0x4100, 5);
- m.cpu_write(0x4101, 2);
- assert_eq!(m.cpu_read(0x8000), 2);
- // cmd 0 -> CHR slot 0 = bank 3.
- m.cpu_write(0x4100, 0);
- m.cpu_write(0x4101, 3);
- assert_eq!(m.ppu_read(0x0000), 3);
- // cmd 7 -> horizontal mirroring.
- m.cpu_write(0x4100, 7);
- m.cpu_write(0x4101, 1);
- assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
- }
-
- #[test]
- fn m137_save_state_round_trip() {
- let mut m =
- Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
- m.cpu_write(0x4100, 5);
- m.cpu_write(0x4101, 1);
- m.cpu_write(0x4100, 0);
- m.cpu_write(0x4101, 2);
- let blob = m.save_state();
- let mut m2 =
- Sachen8259M137::new(synth_prg_32k(4), synth_chr_2k(16), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
- }
-
- // --- Mapper 156 --------------------------------------------------------
-
- #[test]
- fn m156_chr_compose_prg_and_mirroring() {
- let mut m = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
- // Power-on mirroring is single-screen A (Mesen2 InitMapper).
- assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
- // PRG $C010 -> bank 3.
- m.cpu_write(0xC010, 3);
- assert_eq!(m.cpu_read(0x8000), 3);
- assert_eq!(m.cpu_read(0xC000), 7); // fixed last
- // CHR slot 0: low = 5 ($C000), high = 0 -> bank 5.
- m.cpu_write(0xC000, 5);
- assert_eq!(m.ppu_read(0x0000), 5);
- // High nibble of slot 0 lives at $C004 (bit 2 selects the high array):
- // low 5 | (high 1 << 8) = 0x105, wraps mod 32 -> 5.
- m.cpu_write(0xC004, 1);
- assert_eq!(m.ppu_read(0x0000), (0x105usize % 32) as u8);
- // Mirroring $C014: 1 = horizontal, 0 = vertical.
- m.cpu_write(0xC014, 1);
- assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
- m.cpu_write(0xC014, 0);
- assert_eq!(m.current_mirroring(), Mirroring::Vertical);
- }
-
- #[test]
- fn m156_save_state_round_trip() {
- let mut m = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
- m.cpu_write(0xC010, 2);
- m.cpu_write(0xC001, 4);
- m.cpu_write(0xC014, 1);
- let blob = m.save_state();
- let mut m2 = Daou156::new(synth_prg_16k(8), synth_chr_1k(32), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.ppu_read(0x0400), m.ppu_read(0x0400));
- assert_eq!(m2.current_mirroring(), Mirroring::Horizontal);
- }
-
- // --- Mapper 162 --------------------------------------------------------
-
- #[test]
- fn m162_regs_compose_prg() {
- let mut m = WaixingFs304M162::new(synth_prg_32k(64), &[], Mirroring::Vertical).unwrap();
- // Reset: all regs 0 -> A=$5300.2=0 -> A16=1, A15=$5100.1=0 -> bank #2.
- assert_eq!(m.cpu_read(0x8000), 2);
- // Waixing mode $5300=$04 ($5300.2=1): A16=$5000.1, A15=$5100.1.
- m.cpu_write(0x5300, 0x04);
- m.cpu_write(0x5000, 0x02); // $5000.1 = 1 -> A16 = 1 -> bank still has A16 set
- assert_eq!(m.cpu_read(0x8000), 2); // A16=1 -> bank 2
- m.cpu_write(0x5100, 0x02); // $5100.1 = 1 -> A15 = 1 -> bank 3
- assert_eq!(m.cpu_read(0x8000), 3);
- m.cpu_write(0x5000, 0x00); // $5000.1 = 0 -> A16 = 0; A15 still 1 -> bank 1
- assert_eq!(m.cpu_read(0x8000), 1);
- // A17/A18 from $5000 bits 2/3; A19/A20 from $5200 bits 0/1.
- m.cpu_write(0x5000, 0x0C); // bits 3,2 -> A18,A17 = 1,1 -> +12; A16=0,A15(=$5100.1)=1 -> 1
- m.cpu_write(0x5200, 0x03); // A20,A19 = 1,1 -> +48
- assert_eq!(m.cpu_read(0x8000), 1 + 12 + 48);
- }
-
- #[test]
- fn m162_save_state_round_trip() {
- let mut m = WaixingFs304M162::new(synth_prg_32k(8), &[], Mirroring::Vertical).unwrap();
- m.cpu_write(0x5000, 6);
- m.ppu_write(0x0012, 0x44);
- let blob = m.save_state();
- let mut m2 = WaixingFs304M162::new(synth_prg_32k(8), &[], Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.ppu_read(0x0012), 0x44);
- }
-
- // --- Mapper 178 --------------------------------------------------------
-
- #[test]
- fn m178_prg_mode_and_work_ram() {
- let mut m = Waixing178::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
- // UNROM mode (bits 1-2 = 01 -> $4800 = 0x02); inner reg1 = 3 -> base 3.
- m.cpu_write(0x4800, 0x02);
- m.cpu_write(0x4801, 0x03);
- m.cpu_write(0x4802, 0x00);
- assert_eq!(m.cpu_read(0x8000), 3); // switchable 16 KiB at $8000
- assert_eq!(m.cpu_read(0xC000), 7); // UNROM: $C000 fixed to inner 111b
- // NROM-256 / BNROM (mode 0): 32 KiB pair from the even bank.
- m.cpu_write(0x4800, 0x00);
- m.cpu_write(0x4801, 0x02); // base 2 -> 32 KiB pair (2,3)
- assert_eq!(m.cpu_read(0x8000), 2);
- assert_eq!(m.cpu_read(0xC000), 3);
- // Work-RAM round trips.
- m.cpu_write(0x6000, 0x77);
- assert_eq!(m.cpu_read(0x6000), 0x77);
- // Mirroring: $4800 bit 0 (1 = horizontal).
- m.cpu_write(0x4800, 0x01);
- assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
- m.cpu_write(0x4800, 0x00);
- assert_eq!(m.current_mirroring(), Mirroring::Vertical);
- }
-
- #[test]
- fn m178_save_state_round_trip() {
- let mut m = Waixing178::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
- m.cpu_write(0x4800, 0x02);
- m.cpu_write(0x4801, 0x02);
- m.cpu_write(0x6010, 0x55);
- let blob = m.save_state();
- let mut m2 = Waixing178::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.cpu_read(0x6010), 0x55);
- }
-
- // --- Mapper 244 --------------------------------------------------------
-
- #[test]
- fn m244_value_decoded_banks() {
- let mut m =
- Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
- // PRG select (value & 0x08 == 0): value 0x11 -> LUT_PRG[(1)][1] = 2.
- m.cpu_write(0x8000, 0x11);
- assert_eq!(m.cpu_read(0x8000), 2);
- // value 0x30 -> LUT_PRG[3][0] = 3.
- m.cpu_write(0x8000, 0x30);
- assert_eq!(m.cpu_read(0x8000), 3);
- // CHR select (value & 0x08 != 0): value 0x09 -> LUT_CHR[0][1] = 1.
- m.cpu_write(0x8000, 0x09);
- assert_eq!(m.ppu_read(0x0000), 1);
- // value 0x6E -> LUT_CHR[6][6] = 1 (table row 6 reversed).
- m.cpu_write(0x8000, 0x6E);
- assert_eq!(m.ppu_read(0x0000), 1);
- }
-
- #[test]
- fn m244_save_state_round_trip() {
- let mut m =
- Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000, 0x11); // PRG = LUT_PRG[1][1] = 2
- let blob = m.save_state();
- let mut m2 =
- Decathlon244::new(synth_prg_32k(4), synth_chr_8k(8), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
- }
-
- // --- Mapper 250 --------------------------------------------------------
-
- #[test]
- fn m250_address_encoded_mmc3_banking() {
- let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- // A10 (0x0400) carries the MMC3 even/odd line; A0-A7 carry the data.
- // Even $8000 (A10=0), data 0x06 -> reg select index 6.
- m.cpu_write(0x8000 | 0x06, 0);
- // Odd $8000 (A10=1), data 0x03 -> bank_regs[6] = 3.
- m.cpu_write(0x8000 | 0x400 | 0x03, 0);
- assert_eq!(m.cpu_read(0x8000), 3);
- // Mirroring via even $A000 (A10=0), data bit0 = 1.
- m.cpu_write(0xA000 | 0x01, 0);
- assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
- }
-
- #[test]
- fn m250_irq_counts_down() {
- let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- // latch = 3 via even $C000 (A10=0), data 0x03.
- m.cpu_write(0xC000 | 0x03, 0);
- m.cpu_write(0xC000 | 0x400, 0); // reload (odd, A10=1)
- m.cpu_write(0xE000 | 0x400, 0); // enable (odd, A10=1)
- // First cycle reloads from latch (=3); subsequent decrements reach 0.
- for _ in 0..5 {
- m.notify_cpu_cycle();
- }
- assert!(m.irq_pending());
- m.cpu_write(0xE000, 0); // disable + ack (even, A10=0)
- assert!(!m.irq_pending());
- }
-
- #[test]
- fn m250_save_state_round_trip() {
- let mut m = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m.cpu_write(0x8000 | 0x06, 0);
- m.cpu_write(0x8000 | 0x400 | 0x02, 0);
- m.cpu_write(0xC000 | 0x05, 0);
- m.cpu_write(0xC000 | 0x400, 0);
- m.cpu_write(0xE000 | 0x400, 0);
- m.notify_cpu_cycle();
- let blob = m.save_state();
- let mut m2 = Nitra250::new(synth_prg_8k(8), synth_chr_1k(16), Mirroring::Vertical).unwrap();
- m2.load_state(&blob).unwrap();
- assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
- }
-}
diff --git a/crates/rustynes-mappers/src/sprint11.rs b/crates/rustynes-mappers/src/sprint11.rs
deleted file mode 100644
index 90bae709..00000000
--- a/crates/rustynes-mappers/src/sprint11.rs
+++ /dev/null
@@ -1,2159 +0,0 @@
-//! Sprint 11 MMC3-clone / Sachen-8259 / discrete-multicart mappers
-//! (v1.6.0 "Studio" Workstream E mapper-breadth continuation, 126 -> 150).
-//!
-//! A best-effort (Tier-2) batch of unlicensed / pirate / multicart boards
-//! ported from the reference emulators (`Mesen2` `Mmc3Variants/`, `Sachen/`,
-//! `Codemasters/`, `Ntdec/`, `Unlicensed/`) and the nesdev wiki. Like
-//! `sprint5`..`sprint10`, banking math is translated into direct slice
-//! indexing and every bank select wraps with `% count`, so a register write
-//! can never index out of bounds (no panics on register access — required for
-//! the `#![no_std]` chip stack). All boards here are register-decode +
-//! save-state unit-tested only and are **never** accuracy-gated (see `tier.rs`
-//! `MapperTier::BestEffort` + `docs/adr/0011-mapper-tiering.md`).
-//!
-//! Two reusable cores back most of the batch:
-//!
-//! - [`Mmc3CloneMapper`] — a board wrapping a self-contained MMC3-style core
-//! (`Mmc3Clone`: eight bank registers, the `$8000`/`$A000`/`$C000`/`$E000`
-//! register protocol, an A12 falling-edge scanline IRQ counter) plus a
-//! board-specific outer-bank transform applied to the PRG/CHR bank index
-//! before the final slice lookup. The A12 IRQ is the standard
-//! "reload-on-zero, decrement, assert-at-zero-when-enabled" counter (the
-//! Sharp/NEC reload nuance the [`crate::Mmc3`] core models is an accuracy
-//! detail outside the BestEffort remit). Mappers: 44, 49, 52, 115, 134, 189,
-//! 205, 238, 245, 348, 366.
-//! - [`Sachen8259`] — the Sachen 8259 protection ASIC (`$4100`/`$4101`
-//! command/data port). The existing mapper 137 (`sprint10`) is the 8259**D**
-//! 1 KiB-CHR variant; this core covers the 2 KiB-CHR A/B/C variants
-//! (mappers 141 / 138 / 139), which differ only by a CHR shift + per-slot OR
-//! constants. Ported from `Mesen2 Sachen/Sachen8259.h`.
-//!
-//! The remaining boards are simple register banks; two carry a CPU-cycle IRQ:
-//!
-//! - **Mapper 42** (FDS-to-cart conversion, *Mario Baby* / *Ai Senshi Nicol*):
-//! `$6000` PRG-RAM-window bank + `$8000` CHR + `$E000` mirroring; an
-//! enable-gated up-counting M2 IRQ that asserts while the counter is in the
-//! `$6000..$8000` window (CPU-cycle hook).
-//! - **Mapper 50** (Alibaba / *SMB2J* alternate FDS-to-cart conversion): a
-//! fixed PRG layout with one bit-scrambled switchable `$C000` window and an
-//! enable-gated M2 IRQ that fires once at 4096 cycles (CPU-cycle hook).
-//! - **Mapper 46** (Color Dreams "Rumble Station" 15-in-1): a `$6000` outer +
-//! `$8000` inner register pair selecting 32 KiB PRG / 8 KiB CHR.
-//! - **Mapper 51** (BMC 11-in-1): a mode/bank pair with two PRG layouts and a
-//! `$6000` PRG-RAM window bank.
-//! - **Mapper 57** (BMC "GK 6-in-1"): two registers (`$8000` / `$8800`)
-//! composing CHR + a 16/32 KiB PRG select.
-//! - **Mapper 104** (Codemasters "Golden Five" / *Pegasus 5-in-1*): a
-//! `$8000-$9FFF` outer block-select + `$C000-$FFFF` inner 16 KiB PRG select.
-//! - **Mapper 120** (Tobidase Daisakusen FDS-conversion protection): a single
-//! `$41FF` register banking the `$6000` PRG window.
-//! - **Mapper 290** (NTDEC "Asder" BMC-NTD-03): a single address-decoded write
-//! carrying PRG + CHR + mirroring in the address bits.
-//! - **Mapper 301** (BMC-8157): an address-as-data multicart (the written
-//! *address* selects the inner/outer PRG bank + mirroring).
-
-#![allow(
- clippy::cast_possible_truncation,
- clippy::cast_lossless,
- clippy::match_same_arms,
- clippy::doc_markdown,
- clippy::similar_names,
- clippy::too_many_lines,
- clippy::missing_const_for_fn,
- clippy::struct_excessive_bools,
- clippy::bool_to_int_with_if
-)]
-
-use crate::cartridge::Mirroring;
-use crate::mapper::{Mapper, MapperCaps, MapperError};
-use alloc::{boxed::Box, format, vec, vec::Vec};
-
-const PRG_BANK_8K: usize = 0x2000;
-const PRG_BANK_16K: usize = 0x4000;
-const PRG_BANK_32K: usize = 0x8000;
-const CHR_BANK_1K: usize = 0x0400;
-const CHR_BANK_8K: usize = 0x2000;
-const NAMETABLE_SIZE: usize = 0x0400;
-const NAMETABLE_SIZE_U16: u16 = 0x0400;
-
-const SAVE_STATE_VERSION: u8 = 1;
-
-// ---------------------------------------------------------------------------
-// Shared nametable helper (mirrors the one in the other simple-mapper modules).
-// ---------------------------------------------------------------------------
-
-const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
- let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
- let local = (addr as usize) & (NAMETABLE_SIZE - 1);
- let physical = mirroring.physical_bank(table);
- physical * NAMETABLE_SIZE + local
-}
-
-const fn mirroring_to_byte(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 byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
- match b {
- 0 => Mirroring::Horizontal,
- 1 => Mirroring::Vertical,
- 2 => Mirroring::SingleScreenA,
- 3 => Mirroring::SingleScreenB,
- 4 => Mirroring::FourScreen,
- 5 => Mirroring::MapperControlled,
- _ => fallback,
- }
-}
-
-// ===========================================================================
-// Mmc3Clone — reusable MMC3-style core for the clone boards.
-//
-// The MMC3 register protocol (NES 2.0 mapper 4):
-// $8000 even : bank-select (low 3 bits = R index, bit 6 = PRG mode,
-// bit 7 = CHR mode).
-// $8001 odd : bank-data (the value loaded into the selected R register).
-// $A000 even : mirroring (bit 0: 0 = vertical, 1 = horizontal).
-// $C000 even : IRQ latch (reload value).
-// $C001 odd : IRQ reload (force a reload on the next A12 rise).
-// $E000 even : IRQ disable + acknowledge.
-// $E001 odd : IRQ enable.
-//
-// The A12 IRQ counter clocks on every PPU A12 rising edge: if the counter is 0
-// or a reload is pending, it reloads from the latch; otherwise it decrements.
-// After the update, if the counter is 0 and IRQs are enabled, the IRQ asserts.
-// ===========================================================================
-
-/// A reusable MMC3-style banking + A12-IRQ core for the clone boards.
-struct Mmc3Clone {
- regs: [u8; 8],
- bank_select: u8,
- prg_mode: bool,
- chr_mode: bool,
- mirroring: Mirroring,
- irq_counter: u8,
- irq_latch: u8,
- irq_reload: bool,
- irq_enabled: bool,
- irq_pending: bool,
- last_a12: bool,
- prg_count_8k: usize,
- chr_count_1k: usize,
-}
-
-impl Mmc3Clone {
- const SAVE_LEN: usize = 8 + 10;
-
- fn new(prg_count_8k: usize, chr_count_1k: usize, mirroring: Mirroring) -> Self {
- Self {
- regs: [0; 8],
- bank_select: 0,
- prg_mode: false,
- chr_mode: false,
- mirroring,
- irq_counter: 0,
- irq_latch: 0,
- irq_reload: false,
- irq_enabled: false,
- irq_pending: false,
- last_a12: false,
- prg_count_8k: prg_count_8k.max(1),
- chr_count_1k: chr_count_1k.max(1),
- }
- }
-
- /// Handle a write to the `$8000-$FFFF` MMC3 register space.
- fn write_register(&mut self, addr: u16, value: u8) {
- match addr & 0xE001 {
- 0x8000 => {
- self.bank_select = value & 0x07;
- self.prg_mode = value & 0x40 != 0;
- self.chr_mode = value & 0x80 != 0;
- }
- 0x8001 => {
- let idx = (self.bank_select & 0x07) as usize;
- self.regs[idx] = value;
- }
- 0xA000 => {
- self.mirroring = if value & 0x01 == 0 {
- Mirroring::Vertical
- } else {
- Mirroring::Horizontal
- };
- }
- 0xC000 => self.irq_latch = value,
- 0xC001 => {
- self.irq_counter = 0;
- self.irq_reload = true;
- }
- 0xE000 => {
- self.irq_enabled = false;
- self.irq_pending = false;
- }
- 0xE001 => self.irq_enabled = true,
- _ => {}
- }
- }
-
- /// The base 8 KiB PRG bank for CPU slot 0..=3 ($8000/$A000/$C000/$E000),
- /// before any wrapper outer-bank transform. Mirrors the MMC3 PRG layout.
- fn prg_bank(&self, slot: usize) -> usize {
- let last = self.prg_count_8k - 1;
- let second_last = last.saturating_sub(1);
- let r6 = self.regs[6] as usize;
- let r7 = self.regs[7] as usize;
- match (slot, self.prg_mode) {
- (0, false) => r6,
- (0, true) => second_last,
- (1, _) => r7,
- (2, false) => second_last,
- (2, true) => r6,
- (3, _) => last,
- _ => 0,
- }
- }
-
- /// The base 1 KiB CHR bank for PPU 1 KiB slot 0..=7, before any wrapper
- /// outer-bank transform. Mirrors the MMC3 CHR layout (2 KiB R0/R1 +
- /// 1 KiB R2-R5, swapped by `chr_mode`).
- fn chr_bank(&self, slot: usize) -> usize {
- let banks: [usize; 8] = if self.chr_mode {
- [
- self.regs[2] as usize,
- self.regs[3] as usize,
- self.regs[4] as usize,
- self.regs[5] as usize,
- self.regs[0] as usize & !1,
- (self.regs[0] as usize & !1) | 1,
- self.regs[1] as usize & !1,
- (self.regs[1] as usize & !1) | 1,
- ]
- } else {
- [
- self.regs[0] as usize & !1,
- (self.regs[0] as usize & !1) | 1,
- self.regs[1] as usize & !1,
- (self.regs[1] as usize & !1) | 1,
- self.regs[2] as usize,
- self.regs[3] as usize,
- self.regs[4] as usize,
- self.regs[5] as usize,
- ]
- };
- banks[slot & 0x07]
- }
-
- /// Clock the A12 IRQ counter on a PPU A12 transition.
- fn notify_a12(&mut self, level: bool) {
- let rising = level && !self.last_a12;
- self.last_a12 = level;
- if !rising {
- return;
- }
- if self.irq_counter == 0 || self.irq_reload {
- self.irq_counter = self.irq_latch;
- self.irq_reload = false;
- } else {
- self.irq_counter = self.irq_counter.wrapping_sub(1);
- }
- if self.irq_counter == 0 && self.irq_enabled {
- self.irq_pending = true;
- }
- }
-
- fn save(&self, out: &mut Vec) {
- out.extend_from_slice(&self.regs);
- out.push(self.bank_select);
- out.push(u8::from(self.prg_mode));
- out.push(u8::from(self.chr_mode));
- out.push(mirroring_to_byte(self.mirroring));
- out.push(self.irq_counter);
- out.push(self.irq_latch);
- out.push(u8::from(self.irq_reload));
- out.push(u8::from(self.irq_enabled));
- out.push(u8::from(self.irq_pending));
- out.push(u8::from(self.last_a12));
- }
-
- fn load(&mut self, data: &[u8]) {
- self.regs.copy_from_slice(&data[0..8]);
- self.bank_select = data[8];
- self.prg_mode = data[9] != 0;
- self.chr_mode = data[10] != 0;
- self.mirroring = byte_to_mirroring(data[11], self.mirroring);
- self.irq_counter = data[12];
- self.irq_latch = data[13];
- self.irq_reload = data[14] != 0;
- self.irq_enabled = data[15] != 0;
- self.irq_pending = data[16] != 0;
- self.last_a12 = data[17] != 0;
- }
-}
-
-/// Which clone board's outer-bank transform [`Mmc3CloneMapper`] applies.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum CloneBoard {
- /// Mapper 44 — 7-block selector via `$A001`.
- M44,
- /// Mapper 49 — `$6000` outer block-select with a simplified-PRG mode bit.
- M49,
- /// Mapper 52 — `$6000` outer-block / PRG+CHR-size selector.
- M52,
- /// Mapper 115 — `$5000`/`$4100` PRG-override + CHR outer-256K register.
- M115,
- /// Mapper 134 — `$6001` PRG (bit 1) + CHR (bit 5) 256 KiB outer bank.
- M134,
- /// Mapper 189 — `$4120-$7FFF` 32 KiB PRG select (overrides MMC3 PRG).
- M189,
- /// Mapper 205 — `$6000` 2-bit block-select (PRG/CHR outer window).
- M205,
- /// Mapper 238 — `$4020-$7FFF` security register (read-back LUT).
- M238,
- /// Mapper 245 — `$8001` R0 bit 1 -> PRG 256 KiB outer; CHR-RAM 4K/4K swap.
- M245,
- /// Mapper 348 — `$6800` outer-bank register (BMC-830118C).
- M348,
- /// Mapper 366 — `$6000-$7FFF` block-select (BMC-GN-45).
- M366,
-}
-
-// ===========================================================================
-// Mmc3CloneMapper — wraps `Mmc3Clone` + a `CloneBoard` outer transform.
-// ===========================================================================
-
-/// An MMC3-clone board: the shared MMC3-style core plus a board-specific
-/// outer-bank register and PRG/CHR transform.
-pub struct Mmc3CloneMapper {
- board: CloneBoard,
- core: Mmc3Clone,
- prg_rom: Box<[u8]>,
- chr: Box<[u8]>,
- chr_is_ram: bool,
- vram: Box<[u8]>,
- /// Board-specific outer register (semantics per `CloneBoard`).
- outer: u8,
- /// A second board register where needed (115 CHR-hi / protection read).
- outer2: u8,
-}
-
-impl Mmc3CloneMapper {
- fn new(
- board: CloneBoard,
- prg_rom: Box<[u8]>,
- chr_rom: Box<[u8]>,
- mirroring: Mirroring,
- mapper_id: u16,
- ) -> Result {
- if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
- return Err(MapperError::Invalid(format!(
- "mapper {mapper_id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
- prg_rom.len()
- )));
- }
- let chr_is_ram = chr_rom.is_empty();
- let chr: Box<[u8]> = if chr_is_ram {
- vec![0u8; 0x8000].into_boxed_slice() // 32 KiB CHR-RAM (245 needs >8K).
- } else {
- if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
- return Err(MapperError::Invalid(format!(
- "mapper {mapper_id} CHR-ROM size {} is not a multiple of 1 KiB",
- chr_rom.len()
- )));
- }
- chr_rom
- };
- let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
- let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
- Ok(Self {
- board,
- core: Mmc3Clone::new(prg_count_8k, chr_count_1k, mirroring),
- prg_rom,
- chr,
- chr_is_ram,
- vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
- outer: 0,
- outer2: 0,
- })
- }
-
- /// Resolve the final 8 KiB PRG bank for a CPU slot after the board outer
- /// transform.
- fn resolve_prg(&self, slot: usize) -> usize {
- let base = self.core.prg_bank(slot);
- let count = self.core.prg_count_8k;
- let bank = match self.board {
- CloneBoard::M44 => {
- let block = (self.outer & 0x07).min(6) as usize;
- let mask = if block <= 5 { 0x0F } else { 0x1F };
- (base & mask) | (block * 0x10)
- }
- CloneBoard::M49 => {
- let block = ((self.outer >> 6) & 0x03) as usize;
- if self.outer & 0x01 != 0 {
- (base & 0x0F) | (block * 0x10)
- } else {
- ((self.outer >> 4) & 0x03) as usize * 4 + slot
- }
- }
- CloneBoard::M52 => {
- if self.outer & 0x08 != 0 {
- (base & 0x0F) | ((self.outer as usize & 0x07) << 4)
- } else {
- (base & 0x1F) | ((self.outer as usize & 0x06) << 4)
- }
- }
- CloneBoard::M115 => {
- if self.outer & 0x80 != 0 {
- if self.outer & 0x20 != 0 {
- ((self.outer as usize & 0x0F) >> 1) * 4 + slot
- } else {
- let b16 = (self.outer as usize & 0x0F) * 2;
- b16 + (slot & 0x01)
- }
- } else {
- base
- }
- }
- CloneBoard::M134 => (base & 0x1F) | ((self.outer as usize & 0x02) << 4),
- CloneBoard::M189 => {
- let page = ((self.outer as usize) | (self.outer as usize >> 4)) & 0x07;
- page * 4 + slot
- }
- CloneBoard::M205 => {
- let block = self.outer as usize & 0x03;
- let mask = if block <= 1 { 0x1F } else { 0x0F };
- (base & mask) | (block * 0x10)
- }
- CloneBoard::M238 => base,
- CloneBoard::M245 => {
- let or = if self.core.regs[0] & 0x02 != 0 {
- 0x40
- } else {
- 0
- };
- (base & 0x3F) | or
- }
- CloneBoard::M348 => (base & 0x0F) | ((self.outer as usize & 0x0C) << 2),
- CloneBoard::M366 => (base & 0x0F) | (self.outer as usize & 0x30),
- };
- bank % count
- }
-
- /// Resolve the final 1 KiB CHR bank for a PPU slot after the board outer
- /// transform.
- fn resolve_chr(&self, slot: usize) -> usize {
- let base = self.core.chr_bank(slot);
- let count = self.core.chr_count_1k;
- let bank = match self.board {
- CloneBoard::M44 => {
- let block = (self.outer & 0x07).min(6) as usize;
- let mask = if block <= 5 { 0x7F } else { 0xFF };
- (base & mask) | (block * 0x80)
- }
- CloneBoard::M49 => {
- let block = ((self.outer >> 6) & 0x03) as usize;
- (base & 0x7F) | (block * 0x80)
- }
- CloneBoard::M52 => {
- if self.outer & 0x40 != 0 {
- (base & 0x7F)
- | (((self.outer as usize & 0x04) | ((self.outer as usize >> 4) & 0x03))
- << 7)
- } else {
- (base & 0xFF)
- | (((self.outer as usize & 0x04) | ((self.outer as usize >> 4) & 0x02))
- << 7)
- }
- }
- CloneBoard::M115 => base | ((self.outer2 as usize & 0x01) << 8),
- CloneBoard::M134 => (base & 0xFF) | ((self.outer as usize & 0x20) << 3),
- CloneBoard::M189 => base,
- CloneBoard::M205 => {
- let block = self.outer as usize & 0x03;
- if block >= 2 {
- (base & 0x7F) | 0x100
- } else {
- base | if block == 1 { 0x80 } else { 0 }
- }
- }
- CloneBoard::M238 => base,
- CloneBoard::M245 => base, // CHR-RAM; handled in ppu_read.
- CloneBoard::M348 => (base & 0x7F) | ((self.outer as usize & 0x0C) << 5),
- CloneBoard::M366 => (base & 0x7F) | ((self.outer as usize & 0x30) << 3),
- };
- bank % count
- }
-}
-
-impl Mapper for Mmc3CloneMapper {
- fn caps(&self) -> MapperCaps {
- MapperCaps {
- cpu_cycle_hook: false,
- audio: false,
- frame_event_hook: false,
- irq_source: true,
- }
- }
-
- fn cpu_read(&mut self, addr: u16) -> u8 {
- match addr {
- 0x8000..=0x9FFF => {
- let bank = self.resolve_prg(0);
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
- 0xA000..=0xBFFF => {
- let bank = self.resolve_prg(1);
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
- 0xC000..=0xDFFF => {
- let bank = self.resolve_prg(2);
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
- 0xE000..=0xFFFF => {
- let bank = self.resolve_prg(3);
- self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)]
- }
- // 115 protection read-back at $5000-$5FFF.
- 0x5000..=0x5FFF if matches!(self.board, CloneBoard::M115) => self.outer2,
- // 238 security read-back at $4020-$7FFF.
- 0x4020..=0x7FFF if matches!(self.board, CloneBoard::M238) => self.outer2,
- _ => 0,
- }
- }
-
- fn cpu_read_unmapped(&self, addr: u16) -> bool {
- match self.board {
- CloneBoard::M115 => (0x4020..=0x4FFF).contains(&addr),
- CloneBoard::M238 => false, // $4020-$7FFF is all mapped (security reg).
- _ => (0x4020..=0x5FFF).contains(&addr),
- }
- }
-
- fn cpu_write(&mut self, addr: u16, value: u8) {
- match self.board {
- CloneBoard::M115 => match addr {
- 0x5080 => self.outer2 = value,
- 0x4100..=0x7FFF => {
- if addr & 0x01 == 0 {
- self.outer = value; // PRG override reg.
- } else {
- self.outer2 = value; // CHR-hi reg (bit 0 used).
- }
- }
- 0x8000..=0xFFFF => self.core.write_register(addr, value),
- _ => {}
- },
- CloneBoard::M134 => {
- if addr == 0x6001 {
- self.outer = value;
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M189 => {
- if (0x4120..=0x7FFF).contains(&addr) {
- self.outer = value;
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M238 => {
- if (0x4020..=0x7FFF).contains(&addr) {
- const LUT: [u8; 4] = [0x00, 0x02, 0x02, 0x03];
- self.outer2 = LUT[(value & 0x03) as usize];
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M44 => {
- if (0x8000..=0xFFFF).contains(&addr) {
- if addr & 0xE001 == 0xA001 {
- self.outer = value & 0x07;
- }
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M348 => {
- if (0x6800..=0x68FF).contains(&addr) {
- self.outer = value;
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M366 => {
- if (0x6000..=0x7FFF).contains(&addr) {
- self.outer = if addr < 0x7000 {
- (addr as u8) & 0x30
- } else {
- value & 0x30
- };
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M49 | CloneBoard::M52 | CloneBoard::M205 => {
- if (0x6000..=0x7FFF).contains(&addr) {
- self.outer = value;
- } else if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- CloneBoard::M245 => {
- if (0x8000..=0xFFFF).contains(&addr) {
- self.core.write_register(addr, value);
- }
- }
- }
- }
-
- fn ppu_read(&mut 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 {
- 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);
- self.chr[off] = value;
- } else {
- let off = addr as usize & (self.chr.len() - 1);
- self.chr[off] = value;
- }
- }
- 0x2000..=0x3EFF => {
- let off = nametable_offset(addr, self.core.mirroring);
- self.vram[off] = value;
- }
- _ => {}
- }
- }
-
- fn notify_a12(&mut self, level: bool) {
- self.core.notify_a12(level);
- }
-
- fn irq_pending(&self) -> bool {
- self.core.irq_pending
- }
-
- fn irq_acknowledge(&mut self) {
- self.core.irq_pending = false;
- }
-
- fn current_mirroring(&self) -> Mirroring {
- self.core.mirroring
- }
-
- fn save_state(&self) -> Vec