Skip to main content

rustynes_mappers/
lib.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: this crate root contains code derived from puNES (GPL-2.0-or-later) — the JV001 security chip / mapper 147, ported from `JV001.c` / `mapper_147.c` — and from FCEUX / Mesen2 for UNIF board dispatch. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Cartridge file format (iNES + NES 2.0) parsing and mapper implementations.
6//!
7//! See `docs/mappers.md` and `docs/cartridge-format.md` for the implementation
8//! specs, and `ref-docs/research-report.md` §Cartridge for the source material.
9//!
10//! Supports a broad set of mapper families covering the great majority of the
11//! licensed NES library: `NROM`, `MMC1`, `MMC2`/`MMC4`, `MMC3` (Sharp default,
12//! NEC submapper), `MMC5` (with vertical split-screen, `ExGrafix`, 4-byte fill
13//! mode, dual sprite/BG CHR for 8×16 sprites), `UxROM`, `CNROM`, `AxROM`,
14//! `GxROM`, Color Dreams, `CPROM`, `BNROM`/`NINA` (mapper 34 variants),
15//! Camerica `BF9093`, `VRC1`, `VRC2`/`VRC4` (shared superset,
16//! submapper-dispatched), `VRC3`, `VRC6`, `VRC7` (banking and IRQ; FM audio
17//! deferred per `docs/adr/0004-vrc7-audio-deferred.md`), Sunsoft `FME-7`,
18//! Namco 163, the Bandai discrete and FCG family, Jaleco SS88006, plus the
19//! v2.1.0 Tier 2 boards Tengen RAMBO-1 (64), Irem H3001 (65), Sunsoft-3 (67),
20//! Sunsoft-4 (68, CHR-ROM nametables), Holy Diver / Cosmo Carrier (78),
21//! TxSROM/TLSROM (118, per-bank nametable mirroring), and Namco 175/340 (210),
22//! and the v2.6.0 boards including the Nintendo Vs. System (99), the Taito
23//! X1-005 (80, on-cart battery RAM) / X1-017 (82, CHR A12-inversion mode), and
24//! Konami VS / VRC1-on-Vs. (151).
25
26#![no_std]
27// The chip stack carries no `unsafe`; `forbid` makes that a compile-time
28// guarantee rather than an observation (core audit section 2.1). The FFI
29// and platform `unsafe` lives in the frontend, cheevos and mobile crates.
30#![forbid(unsafe_code)]
31#![warn(missing_docs)]
32// The expansion-audio synth cores (VRC6/VRC7/FDS/MMC5/N163/5B) live in their
33// owning mapper modules but are reused by the NSF expansion-audio router
34// (`nsf_expansion`). Exposing them `pub(crate)` from private modules is the
35// intended cross-module reuse pattern, so the redundant-pub-crate lint
36// (which assumes `pub(crate)` in a private module is a mistake) is wrong here.
37#![allow(clippy::redundant_pub_crate)]
38
39extern crate alloc;
40
41#[cfg(test)]
42extern crate std;
43
44use alloc::{boxed::Box, string::ToString};
45
46mod a12_filter;
47mod bmc_simple;
48mod cartridge;
49mod fds;
50mod header;
51mod homebrew_boards;
52mod jaleco_discrete;
53mod kaiser;
54mod m000_nrom;
55mod m001_mmc1;
56mod m002_uxrom;
57mod m003_cnrom;
58mod m004_mmc3;
59mod m005_mmc5;
60mod m007_axrom;
61mod m009_mmc2;
62mod m010_mmc4;
63mod m011_color_dreams;
64mod m013_cprom;
65mod m016_bandai_fcg;
66mod m018_jaleco_ss88006;
67mod m019_namco163;
68mod m021_vrc4;
69mod m022_vrc2;
70mod m024_vrc6;
71mod m032_irem_g101;
72mod m033_taito_tc0190;
73mod m034_bnrom_nina001;
74mod m035_jy_asic;
75mod m036_txc_policeman;
76mod m038_bitcorp38;
77mod m039_subor39;
78mod m041_caltron41;
79mod m042_fds_conv_bio_miracle;
80mod m048_taito_tc0690;
81mod m050_fds_conv_smb2j;
82mod m064_rambo1;
83mod m065_irem_h3001;
84mod m066_gxrom;
85mod m067_sunsoft3;
86mod m068_sunsoft4;
87mod m069_sunsoft_fme7;
88mod m070_bandai74;
89mod m071_camerica_bf9093;
90mod m073_vrc3;
91mod m075_vrc1;
92mod m076_namcot3446;
93mod m077_irem_napoleon;
94mod m078_irem_jaleco78;
95mod m079_ave_nina03_06;
96mod m080_taito_x1_005;
97mod m082_taito_x1_017;
98mod m083_cony;
99mod m085_vrc7;
100mod m087_jaleco87;
101mod m088_namco118;
102mod m089_sunsoft2;
103mod m091_jy_sf3;
104mod m093_sunsoft3r;
105mod m094_un1rom;
106mod m095_namcot3425;
107mod m096_bandai96;
108mod m097_irem_tam_s1;
109mod m099_vs_system;
110mod m105_nes_event;
111mod m107_magic_dragon107;
112mod m113_ave_nina006;
113mod m118_txsrom;
114mod m119_tqrom;
115mod m132_txc_22211;
116mod m136_sachen_3011;
117mod m151_konami_vs;
118mod m152_bandai152;
119mod m156_daou156;
120mod m163_nanjing;
121mod m176_bmc_fk23c;
122mod m177_hengedianzi;
123mod m179_hengedianzi;
124mod m180_nichibutsu180;
125mod m184_sunsoft1;
126mod m185_cnrom185;
127mod m210_namco175;
128mod m228_action52;
129mod m232_camerica_bf9096;
130mod m240_cne_multicart;
131mod m241_bxrom241;
132mod m244_cne_decathlon;
133mod m246_fong_shen_bang246;
134mod m250_nitra250;
135mod m268_bmc_coolboy;
136mod m513_sachen_9602;
137mod mapper;
138mod mmc3_boards;
139mod mmc3_clones;
140mod multicart_discrete;
141mod nsf;
142mod nsf_expansion;
143mod ntdec;
144mod sachen_8259;
145mod sachen_discrete;
146mod sst39sf040;
147mod tier;
148mod unif;
149mod waixing;
150
151pub use bmc_simple::{new_m164, new_m261, new_m286, new_m289, new_m320, new_m336, new_m349};
152pub use cartridge::{Cartridge, ConsoleType, Mirroring, Region, RomError, VsPpuPalette, VsPpuType};
153pub use fds::{
154    DISK_BYTE_CYCLES, FDS_SIDE_LEN, Fds, FdsDisk, FdsQuirk, FdsTraceRec, HEAD_RESEEK_CYCLES,
155    fds_crc32, parse_fds, quirk_for_crc,
156};
157pub use header::{
158    ExpansionDevice, ExtendedConsoleType, Header, VsHardwareType, parse_header,
159    serialize_header_preserving,
160};
161pub use homebrew_boards::{Action53M28, Cufrom29, Gtrom111, Inl31, MagicFloor218, Unrom512M30};
162pub use jaleco_discrete::{Jaleco72, Jaleco86, Jaleco92, Jaleco101, Jaleco140};
163pub use kaiser::{new_m56, new_m142, new_m303, new_m305, new_m306, new_m312};
164pub use m000_nrom::Nrom;
165pub use m001_mmc1::Mmc1;
166pub use m002_uxrom::UxRom;
167pub use m003_cnrom::CnRom;
168pub use m004_mmc3::{Mmc3, Mmc3Revision, Mmc3Variant};
169pub use m005_mmc5::Mmc5;
170pub use m007_axrom::AxRom;
171pub use m009_mmc2::Mmc2;
172pub use m010_mmc4::Mmc4;
173pub use m011_color_dreams::ColorDreams;
174pub use m013_cprom::Cprom;
175pub use m016_bandai_fcg::{BandaiFcg, FcgVariant};
176pub use m018_jaleco_ss88006::JalecoSs88006;
177pub use m019_namco163::Namco163;
178pub use m021_vrc4::Vrc4;
179pub use m022_vrc2::Vrc2;
180pub use m024_vrc6::Vrc6;
181pub use m032_irem_g101::IremG101;
182pub use m033_taito_tc0190::TaitoTc0190;
183pub use m034_bnrom_nina001::{M34, M34Variant};
184pub use m035_jy_asic::{JyAsic, JyBoard};
185pub use m036_txc_policeman::Txc36;
186pub use m038_bitcorp38::Bitcorp38;
187pub use m039_subor39::Subor39;
188pub use m041_caltron41::Caltron41;
189pub use m042_fds_conv_bio_miracle::Mapper42;
190pub use m048_taito_tc0690::TaitoTc0690;
191pub use m050_fds_conv_smb2j::Mapper50;
192pub use m064_rambo1::Rambo1;
193pub use m065_irem_h3001::IremH3001;
194pub use m066_gxrom::GxRom;
195pub use m067_sunsoft3::Sunsoft3;
196pub use m068_sunsoft4::Sunsoft4;
197pub use m069_sunsoft_fme7::Fme7;
198pub use m070_bandai74::Bandai74;
199pub use m071_camerica_bf9093::Camerica;
200pub use m073_vrc3::Vrc3;
201pub use m075_vrc1::Vrc1;
202pub use m076_namcot3446::Namcot3446M76;
203pub use m077_irem_napoleon::Irem77;
204pub use m078_irem_jaleco78::{M78, M78Variant};
205pub use m079_ave_nina03_06::Nina0379;
206pub use m080_taito_x1_005::TaitoX1005;
207pub use m082_taito_x1_017::TaitoX1017;
208pub use m083_cony::Cony83;
209pub use m085_vrc7::Vrc7;
210pub use m087_jaleco87::Jaleco87;
211pub use m088_namco118::{Namco118, Namco118Board};
212pub use m089_sunsoft2::Sunsoft2;
213pub use m091_jy_sf3::Jy91;
214pub use m093_sunsoft3r::Sunsoft3r;
215pub use m094_un1rom::Un1rom94;
216pub use m095_namcot3425::Namcot3425M95;
217pub use m096_bandai96::Bandai96;
218pub use m097_irem_tam_s1::Irem97;
219pub use m099_vs_system::VsSystem;
220pub use m105_nes_event::{NWC_TOURNAMENT_DIP, NesEvent105};
221pub use m107_magic_dragon107::MagicDragon107;
222pub use m113_ave_nina006::Nina006M113;
223pub use m118_txsrom::TxSrom;
224pub use m119_tqrom::Tqrom;
225pub use m132_txc_22211::Txc132;
226pub use m136_sachen_3011::new_m136;
227pub use m151_konami_vs::KonamiVs;
228pub use m152_bandai152::Bandai152;
229pub use m156_daou156::Daou156;
230pub use m163_nanjing::Nanjing163;
231pub use m176_bmc_fk23c::new_m176;
232pub use m177_hengedianzi::Hengedianzi177;
233pub use m179_hengedianzi::Hengedianzi179;
234pub use m180_nichibutsu180::Nichibutsu180;
235pub use m184_sunsoft1::Sunsoft1;
236pub use m185_cnrom185::CnRom185;
237pub use m210_namco175::{Namco175, Namco175Board};
238pub use m228_action52::Action52M228;
239pub use m232_camerica_bf9096::Camerica232;
240pub use m240_cne_multicart::Cne240;
241pub use m241_bxrom241::Bxrom241;
242pub use m244_cne_decathlon::Decathlon244;
243pub use m246_fong_shen_bang246::FongShenBang246;
244pub use m250_nitra250::Nitra250;
245pub use m268_bmc_coolboy::new_m268;
246pub use m513_sachen_9602::new_m513;
247pub use mapper::{
248    BgSplitState, ExAttribute, Mapper, MapperCaps, MapperDebugInfo, MapperError, MapperFrameEvents,
249    mirroring_name,
250};
251pub use mmc3_boards::{Board as Mmc3BoardKind, Mmc3Board};
252pub use mmc3_clones::{
253    Mmc3CloneMapper, new_m44, new_m49, new_m52, new_m115, new_m134, new_m189, new_m205, new_m238,
254    new_m245, new_m348, new_m366,
255};
256pub use multicart_discrete::{
257    DiscreteBoard, DiscreteMapper, Maxi15M234, Multicart15, Multicart58, Multicart60, Multicart61,
258    Multicart62, Multicart200, Multicart201, Multicart202, Multicart203, Multicart212,
259    Multicart213, Multicart214, Multicart225, Multicart226, Multicart227, Multicart229,
260    Multicart231, Multicart233, new_m46, new_m51, new_m57, new_m104, new_m120, new_m204, new_m290,
261    new_m299, new_m301,
262};
263pub use nsf::{Nsf, NsfMapper, is_nsf, parse_nsf};
264pub use ntdec::{Ntdec63, Ntdec81, Ntdec174, Ntdec2722M40, NtdecAsder112, new_m193, new_m221};
265pub use sachen_8259::{Sachen8259, Sachen8259M137, Sachen8259Variant};
266pub use sachen_discrete::{
267    Sa020aBoard, Sachen133, Sachen145, Sachen146, Sachen148, Sachen149, Sachen150, Sachen3018M147,
268    SachenTca01M143,
269};
270pub use tier::{MapperTier, mapper_tier};
271pub use unif::{UnifError, UnifImage, board_to_mapper, parse_unif, unif_to_ines};
272pub use waixing::{Waixing178, Waixing242, WaixingFs304M162, new_m253};
273
274/// Returns the crate version string.
275#[must_use]
276pub const fn version() -> &'static str {
277    env!("CARGO_PKG_VERSION")
278}
279
280/// Build one of the v2.9.6 MMC3 boards (`mmc3_boards.rs`) from a parsed header.
281///
282/// Work RAM and the CHR-RAM overlay are taken from the header only when it is
283/// NES 2.0: iNES 1.0 reports a nominal 8 KiB of PRG-RAM for every image
284/// (`header.rs`), which would put RAM on multicarts that have a register in
285/// that window instead. Each board then falls back to its own documented
286/// default (`Board::default_wram` / `Board::overlay_bytes`).
287fn mmc3_board(
288    kind: Mmc3BoardKind,
289    prg_rom: Box<[u8]>,
290    chr_rom: Box<[u8]>,
291    h: &Header,
292) -> Result<Box<dyn Mapper>, RomError> {
293    let (wram, chr_ram) = if h.is_nes2 {
294        (h.prg_ram_window() as usize, h.chr_ram_size as usize)
295    } else {
296        (0, 0)
297    };
298    Ok(Box::new(
299        Mmc3Board::new(kind, prg_rom, chr_rom, h.mirroring, wram, chr_ram)
300            .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
301    ))
302}
303
304/// Parse an iNES 1.0 / NES 2.0 ROM file.
305///
306/// Validates the magic, detects the format, applies the appropriate sizing
307/// rules, slices out the trainer (if any), PRG-ROM, and CHR-ROM, and returns
308/// a fully populated [`Cartridge`] plus a constructed `dyn Mapper` ready for
309/// the bus to consume.
310///
311/// # Errors
312///
313/// - [`RomError::Truncated`] if the file is shorter than the declared sections.
314/// - [`RomError::BadMagic`] if the first 4 bytes are not `"NES\x1A"`.
315/// - [`RomError::UnsupportedMapper`] if the mapper id is outside the supported
316///   set listed at crate level.
317/// - [`RomError::InvalidConfig`] for inconsistent header fields (e.g.,
318///   exponent overflow).
319#[allow(clippy::too_many_lines)]
320pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box<dyn Mapper>), RomError> {
321    // Famicom Disk System: a `.fds` disk image carries no embedded BIOS, so it
322    // cannot be constructed through the ordinary cartridge path (which has no
323    // `disksys.rom` to hand it). The real disk path is `crate::fds::parse_fds`
324    // plus `rustynes_core::Nes::from_disk(disk_bytes, bios_bytes)`. Detect the two
325    // on-disk forms here — the fwNES container (`"FDS\x1A"` magic) and a raw
326    // 65500-byte disk side whose first block opens with `\x01*NINTENDO-HVC*` —
327    // and return [`RomError::FdsUnsupported`] so a frontend that has not yet
328    // wired `from_disk` (no BIOS prompt) gets a clear message instead of a
329    // generic bad-magic error. FDS Stage 1 (drive + BIOS + IRQ/timing) landed
330    // in v2.2.0; audio + write + persistence are Stage 2. See `fds.rs`.
331    if bytes.len() >= 4 && &bytes[0..4] == b"FDS\x1A" {
332        return Err(RomError::FdsUnsupported);
333    }
334    if bytes.len() >= 15 && bytes[0] == 0x01 && &bytes[1..15] == b"*NINTENDO-HVC*" {
335        return Err(RomError::FdsUnsupported);
336    }
337
338    // NSF music files (`"NESM\x1A"`) have no iNES header and no PPU program;
339    // they run through the dedicated `rustynes_core::Nes::from_nsf` path (which
340    // builds an `NsfMapper`), not this cartridge parser. Detect the magic here
341    // and return a clear message so a frontend that hands NSF bytes to `parse`
342    // by mistake gets a routing hint instead of a generic bad-magic error.
343    if nsf::is_nsf(bytes) {
344        return Err(RomError::InvalidConfig(
345            "NSF music file — load via Nes::from_nsf, not parse()".into(),
346        ));
347    }
348
349    // UNIF container (`"UNIF"` magic): resolve the MAPR board name to an iNES
350    // mapper, synthesize an equivalent NES 2.0 image, and parse that through the
351    // standard path below — reusing all mapper construction + Cartridge assembly
352    // (v1.6.0 Workstream E2). The synthetic image carries `"NES\x1A"` magic, so
353    // it never re-enters this branch.
354    if bytes.len() >= 4 && &bytes[0..4] == unif::UNIF_MAGIC {
355        let img = unif::parse_unif(bytes).map_err(|e| RomError::InvalidConfig(e.to_string()))?;
356        return parse(&unif::unif_to_ines(&img));
357    }
358
359    let h = parse_header(bytes)?;
360
361    let mut cursor = header::HEADER_LEN;
362    if h.has_trainer {
363        if bytes.len() < cursor + header::TRAINER_LEN {
364            return Err(RomError::Truncated {
365                needed: cursor + header::TRAINER_LEN,
366                got: bytes.len(),
367            });
368        }
369        cursor += header::TRAINER_LEN;
370    }
371
372    if bytes.len() < cursor + h.prg_size {
373        return Err(RomError::Truncated {
374            needed: cursor + h.prg_size,
375            got: bytes.len(),
376        });
377    }
378    let prg_rom: Box<[u8]> = bytes[cursor..cursor + h.prg_size]
379        .to_vec()
380        .into_boxed_slice();
381    cursor += h.prg_size;
382
383    if bytes.len() < cursor + h.chr_size {
384        return Err(RomError::Truncated {
385            needed: cursor + h.chr_size,
386            got: bytes.len(),
387        });
388    }
389    let chr_rom: Box<[u8]> = bytes[cursor..cursor + h.chr_size]
390        .to_vec()
391        .into_boxed_slice();
392
393    // Tail bytes beyond declared PRG+CHR are ignored for iNES 1.0; in NES 2.0
394    // they are the optional misc-ROM block which we do not surface yet.
395
396    // Arcade-platform (Vs. System / PlayChoice-10) detection drives the RGB
397    // PPU palette. Two signals, applied in order:
398    //
399    // 1. **Mapper-driven** (the most robust): mapper 99 (Vs. DualSystem CHR
400    //    bank) and mapper 151 (Konami VRC1 on a Vs. board) are Vs.-only — no
401    //    licensed home game uses either — so a cart bearing one is forced to
402    //    `ConsoleType::VsSystem` + the 2C03 RGB PPU (the most common Vs. PPU)
403    //    whenever the header did not already carry a resolved Vs. PPU type.
404    //
405    // 2. **Clean-byte-7 arcade flag** (real No-Intro arcade dumps): a genuine
406    //    Vs./PC10 dump is clean iNES 1.0 with byte 7 EXACTLY `0x01` (Vs.) or
407    //    `0x02` (PC10) — NOT NES 2.0, mapper-hi-nibble 0. We accept ONLY those
408    //    two exact values on a non-NES-2.0 header. This is the critical guard:
409    //    the notorious corruption is byte 7 == `0x0A` (console field = 2,
410    //    PlayChoice-10, PLUS the NES-2.0 marker bits 2-3 = `10`), carried by
411    //    many home dumps (e.g. the committed `Excitebike.nes`). Because `0x0A`
412    //    is NES 2.0 AND is neither `0x01` nor `0x02`, it is ignored and the
413    //    cart stays whatever `h.console_type` parsed (it never reaches the RGB
414    //    palette: a corrupted console-2 home dump resolves to
415    //    `VsPpuType::None` -> `Composite2C02`, byte-for-byte the legacy path).
416    //    A survey confirmed no oracle/home ROM carries a clean `0x01`/`0x02`.
417    //    Both Vs. AND PC10 use the 2C03 RGB PPU.
418    //
419    // A true NES-2.0 Vs. dump (console field = 1) keeps the existing behaviour:
420    // `h.console_type == VsSystem` already resolved `h.vs_ppu_type` from byte 13
421    // in the header parser, so it falls through the final `else`. We do NOT
422    // special-case an NES-2.0 PlayChoice-10 console field, precisely because
423    // that field is the corrupted-home-dump signal in this library.
424    let clean_ines = !h.is_nes2;
425    let (console_type, vs_ppu_type) = if h.mapper_id == 99 || h.mapper_id == 151 {
426        let vs = if h.vs_ppu_type == VsPpuType::None {
427            VsPpuType::Rp2C03
428        } else {
429            h.vs_ppu_type
430        };
431        (ConsoleType::VsSystem, vs)
432    } else if clean_ines && bytes[7] == 0x01 {
433        // Clean iNES Vs. System arcade dump.
434        (ConsoleType::VsSystem, VsPpuType::Rp2C03)
435    } else if clean_ines && bytes[7] == 0x02 {
436        // Clean iNES PlayChoice-10 arcade dump. PC10 used the 2C03 RGB PPU;
437        // route it through the 2C03 palette by resolving its (otherwise-`None`)
438        // Vs. PPU type to the 2C03.
439        (ConsoleType::Playchoice10, VsPpuType::Rp2C03)
440    } else {
441        (h.console_type, h.vs_ppu_type)
442    };
443
444    // A mapper-30 image that declares CHR-ROM is not UNROM-512 and is built as
445    // WAIXING-FS005 below. The cartridge identity has to say so too, or every
446    // consumer keyed on `(mapper_id, submapper)` -- above all `mapper_tier`,
447    // which would answer Curated (accuracy-gated) for id 30 -- describes a board
448    // that is not the one running. That would bypass the BestEffort
449    // classification 176/2 carries precisely for these ROMs, which is the
450    // honesty gate reporting evidence it does not have.
451    let (identity_mapper, identity_submapper) = if h.mapper_id == 30 && !chr_rom.is_empty() {
452        (176, 2)
453    } else {
454        (h.mapper_id, h.submapper)
455    };
456
457    let cart = Cartridge {
458        prg_rom: prg_rom.clone(),
459        chr_rom: chr_rom.clone(),
460        mapper_id: identity_mapper,
461        submapper: identity_submapper,
462        mirroring: h.mirroring,
463        region: h.region,
464        console_type,
465        vs_ppu_type,
466        // From the header byte-13 high nibble; independent of the mapper-99/151
467        // console-type forcing above (a non-dual Vs. cart stays false).
468        vs_dual_system: h.is_vs_dual_system(),
469        // The whole window, volatile + NVRAM, as the field held before the
470        // header split them (v2.9.8).
471        prg_ram_size: h.prg_ram_window(),
472        chr_ram_size: h.chr_ram_size,
473        has_battery: h.has_battery,
474        has_trainer: h.has_trainer,
475        is_nes2: h.is_nes2,
476        nametable_wiring_bits: bytes[6] & 0x09,
477    };
478
479    let mapper: Box<dyn Mapper> = match h.mapper_id {
480        0 => {
481            let nrom = Nrom::new(prg_rom, chr_rom, h.mirroring)
482                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
483            Box::new(nrom)
484        }
485        1 => {
486            // MMC1. Default revision is Sharp (no NES 2.0 submapper). Submapper
487            // values (1-5) signal SUROM / SOROM / SXROM / SEROM variants —
488            // observationally equivalent at the register-protocol level.
489            let prg_ram_bytes = if h.prg_ram_window() == 0 {
490                0
491            } else {
492                h.prg_ram_window() as usize
493            };
494            let mmc1 = Mmc1::new(prg_rom, chr_rom, h.mirroring, prg_ram_bytes)
495                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
496            Box::new(mmc1)
497        }
498        2 => {
499            let uxrom = UxRom::new(prg_rom, chr_rom, h.mirroring)
500                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
501            Box::new(uxrom)
502        }
503        3 => {
504            let cnrom = CnRom::new(prg_rom, chr_rom, h.mirroring)
505                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
506            Box::new(cnrom)
507        }
508        // v2.9.6: mapper 4 submapper 5 carries the T9552 scrambler
509        // (`T9552.md`); it is otherwise an MMC3 board.
510        4 if h.is_nes2 && h.submapper == 5 => {
511            mmc3_board(Mmc3BoardKind::M4T9552, prg_rom, chr_rom, &h)?
512        }
513        249 => mmc3_board(Mmc3BoardKind::M249, prg_rom, chr_rom, &h)?,
514        4 => {
515            // MMC3, and the boards that share its iNES number. NES 2.0
516            // submappers (`NES_2_0_submappers.md`; corrected in v2.9.6, which
517            // had 1 as "NEC" and 4 as Sharp):
518            //   0 — Sharp MMC3 (default; Star Trek 25th Anniversary needs it).
519            //   1 — MMC6 (StarTropics): its own 1 KiB PRG-RAM scheme.
520            //   2 — MMC3C with hard-wired mirroring.
521            //   3 — Acclaim MC-ACC: falling-edge A12 counter, /8 prescaler.
522            //   4 — NEC MMC3 ("Loading the latch with 0 disables IRQ").
523            //   5 — T9552 scrambler, dispatched to `mmc3_boards.rs` above.
524            // An iNES 1.0 StarTropics stays a plain MMC3, as the page advises
525            // for headers that cannot say which chip it is.
526            let sub = if h.is_nes2 { h.submapper } else { 0 };
527            let revision = if sub == 4 {
528                Mmc3Revision::Nec
529            } else {
530                Mmc3Revision::Sharp
531            };
532            let variant = match sub {
533                1 => Mmc3Variant::Mmc6,
534                2 => Mmc3Variant::HardwiredMirroring,
535                3 => Mmc3Variant::McAcc,
536                _ => Mmc3Variant::Standard,
537            };
538            let prg_ram_bytes = if h.prg_ram_window() == 0 {
539                0
540            } else {
541                h.prg_ram_window() as usize
542            };
543            let mmc3 = Mmc3::new(prg_rom, chr_rom, h.mirroring, prg_ram_bytes, revision)
544                .map_err(|e| RomError::InvalidConfig(e.to_string()))?
545                .with_variant(variant);
546            Box::new(mmc3)
547        }
548        5 => {
549            // MMC5 v0: banking + ExRAM modes 10/11 + scanline IRQ. Several
550            // features deferred (vertical split, dual sprite/BG CHR for
551            // 8x16 sprites, ExGrafix attribute injection, audio extension);
552            // see `crates/rustynes-mappers/src/m005_mmc5.rs` module docs.
553            let prg_ram_bytes = if h.prg_ram_window() == 0 {
554                0
555            } else {
556                h.prg_ram_window() as usize
557            };
558            let mmc5 = Mmc5::new(prg_rom, chr_rom, h.mirroring, prg_ram_bytes)
559                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
560            Box::new(mmc5)
561        }
562        7 => {
563            let axrom =
564                AxRom::new(prg_rom, chr_rom).map_err(|e| RomError::InvalidConfig(e.to_string()))?;
565            Box::new(axrom)
566        }
567        9 => {
568            let mmc2 = Mmc2::new(prg_rom, chr_rom, h.mirroring)
569                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
570            Box::new(mmc2)
571        }
572        10 => {
573            let mmc4 = Mmc4::new(prg_rom, chr_rom, h.mirroring)
574                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
575            Box::new(mmc4)
576        }
577        11 => {
578            let cd = ColorDreams::new(prg_rom, chr_rom, h.mirroring)
579                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
580            Box::new(cd)
581        }
582        13 => {
583            let cprom = Cprom::new(prg_rom, h.mirroring)
584                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
585            Box::new(cprom)
586        }
587        // VRC2 / VRC4 share iNES mapper IDs across submapper variants.
588        // We dispatch by (mapper, submapper) pair.  Default mapping below
589        // is conservative; refer to nesdev wiki for the full table.
590        21 | 23 | 25 => {
591            // Mapper 21 -> VRC4 only (a/c).
592            // Mapper 22 -> VRC2a (no submapper).
593            // Mapper 23 -> VRC2b/VRC4e/VRC4f.
594            // Mapper 25 -> VRC2c/VRC4b/VRC4d.
595            // We model VRC4 with CPU-cycle IRQ as the superset; submapper
596            // selects pin-decoder variant.  Banking matches between VRC2
597            // and VRC4; the difference is mostly the IRQ counter which
598            // VRC2 lacks (we just leave it idle).
599            let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring)
600                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
601            Box::new(m021_vrc4)
602        }
603        22 => {
604            let vrc2 = Vrc2::new(prg_rom, chr_rom, 22, h.submapper, h.mirroring)
605                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
606            Box::new(vrc2)
607        }
608        24 | 26 => {
609            let vrc6 = Vrc6::new(prg_rom, chr_rom, h.mapper_id, h.mirroring)
610                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
611            Box::new(vrc6)
612        }
613        34 => {
614            let variant = if h.is_nes2 && h.submapper == 1 {
615                M34Variant::Nina001
616            } else {
617                M34Variant::Bnrom
618            };
619            let m34 = M34::new(prg_rom, chr_rom, h.mirroring, variant)
620                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
621            Box::new(m34)
622        }
623        66 => {
624            let gxrom = GxRom::new(prg_rom, chr_rom, h.mirroring)
625                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
626            Box::new(gxrom)
627        }
628        69 => {
629            let fme7 = Fme7::new(prg_rom, chr_rom, h.mirroring)
630                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
631            Box::new(fme7)
632        }
633        71 => {
634            // Some Camerica boards (BF9097) have $9000 mirroring control
635            // (subm 1).  Default off.
636            let has_single_screen = h.is_nes2 && h.submapper == 1;
637            let cam = Camerica::new(prg_rom, h.mirroring, has_single_screen)
638                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
639            Box::new(cam)
640        }
641        75 => {
642            let vrc1 = Vrc1::new(prg_rom, chr_rom, h.mirroring)
643                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
644            Box::new(vrc1)
645        }
646        19 => {
647            let n163 = Namco163::new(prg_rom, chr_rom, h.mirroring)
648                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
649            Box::new(n163)
650        }
651        16 => {
652            // Bandai FCG family. Submapper selects the register window +
653            // counter latching + EEPROM (nesdev INES_Mapper_016 §submappers):
654            //   0 — unspecified (emulate both windows; LZ93D50 behaviour).
655            //   4 — FCG-1/2 ($6000-$7FFF window, direct counter, no EEPROM).
656            //   5 — LZ93D50 ($8000-$FFFF window, latched counter, 24C02).
657            // Submappers 1/2/3 are deprecated aliases of mappers 159/157/153;
658            // a mapper-16 cart carrying them is treated as the closest FCG
659            // behaviour (LZ93D50 + 24C02) here.
660            let variant = match h.submapper {
661                4 => FcgVariant::Fcg,
662                5 => FcgVariant::Lz93d50_24c02,
663                _ => FcgVariant::Both,
664            };
665            let fcg = BandaiFcg::new(prg_rom, chr_rom, h.mirroring, variant)
666                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
667            Box::new(fcg)
668        }
669        153 => {
670            // v2.9.6: Bandai LZ93D50 with 8 KiB WRAM and an outer PRG bank
671            // (`INES_Mapper_153.md`).
672            let fcg = BandaiFcg::new(prg_rom, chr_rom, h.mirroring, FcgVariant::Lz93d50Wram)
673                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
674            Box::new(fcg)
675        }
676        159 => {
677            // Bandai LZ93D50 with a 128-byte X24C01 serial EEPROM.
678            let fcg = BandaiFcg::new(prg_rom, chr_rom, h.mirroring, FcgVariant::Lz93d50_24c01)
679                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
680            Box::new(fcg)
681        }
682        18 => {
683            // Jaleco SS88006 (Ganbare Goemon Gaiden, Magical Kids Doropie).
684            let ss = JalecoSs88006::new(prg_rom, chr_rom, h.mirroring)
685                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
686            Box::new(ss)
687        }
688        32 => {
689            // Irem G-101 (Image Fight, Major League, Kaiketsu Yancha Maru 2,
690            // Magical Pop's): two switchable 8 KiB PRG banks with a software
691            // swap-mode bit, eight 1 KiB CHR banks, software H/V mirroring.
692            // Submapper 1 (Major League) hard-wires single-screen A and ignores
693            // the $9000 mirroring bit. Many dumps omit the submapper byte; the
694            // $9000 mirroring control still works for the other titles, so we
695            // only force one-screen on an explicit submapper-1 flag.
696            let force_one_screen = h.is_nes2 && h.submapper == 1;
697            let m32 = IremG101::new(prg_rom, chr_rom, h.mirroring, force_one_screen)
698                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
699            Box::new(m32)
700        }
701        33 => {
702            // Taito TC0190 / TC0350 (Don Doko Don, Power Blazer): two
703            // switchable 8 KiB PRG banks, 2x2 KiB + 4x1 KiB CHR banks, and a
704            // software mirroring bit. No IRQ (that is the very-similar
705            // mapper 48 / TC0690).
706            let m33 = TaitoTc0190::new(prg_rom, chr_rom, h.mirroring)
707                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
708            Box::new(m33)
709        }
710        48 => {
711            // Taito TC0690 (Don Doko Don 2, Flintstones 2, Jetsons, Bakushou!!
712            // Jinsei Gekijou 3): TC0190 banking plus an MMC3-style A12 scanline
713            // IRQ and an $E000 mirroring register.
714            let m48 = TaitoTc0690::new(prg_rom, chr_rom, h.mirroring)
715                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
716            Box::new(m48)
717        }
718        70 => {
719            // Bandai discrete (UxROM-like): PRG bits 4-7, CHR bits 0-3.
720            let m70 = Bandai74::new(prg_rom, chr_rom, h.mirroring)
721                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
722            Box::new(m70)
723        }
724        87 => {
725            // Jaleco/Konami CNROM-style (Argus, Choplifter, The Goonies, City
726            // Connection): fixed PRG + a single bit-swapped 8 KiB CHR-bank
727            // register in the $6000-$7FFF window. No IRQ.
728            let m87 = Jaleco87::new(prg_rom, chr_rom, h.mirroring)
729                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
730            Box::new(m87)
731        }
732        89 => {
733            // Sunsoft-2 on the Sunsoft-3 board (Tenka no Goikenban: Mito
734            // Koumon): one $8000-$FFFF register switches a 16 KiB PRG bank, an
735            // 8 KiB CHR bank (with an A16 high bit), and one-screen mirroring.
736            // No IRQ.
737            let m89 = Sunsoft2::new(prg_rom, chr_rom, h.mirroring)
738                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
739            Box::new(m89)
740        }
741        184 => {
742            // Sunsoft-1 (Atlantis no Nazo, The Wing of Madoola, Kid Niki):
743            // fixed PRG + two 4 KiB CHR banks selected by a single register in
744            // the $6000-$7FFF window. No IRQ.
745            let m184 = Sunsoft1::new(prg_rom, chr_rom, h.mirroring)
746                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
747            Box::new(m184)
748        }
749        93 => {
750            // Sunsoft-3R / Sunsoft-2 IC (Shanghai, Fantasy Zone): UxROM-like
751            // 16 KiB PRG bank (bits 4-6) + CHR-RAM enable (bit 0); last PRG
752            // bank fixed. CHR is 8 KiB RAM. No IRQ.
753            let m93 = Sunsoft3r::new(prg_rom, h.mirroring)
754                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
755            Box::new(m93)
756        }
757        99 => {
758            // Nintendo Vs. System: fixed PRG (8/16/32 KiB) + an 8 KiB CHR bank
759            // selected by bit 2 of the $4016 write. Detecting mapper 99 forces
760            // ConsoleType::VsSystem + the 2C03 RGB PPU above (mapper-driven, so
761            // robust against the byte-7 trap). The bus forwards every $4016
762            // write to the mapper for the CHR-select bit.
763            let m99 = VsSystem::new(prg_rom, chr_rom, h.mirroring)
764                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
765            Box::new(m99)
766        }
767        152 => {
768            // Bandai 74161/161 1-screen (Arkanoid II, Pocket Zaurus): UxROM-like
769            // 16 KiB PRG bank (bits 4-6) + 8 KiB CHR bank (bits 0-3) + a bit-7
770            // software 1-screen mirroring select; last PRG bank fixed. No IRQ.
771            let m152 = Bandai152::new(prg_rom, chr_rom)
772                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
773            Box::new(m152)
774        }
775        73 => {
776            // Konami VRC3 (Salamander JP): 16-bit CPU-cycle IRQ, no CHR
777            // banking (8 KiB CHR-RAM), 16 KiB PRG bank at $F000.
778            let vrc3 = Vrc3::new(prg_rom, h.mirroring)
779                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
780            Box::new(vrc3)
781        }
782        64 => {
783            // Tengen RAMBO-1 (Klax, Skull & Crossbones): MMC3-like banking
784            // with a third switchable PRG bank, finer CHR banking, and a
785            // dual-mode (scanline A12 / CPU-cycle) IRQ.
786            let m64 = Rambo1::new(prg_rom, chr_rom, h.mirroring)
787                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
788            Box::new(m64)
789        }
790        65 => {
791            // Irem H3001 (Daiku no Gen-san 2, Spartan X 2): 16-bit CPU-cycle
792            // down-counter IRQ with a write-high/write-low reload latch.
793            let m65 = IremH3001::new(prg_rom, chr_rom, h.mirroring)
794                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
795            Box::new(m65)
796        }
797        67 => {
798            // Sunsoft-3 (Fantasy Zone 2): 16-bit CPU-cycle IRQ written as a
799            // two-write (high-then-low) toggling counter.
800            let m67 = Sunsoft3::new(prg_rom, chr_rom, h.mirroring)
801                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
802            Box::new(m67)
803        }
804        68 => {
805            // Sunsoft-4 (After Burner, Maharaja): PRG/CHR banking + CHR-ROM
806            // as nametables (two nametable bank registers + enable bit). No
807            // IRQ.
808            let m68 = Sunsoft4::new(prg_rom, chr_rom, h.mirroring)
809                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
810            Box::new(m68)
811        }
812        78 => {
813            // Holy Diver / Uchuusen Cosmo Carrier: UxROM-like 16 KiB PRG +
814            // 8 KiB CHR with submapper-selected mirroring. NES 2.0
815            // submapper 3 = Holy Diver (H/V switch); submapper 1 = Cosmo
816            // Carrier (single-screen A/B). Without a submapper (iNES 1.0, or
817            // NES 2.0 submapper 0) the header's "alternative nametables" bit
818            // decides, per NESdev `INES_Mapper_078`: iNES images "often set
819            // [it] for Holy Diver and cleared it for Cosmo Carrier", and
820            // Nestopia / FCEUX default to Cosmo Carrier's 1scA/1scB wiring.
821            // Until v2.9.8 the no-submapper case was always Holy Diver, which
822            // ignored the bit and contradicted both statements.
823            let variant = match (h.is_nes2, h.submapper) {
824                (true, 1) => M78Variant::CosmoCarrier,
825                (true, 3) => M78Variant::HolyDiver,
826                _ if h.mirroring == Mirroring::FourScreen => M78Variant::HolyDiver,
827                _ => M78Variant::CosmoCarrier,
828            };
829            let m78 = M78::new(prg_rom, chr_rom, variant)
830                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
831            Box::new(m78)
832        }
833        118 => {
834            // TxSROM / TLSROM (Armadillo, NES Play Action Football, Alien
835            // Syndrome): MMC3 banking + IRQ plus per-bank nametable mirroring
836            // driven by CHR bank bit 7.
837            let prg_ram_bytes = if h.prg_ram_window() == 0 {
838                0
839            } else {
840                h.prg_ram_window() as usize
841            };
842            let m118 = TxSrom::new(prg_rom, chr_rom, h.mirroring, prg_ram_bytes)
843                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
844            Box::new(m118)
845        }
846        119 => {
847            // TQROM (Pin*Bot, High Speed): MMC3 PRG/IRQ/mirroring plus a mixed
848            // CHR address space — 64 KiB CHR-ROM + 8 KiB CHR-RAM, selected per
849            // 1 KiB bank by bit 6 of the resolved CHR bank number (set =
850            // CHR-RAM). See `crates/rustynes-mappers/src/m119_tqrom.rs`.
851            let prg_ram_bytes = if h.prg_ram_window() == 0 {
852                0
853            } else {
854                h.prg_ram_window() as usize
855            };
856            let m119 = Tqrom::new(prg_rom, chr_rom, h.mirroring, prg_ram_bytes)
857                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
858            Box::new(m119)
859        }
860        210 => {
861            // Namco 175 / 340: Namco-163-board variants without the
862            // expansion audio (and without an IRQ on either). NES 2.0
863            // submapper 1 = Namco 175 (hardwired H/V mirroring, optional
864            // enable-gated WRAM); submapper 2 = Namco 340 (H/V/1sc mirroring
865            // control). Without a submapper, the wiki recommends 175 if the
866            // header battery bit is set (WRAM present) and 340 otherwise.
867            let board = if h.is_nes2 && h.submapper == 2 {
868                Namco175Board::N340
869            } else if (h.is_nes2 && h.submapper == 1) || h.has_battery {
870                Namco175Board::N175
871            } else {
872                Namco175Board::N340
873            };
874            let m210 = Namco175::new(prg_rom, chr_rom, h.mirroring, board)
875                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
876            Box::new(m210)
877        }
878        80 => {
879            // Taito X1-005 (Kyonshiizu 2, Bakushou!! Jinsei Gekijou): a small
880            // $7EF0-$7EFF register window (two 2 KiB + four 1 KiB CHR banks,
881            // two switchable 8 KiB PRG banks, software H/V mirroring) plus an
882            // on-cart 128-byte battery RAM at $7F00-$7FFF unlocked by writing
883            // $A3 to both $7EF8 and $7EF9. No IRQ.
884            let m80 = TaitoX1005::new(prg_rom, chr_rom, h.mirroring)
885                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
886            Box::new(m80)
887        }
888        82 => {
889            // Taito X1-017 (Kyuukyoku Harikiri Koushien / Stadium III): like the
890            // X1-005 but with a CHR A12-inversion mode bit (the non-linear
891            // 2 KiB/1 KiB CHR-region swap), three protectable 8 KiB PRG-RAM
892            // regions, value-shifted CHR (2 KiB banks >> 1) + PRG ($7EFA-$7EFC
893            // banks >> 2) registers. The IRQ surface is decoded but unused by
894            // the licensed games.
895            let m82 = TaitoX1017::new(prg_rom, chr_rom, h.mirroring)
896                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
897            Box::new(m82)
898        }
899        151 => {
900            // Konami VS (Vs. Gradius / Vs. The Goonies): Konami's VRC1
901            // silicon on a Nintendo Vs. System board. Banking is byte-identical
902            // to mapper 75 (VRC1); the console type was forced to Vs. System +
903            // the 2C03 RGB PPU above (mapper-driven, like mapper 99).
904            let m151 = KonamiVs::new(prg_rom, chr_rom, h.mirroring)
905                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
906            Box::new(m151)
907        }
908        88 => {
909            // Namco 118 with PPU A12 -> CHR A16 (disjoint 64 KiB halves).
910            let m88 = Namco118::new(prg_rom, chr_rom, h.mirroring, Namco118Board::M88)
911                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
912            Box::new(m88)
913        }
914        154 => {
915            // NAMCOT-3453: mapper 88 plus a one-screen nametable select. The
916            // board powers up with whatever the header claims; the game sets it
917            // on its first $8000-$FFFF write.
918            let m154 = Namco118::new(prg_rom, chr_rom, h.mirroring, Namco118Board::M154)
919                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
920            Box::new(m154)
921        }
922        206 => {
923            // DxROM / Namco 118 base: MMC3 banking subset, no IRQ / A12.
924            let m206 = Namco118::new(prg_rom, chr_rom, h.mirroring, Namco118Board::Dxrom)
925                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
926            Box::new(m206)
927        }
928        85 => {
929            // VRC7 (Mapper 85; Lagrange Point JP).  Banking + IRQ
930            // identical to VRC6.  FM audio is deferred per ADR-0004
931            // (`docs/adr/0004-vrc7-audio-deferred.md`) — there is no
932            // permissively-licensed Rust OPLL crate, and the workspace
933            // does not take a C build dependency.  The audio register
934            // surface at $9010 / $9030 is still decoded and latched so
935            // a future v1.x synthesizer integration can read the byte
936            // stream without changing the banking / IRQ / save-state
937            // layout.
938            let vrc7 = Vrc7::new(prg_rom, chr_rom, h.mirroring)
939                .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
940            Box::new(vrc7)
941        }
942        // --- v1.2.0 Workstream A, curated (Tier-1) long-tail boards. ---
943        // Simple discrete-logic mappers; see `tier.rs` (`MapperTier::Curated`)
944        // and `docs/adr/0011-mapper-tiering.md`. Each is register-decode
945        // unit-tested in its own per-board module.
946        38 => Box::new(
947            Bitcorp38::new(prg_rom, chr_rom, h.mirroring)
948                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
949        ),
950        41 => Box::new(
951            Caltron41::new(prg_rom, chr_rom, h.mirroring)
952                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
953        ),
954        79 => Box::new(
955            Nina0379::new(prg_rom, chr_rom, h.mirroring)
956                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
957        ),
958        86 => Box::new(
959            Jaleco86::new(prg_rom, chr_rom, h.mirroring)
960                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
961        ),
962        // Mapper 113: mirroring is register-controlled (no header arg).
963        113 => Box::new(
964            Nina006M113::new(prg_rom, chr_rom)
965                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
966        ),
967        140 => Box::new(
968            Jaleco140::new(prg_rom, chr_rom, h.mirroring)
969                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
970        ),
971        232 => Box::new(
972            Camerica232::new(prg_rom, chr_rom, h.mirroring)
973                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
974        ),
975        240 => Box::new(
976            Cne240::new(prg_rom, chr_rom, h.mirroring)
977                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
978        ),
979        241 => Box::new(
980            Bxrom241::new(prg_rom, chr_rom, h.mirroring)
981                .map_err(|e| RomError::InvalidConfig(e.to_string()))?
982                .with_battery(h.has_battery),
983        ),
984        // --- v1.2.0 Workstream A, best-effort (Tier-2) long-tail sweep
985        // Reference-ported discrete / multicart boards,
986        // register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
987        // (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
988        15 => Box::new(
989            Multicart15::new(prg_rom, &chr_rom)
990                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
991        ),
992        36 => Box::new(
993            Txc36::new(prg_rom, chr_rom, h.mirroring)
994                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
995        ),
996        39 => Box::new(
997            Subor39::new(prg_rom, chr_rom, h.mirroring)
998                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
999        ),
1000        61 => Box::new(
1001            Multicart61::new(prg_rom, &chr_rom)
1002                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1003        ),
1004        62 => Box::new(
1005            Multicart62::new(prg_rom, chr_rom, h.mirroring)
1006                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1007        ),
1008        72 => Box::new(
1009            Jaleco72::new(prg_rom, chr_rom, h.mirroring)
1010                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1011        ),
1012        77 => Box::new(
1013            Irem77::new(prg_rom, chr_rom).map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1014        ),
1015        92 => Box::new(
1016            Jaleco92::new(prg_rom, chr_rom, h.mirroring)
1017                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1018        ),
1019        96 => Box::new(
1020            Bandai96::new(prg_rom, chr_rom, h.mirroring)
1021                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1022        ),
1023        97 => Box::new(
1024            Irem97::new(prg_rom, chr_rom, h.mirroring)
1025                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1026        ),
1027        132 => Box::new(
1028            Txc132::new(prg_rom, chr_rom, h.mirroring)
1029                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1030        ),
1031        133 => Box::new(
1032            Sachen133::new(prg_rom, chr_rom, h.mirroring)
1033                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1034        ),
1035        145 => Box::new(
1036            Sachen145::new(prg_rom, chr_rom, h.mirroring)
1037                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1038        ),
1039        146 => Box::new(
1040            Sachen146::new(prg_rom, chr_rom, h.mirroring)
1041                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1042        ),
1043        147 => Box::new(
1044            Sachen3018M147::new(prg_rom, chr_rom, h.mirroring)
1045                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1046        ),
1047        148 => Box::new(
1048            Sachen148::new(prg_rom, chr_rom, h.mirroring)
1049                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1050        ),
1051        149 => Box::new(
1052            Sachen149::new(prg_rom, chr_rom, h.mirroring)
1053                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1054        ),
1055        150 => Box::new(
1056            Sachen150::new(prg_rom, chr_rom).map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1057        ),
1058        // Same SA-020A ASIC as mapper 150, on the PCB it was designed for; only
1059        // the CHR/PRG bank-bit significance differs (NESdev mapper 243 Errata).
1060        243 => Box::new(
1061            Sachen150::new_on_board(prg_rom, chr_rom, Sa020aBoard::Sa020a)
1062                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1063        ),
1064        180 => Box::new(
1065            Nichibutsu180::new(prg_rom, chr_rom, h.mirroring)
1066                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1067        ),
1068        185 => Box::new(
1069            CnRom185::new(prg_rom, chr_rom, h.mirroring, h.submapper)
1070                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1071        ),
1072        200 => Box::new(
1073            Multicart200::new(prg_rom, chr_rom, h.mirroring)
1074                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1075        ),
1076        201 => Box::new(
1077            Multicart201::new(prg_rom, chr_rom, h.mirroring)
1078                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1079        ),
1080        202 => Box::new(
1081            Multicart202::new(prg_rom, chr_rom, h.mirroring)
1082                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1083        ),
1084        203 => Box::new(
1085            Multicart203::new(prg_rom, chr_rom, h.mirroring)
1086                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1087        ),
1088        212 => Box::new(
1089            Multicart212::new(prg_rom, chr_rom, h.mirroring)
1090                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1091        ),
1092        213 => Box::new(
1093            Multicart213::new(prg_rom, chr_rom, h.mirroring)
1094                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1095        ),
1096        214 => Box::new(
1097            Multicart214::new(prg_rom, chr_rom, h.mirroring)
1098                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1099        ),
1100        // --- v1.3.0 "Bedrock" Workstream D1, best-effort (Tier-2) sweep
1101        // Reference-ported discrete / multicart boards,
1102        // register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
1103        // (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
1104        29 => Box::new(
1105            Cufrom29::new(prg_rom, &chr_rom, h.mirroring)
1106                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1107        ),
1108        31 => Box::new(
1109            Inl31::new(prg_rom, &chr_rom, h.mirroring)
1110                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1111        ),
1112        58 => Box::new(
1113            Multicart58::new(prg_rom, chr_rom, h.mirroring)
1114                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1115        ),
1116        60 => Box::new(
1117            Multicart60::new(prg_rom, chr_rom, h.mirroring)
1118                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1119        ),
1120        94 => Box::new(
1121            Un1rom94::new(prg_rom, &chr_rom, h.mirroring)
1122                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1123        ),
1124        101 => Box::new(
1125            Jaleco101::new(prg_rom, chr_rom, h.mirroring)
1126                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1127        ),
1128        107 => Box::new(
1129            MagicDragon107::new(prg_rom, chr_rom, h.mirroring)
1130                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1131        ),
1132        // Mapper 111 with CHR-ROM is not GTROM: it is the Chinese *Ninja
1133        // Ryukenden* translation's board, an MMC1 variant whose registers are
1134        // written directly with six data bits and address 256 KiB of CHR
1135        // (`GTROM.md`, "Variations"; forums.nesdev.org t=24276). Neither source
1136        // gives its address decode or bit mapping, so it is refused with a
1137        // clear message rather than run as a GTROM that renders garbage.
1138        111 if !chr_rom.is_empty() => {
1139            return Err(RomError::InvalidConfig(
1140                "mapper 111 with CHR-ROM is the Ninja Ryukenden MMC1 variant, which is not \
1141                 supported (GTROM carries CHR-RAM)"
1142                    .into(),
1143            ));
1144        }
1145        // Mapper 111: 4-screen CHR-RAM; no header CHR / mirroring arg.
1146        111 => Box::new(
1147            Gtrom111::new(prg_rom, &chr_rom).map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1148        ),
1149        143 => Box::new(
1150            SachenTca01M143::new(prg_rom, chr_rom, h.mirroring)
1151                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1152        ),
1153        // Mappers 177 / 179: CHR-RAM; mirroring is register-controlled.
1154        177 => Box::new(
1155            Hengedianzi177::new(prg_rom, &chr_rom)
1156                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1157        ),
1158        179 => Box::new(
1159            Hengedianzi179::new(prg_rom, &chr_rom)
1160                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1161        ),
1162        218 => {
1163            // Magic Floor wires CIRAM A10 to one PPU address line chosen by
1164            // the RAW flags-6 bits 3 and 0 (NESdev "INES Mapper 218"): with
1165            // bit 3 set, bit 0 picks A13 (`$A9`, single screen B) over A12
1166            // (`$A8`, single screen A). The generic parser has already folded
1167            // bit 0 into `FourScreen`, so re-read it here, as mapper 30 does.
1168            let mirroring = if h.four_screen {
1169                if (bytes[6] & 0x01) != 0 {
1170                    Mirroring::SingleScreenB
1171                } else {
1172                    Mirroring::SingleScreenA
1173                }
1174            } else {
1175                h.mirroring
1176            };
1177            Box::new(
1178                MagicFloor218::new(prg_rom, &chr_rom, mirroring)
1179                    .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1180            )
1181        }
1182        231 => Box::new(
1183            Multicart231::new(prg_rom, &chr_rom, h.mirroring)
1184                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1185        ),
1186        234 => Box::new(
1187            Maxi15M234::new(prg_rom, chr_rom, h.mirroring)
1188                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1189        ),
1190        // --- v1.4.0 "Fidelity" Workstream G, best-effort (Tier-2) sweep
1191        // Reference-ported discrete / multicart boards,
1192        // register-decode unit-tested only, NOT accuracy-gated. See `tier.rs`
1193        // (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
1194        28 => Box::new(
1195            Action53M28::new(prg_rom, &chr_rom, h.mirroring)
1196                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1197        ),
1198        // A mapper-30 header that also declares CHR-ROM is provably wrong:
1199        // UNROM 512 is a CHR-RAM-only board, so there is no CHR-ROM for the
1200        // header to be describing. The known dumps in this shape are Waixing
1201        // 2005+ re-releases on the FS005 board, which is mapper 176 submapper 2
1202        // -- an 8025 ASIC whose MMC3 core happens to run a mapper-30-sized image
1203        // without complaint, which is exactly why the mis-header goes unnoticed.
1204        // Route them to the board they actually are rather than emulating a
1205        // board that cannot exist. Genuine mapper-30 images declare 0 CHR-ROM
1206        // and are untouched.
1207        30 if !chr_rom.is_empty() => Box::new(
1208            new_m176(prg_rom, chr_rom, h.mirroring, 2)
1209                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1210        ),
1211        30 => {
1212            // UNROM-512 decodes its nametable wiring from the RAW iNES byte-6
1213            // N/M bits (bit 3 = four-screen, bit 0 = vertical), which use an
1214            // inverted convention from the generic parser; pass them through.
1215            // `bytes[6]` is always the real iNES header here (the UNIF path
1216            // recurses on a synthesized iNES image before reaching this point).
1217            let four_screen = h.four_screen;
1218            let vertical = (bytes[6] & 0x01) != 0;
1219            Box::new(
1220                Unrom512M30::new(
1221                    prg_rom,
1222                    &chr_rom,
1223                    four_screen,
1224                    vertical,
1225                    h.submapper,
1226                    h.has_battery,
1227                )
1228                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1229            )
1230        }
1231        63 => Box::new(
1232            Ntdec63::new(prg_rom, &chr_rom, h.mirroring)
1233                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1234        ),
1235        76 => Box::new(
1236            Namcot3446M76::new(prg_rom, chr_rom, h.mirroring)
1237                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1238        ),
1239        174 => Box::new(
1240            Ntdec174::new(prg_rom, chr_rom, h.mirroring)
1241                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1242        ),
1243        225 => Box::new(
1244            Multicart225::new(prg_rom, chr_rom, h.mirroring)
1245                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1246        ),
1247        226 => Box::new(
1248            Multicart226::new(prg_rom, &chr_rom, h.mirroring)
1249                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1250        ),
1251        227 => Box::new(
1252            Multicart227::new(prg_rom, &chr_rom, h.mirroring)
1253                .map_err(|e| RomError::InvalidConfig(e.to_string()))?
1254                .with_battery(h.has_battery),
1255        ),
1256        229 => Box::new(
1257            Multicart229::new(prg_rom, chr_rom, h.mirroring)
1258                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1259        ),
1260        233 => Box::new(
1261            Multicart233::new(prg_rom, &chr_rom, h.mirroring)
1262                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1263        ),
1264        242 => Box::new(
1265            Waixing242::new(prg_rom, &chr_rom, h.mirroring)
1266                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1267        ),
1268        246 => Box::new(
1269            FongShenBang246::new(prg_rom, chr_rom, h.mirroring)
1270                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1271        ),
1272        // --- v1.5.0 "Lens" Workstream F, best-effort (Tier-2) sweep
1273        // Reference-ported discrete / multicart / pirate boards,
1274        // register-decode + save-state unit-tested only, NOT accuracy-gated.
1275        // See `tier.rs` (`MapperTier::BestEffort`) + `docs/adr/0011-mapper-tiering.md`.
1276        40 => Box::new(
1277            Ntdec2722M40::new(prg_rom, &chr_rom, h.mirroring)
1278                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1279        ),
1280        81 => Box::new(
1281            Ntdec81::new(prg_rom, chr_rom, h.mirroring)
1282                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1283        ),
1284        95 => Box::new(
1285            Namcot3425M95::new(prg_rom, chr_rom, h.mirroring)
1286                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1287        ),
1288        112 => Box::new(
1289            NtdecAsder112::new(prg_rom, chr_rom, h.mirroring)
1290                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1291        ),
1292        137 => Box::new(
1293            Sachen8259M137::new(prg_rom, chr_rom, h.mirroring)
1294                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1295        ),
1296        156 => Box::new(
1297            Daou156::new(prg_rom, chr_rom, h.mirroring)
1298                .map_err(|e| RomError::InvalidConfig(e.to_string()))?
1299                .with_battery(h.has_battery),
1300        ),
1301        162 => Box::new(
1302            WaixingFs304M162::new(prg_rom, &chr_rom, h.mirroring)
1303                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1304        ),
1305        178 => Box::new(
1306            Waixing178::new(prg_rom, &chr_rom, h.mirroring)
1307                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1308        ),
1309        244 => Box::new(
1310            Decathlon244::new(prg_rom, chr_rom, h.mirroring)
1311                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1312        ),
1313        250 => Box::new(
1314            Nitra250::new(prg_rom, chr_rom, h.mirroring)
1315                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1316        ),
1317        // --- v1.6.0 "Studio" Workstream E, best-effort (Tier-2): J.Y. Company
1318        // ASIC. One silicon implementation behind three iNES mapper numbers;
1319        // 90 inhibits the ROM-nametable / extended-mirroring feature, 209
1320        // register-enables it, 211 forces it on. The register-decode is derived
1321        // from Mesen2's `JyCompany` (GPL-3.0-or-later) and the nesdev "J.Y.
1322        // Company ASIC" page. See NOTICE + docs/originality-and-provenance.md §1.
1323        // Register-decode +
1324        // save-state unit-tested only, NOT accuracy-gated (`tier.rs`).
1325        90 => Box::new(
1326            JyAsic::new(prg_rom, chr_rom, h.mirroring, JyBoard::M90)
1327                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1328        ),
1329        209 => Box::new(
1330            JyAsic::new(prg_rom, chr_rom, h.mirroring, JyBoard::M209)
1331                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1332        ),
1333        211 => Box::new(
1334            JyAsic::new(prg_rom, chr_rom, h.mirroring, JyBoard::M211)
1335                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1336        ),
1337        // J.Y. Company ASIC, single-game "extended" board (same silicon as
1338        // 90/209/211; the ROM-nametable feature is register-enabled like 209).
1339        35 => Box::new(
1340            JyAsic::new(prg_rom, chr_rom, h.mirroring, JyBoard::M35)
1341                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1342        ),
1343        // --- v1.6.0 "Studio" Workstream E, best-effort (Tier-2) sweep
1344        // MMC3-clone variants (shared MMC3-style core + A12 IRQ),
1345        // Sachen 8259 A/B/C, and discrete multicarts. Register-decode +
1346        // save-state unit-tested only, NOT accuracy-gated (`tier.rs`).
1347        // v2.9.6 "Roster": MMC3 boards written from their NESdev pages
1348        // (`mmc3_boards.rs`). Mapper 12 submapper 1 (the Magic Card 4M
1349        // extraction) is a different device and is not supported.
1350        12 if h.submapper == 0 => mmc3_board(Mmc3BoardKind::M12, prg_rom, chr_rom, &h)?,
1351        83 => Box::new(
1352            Cony83::new(prg_rom, chr_rom, h.is_nes2.then_some(h.submapper))
1353                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1354        ),
1355        91 => Box::new(
1356            Jy91::new(prg_rom, chr_rom, h.mirroring, h.submapper)
1357                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1358        ),
1359        105 => Box::new(
1360            NesEvent105::new(prg_rom, h.mirroring)
1361                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1362        ),
1363        163 => Box::new(
1364            Nanjing163::new(prg_rom, h.mirroring)
1365                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1366        ),
1367        228 => Box::new(
1368            Action52M228::new(prg_rom, chr_rom)
1369                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1370        ),
1371        37 => mmc3_board(Mmc3BoardKind::M37, prg_rom, chr_rom, &h)?,
1372        45 => mmc3_board(Mmc3BoardKind::M45, prg_rom, chr_rom, &h)?,
1373        47 => mmc3_board(Mmc3BoardKind::M47, prg_rom, chr_rom, &h)?,
1374        74 => mmc3_board(Mmc3BoardKind::M74, prg_rom, chr_rom, &h)?,
1375        121 => mmc3_board(Mmc3BoardKind::M121, prg_rom, chr_rom, &h)?,
1376        191 => mmc3_board(Mmc3BoardKind::M191, prg_rom, chr_rom, &h)?,
1377        192 => mmc3_board(Mmc3BoardKind::M192, prg_rom, chr_rom, &h)?,
1378        194 => mmc3_board(Mmc3BoardKind::M194, prg_rom, chr_rom, &h)?,
1379        195 => mmc3_board(Mmc3BoardKind::M195, prg_rom, chr_rom, &h)?,
1380        44 => Box::new(
1381            new_m44(prg_rom, chr_rom, h.mirroring)
1382                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1383        ),
1384        49 => Box::new(
1385            new_m49(prg_rom, chr_rom, h.mirroring)
1386                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1387        ),
1388        52 => Box::new(
1389            new_m52(prg_rom, chr_rom, h.mirroring)
1390                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1391        ),
1392        115 => Box::new(
1393            new_m115(prg_rom, chr_rom, h.mirroring)
1394                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1395        ),
1396        134 => Box::new(
1397            new_m134(prg_rom, chr_rom, h.mirroring)
1398                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1399        ),
1400        189 => Box::new(
1401            new_m189(prg_rom, chr_rom, h.mirroring)
1402                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1403        ),
1404        205 => Box::new(
1405            new_m205(prg_rom, chr_rom, h.mirroring)
1406                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1407        ),
1408        238 => Box::new(
1409            new_m238(prg_rom, chr_rom, h.mirroring)
1410                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1411        ),
1412        245 => Box::new(
1413            new_m245(prg_rom, chr_rom, h.mirroring)
1414                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1415        ),
1416        348 => Box::new(
1417            new_m348(prg_rom, chr_rom, h.mirroring)
1418                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1419        ),
1420        366 => Box::new(
1421            new_m366(prg_rom, chr_rom, h.mirroring)
1422                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1423        ),
1424        // Sachen 8259 A/B/C (the 2 KiB-CHR variants; 8259D is mapper 137).
1425        141 => Box::new(
1426            Sachen8259::new(Sachen8259Variant::A, prg_rom, chr_rom, h.mirroring)
1427                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1428        ),
1429        138 => Box::new(
1430            Sachen8259::new(Sachen8259Variant::B, prg_rom, chr_rom, h.mirroring)
1431                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1432        ),
1433        139 => Box::new(
1434            Sachen8259::new(Sachen8259Variant::C, prg_rom, chr_rom, h.mirroring)
1435                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1436        ),
1437        // Discrete unlicensed / multicart boards. m42 + m50 carry a CPU-cycle
1438        // IRQ; the rest are hook-free.
1439        42 => Box::new(
1440            Mapper42::new(prg_rom, chr_rom, h.mirroring)
1441                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1442        ),
1443        50 => Box::new(
1444            Mapper50::new(prg_rom, &chr_rom, h.mirroring)
1445                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1446        ),
1447        46 => Box::new(
1448            new_m46(prg_rom, chr_rom, h.mirroring)
1449                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1450        ),
1451        51 => Box::new(
1452            new_m51(prg_rom, chr_rom, h.mirroring)
1453                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1454        ),
1455        57 => Box::new(
1456            new_m57(prg_rom, chr_rom, h.mirroring)
1457                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1458        ),
1459        104 => Box::new(
1460            new_m104(prg_rom, chr_rom, h.mirroring)
1461                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1462        ),
1463        120 => Box::new(
1464            new_m120(prg_rom, chr_rom, h.mirroring)
1465                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1466        ),
1467        290 => Box::new(
1468            new_m290(prg_rom, chr_rom, h.mirroring)
1469                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1470        ),
1471        301 => Box::new(
1472            new_m301(prg_rom, chr_rom, h.mirroring)
1473                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1474        ),
1475        // --- v1.7.0 "Forge" Workstream G1, best-effort (Tier-2) reusable-ASIC
1476        // BMC / pirate cores. FK23C / COOLBOY / MINDKIDS / Sachen /
1477        // Waixing / Kaiser clusters. Register-decode + save-state unit-tested
1478        // only, NOT accuracy-gated (`tier.rs`).
1479        176 => Box::new(
1480            new_m176(prg_rom, chr_rom, h.mirroring, h.submapper)
1481                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1482        ),
1483        268 => Box::new(
1484            new_m268(prg_rom, chr_rom, h.mirroring)
1485                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1486        ),
1487        513 => Box::new(
1488            new_m513(prg_rom, chr_rom, h.mirroring)
1489                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1490        ),
1491        136 => Box::new(
1492            new_m136(prg_rom, chr_rom, h.mirroring)
1493                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1494        ),
1495        164 => Box::new(
1496            new_m164(prg_rom, chr_rom, h.mirroring)
1497                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1498        ),
1499        253 => Box::new(
1500            new_m253(prg_rom, chr_rom, h.mirroring)
1501                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1502        ),
1503        286 => Box::new(
1504            new_m286(prg_rom, chr_rom, h.mirroring)
1505                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1506        ),
1507        56 => Box::new(
1508            new_m56(prg_rom, chr_rom, h.mirroring)
1509                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1510        ),
1511        142 => Box::new(
1512            new_m142(prg_rom, chr_rom, h.mirroring)
1513                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1514        ),
1515        303 => Box::new(
1516            new_m303(prg_rom, chr_rom, h.mirroring)
1517                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1518        ),
1519        305 => Box::new(
1520            new_m305(prg_rom, chr_rom, h.mirroring)
1521                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1522        ),
1523        306 => Box::new(
1524            new_m306(prg_rom, chr_rom, h.mirroring)
1525                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1526        ),
1527        312 => Box::new(
1528            new_m312(prg_rom, chr_rom, h.mirroring)
1529                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1530        ),
1531        261 => Box::new(
1532            new_m261(prg_rom, chr_rom, h.mirroring)
1533                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1534        ),
1535        289 => Box::new(
1536            new_m289(prg_rom, chr_rom, h.mirroring)
1537                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1538        ),
1539        320 => Box::new(
1540            new_m320(prg_rom, chr_rom, h.mirroring)
1541                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1542        ),
1543        336 => Box::new(
1544            new_m336(prg_rom, chr_rom, h.mirroring)
1545                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1546        ),
1547        349 => Box::new(
1548            new_m349(prg_rom, chr_rom, h.mirroring)
1549                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1550        ),
1551        // --- v1.8.9 "Backlog" beta.6, best-effort (Tier-2) NTDEC / TXC / BMC
1552        // multicart cores. Register-decode + save-state unit-tested
1553        // only, NOT accuracy-gated (`tier.rs`).
1554        193 => Box::new(
1555            new_m193(prg_rom, chr_rom, h.mirroring)
1556                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1557        ),
1558        204 => Box::new(
1559            new_m204(prg_rom, chr_rom, h.mirroring)
1560                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1561        ),
1562        221 => Box::new(
1563            new_m221(prg_rom, chr_rom, h.mirroring)
1564                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1565        ),
1566        299 => Box::new(
1567            new_m299(prg_rom, chr_rom, h.mirroring)
1568                .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
1569        ),
1570        other => return Err(RomError::UnsupportedMapper(other)),
1571    };
1572
1573    // v2.9.6: on a self-flashable board the flash chip IS the non-volatile
1574    // save, whatever the header's battery bit says (GTROM headers do not set
1575    // it, and UNROM 512 submappers 1/3/4 are flashable without it). The
1576    // frontends persist a battery save only when this flag is set, so it is
1577    // raised here for the boards whose `save_data()` is their flash image.
1578    let mut cart = cart;
1579    if matches!(h.mapper_id, 30 | 111) && !mapper.save_data().is_empty() {
1580        cart.has_battery = true;
1581    }
1582
1583    Ok((cart, mapper))
1584}
1585
1586#[cfg(test)]
1587#[allow(clippy::cast_possible_truncation)]
1588mod tests {
1589    use super::*;
1590    use alloc::{vec, vec::Vec};
1591
1592    fn synth_nrom_rom(prg_kib: usize, chr_kib: usize) -> Vec<u8> {
1593        let mut bytes = Vec::with_capacity(16 + prg_kib * 1024 + chr_kib * 1024);
1594        bytes.extend_from_slice(&header::MAGIC);
1595        bytes.push((prg_kib / 16) as u8); // PRG units
1596        bytes.push((chr_kib / 8) as u8); // CHR units
1597        bytes.push(0x00); // flags6: mapper 0, horizontal
1598        bytes.push(0x00); // flags7
1599        bytes.extend_from_slice(&[0u8; 8]); // bytes 8-15
1600
1601        // PRG payload: byte = lower 8 bits of address.
1602        for i in 0..(prg_kib * 1024) {
1603            bytes.push((i & 0xFF) as u8);
1604        }
1605        // CHR payload: byte = inverted address.
1606        for i in 0..(chr_kib * 1024) {
1607            bytes.push(!(i as u8));
1608        }
1609        bytes
1610    }
1611
1612    #[test]
1613    fn version_is_non_empty() {
1614        assert_ne!(version(), "");
1615    }
1616
1617    #[test]
1618    fn parse_synthetic_nrom_16k() {
1619        let rom = synth_nrom_rom(16, 8);
1620        let (cart, mut mapper) = parse(&rom).unwrap();
1621        assert_eq!(cart.mapper_id, 0);
1622        assert_eq!(cart.prg_rom.len(), 16 * 1024);
1623        assert_eq!(cart.chr_rom.len(), 8 * 1024);
1624        assert_eq!(cart.mirroring, Mirroring::Horizontal);
1625        // Confirm 16K mirroring through the mapper.
1626        assert_eq!(mapper.cpu_read(0x8000), mapper.cpu_read(0xC000));
1627    }
1628
1629    #[test]
1630    fn parse_synthetic_nrom_32k() {
1631        let rom = synth_nrom_rom(32, 8);
1632        let (cart, _mapper) = parse(&rom).unwrap();
1633        assert_eq!(cart.prg_rom.len(), 32 * 1024);
1634    }
1635
1636    /// A mapper-78 image: 32 KiB PRG, 8 KiB CHR. `flags6_low` carries the
1637    /// mirroring / alternative-nametables bits; `nes2_sub` makes it NES 2.0
1638    /// with that submapper.
1639    fn synth_m78(flags6_low: u8, nes2_sub: Option<u8>) -> Vec<u8> {
1640        let mut rom = synth_nrom_rom(32, 8);
1641        rom[6] = 0xE0 | (flags6_low & 0x0F); // mapper 78 low nibble = 0xE
1642        rom[7] = 0x40; // mapper 78 high nibble = 0x4
1643        if let Some(sub) = nes2_sub {
1644            rom[7] |= 0x08;
1645            rom[8] = sub << 4;
1646        }
1647        rom
1648    }
1649
1650    /// The mirroring a mapper-78 image selects once bit 3 of the bank register
1651    /// is set: Vertical on the Holy Diver wiring, single-screen B on Cosmo
1652    /// Carrier's.
1653    fn m78_mirroring_with_bit3(rom: &[u8]) -> Mirroring {
1654        let (_cart, mut mapper) = parse(rom).unwrap();
1655        mapper.cpu_write(0x8000, 0x08);
1656        mapper.current_mirroring()
1657    }
1658
1659    #[test]
1660    fn mapper_78_ines_header_selects_wiring_by_alt_nametables_bit() {
1661        // NESdev `INES_Mapper_078`: "iNES1 ROM image headers often set the
1662        // 'alternative nametables' flag for Holy Diver and cleared it for
1663        // Cosmo Carrier", and Nestopia / FCEUX default to Cosmo Carrier's
1664        // 1scA/1scB wiring. So an iNES 1.0 image with the flag clear is
1665        // Cosmo Carrier, and with it set is Holy Diver.
1666        assert_eq!(
1667            m78_mirroring_with_bit3(&synth_m78(0x00, None)),
1668            Mirroring::SingleScreenB,
1669            "iNES 1.0, flag clear: Cosmo Carrier wiring"
1670        );
1671        assert_eq!(
1672            m78_mirroring_with_bit3(&synth_m78(0x08, None)),
1673            Mirroring::Vertical,
1674            "iNES 1.0, flag set: Holy Diver wiring"
1675        );
1676        // NES 2.0 submapper 0 says nothing, so it falls back to the same rule.
1677        assert_eq!(
1678            m78_mirroring_with_bit3(&synth_m78(0x00, Some(0))),
1679            Mirroring::SingleScreenB,
1680            "NES 2.0 submapper 0, flag clear: Cosmo Carrier wiring"
1681        );
1682        // A named submapper wins over the flag, in both directions.
1683        assert_eq!(
1684            m78_mirroring_with_bit3(&synth_m78(0x08, Some(1))),
1685            Mirroring::SingleScreenB,
1686            "submapper 1: Cosmo Carrier"
1687        );
1688        assert_eq!(
1689            m78_mirroring_with_bit3(&synth_m78(0x00, Some(3))),
1690            Mirroring::Vertical,
1691            "submapper 3: Holy Diver"
1692        );
1693    }
1694
1695    #[test]
1696    fn parse_truncated_returns_typed_error() {
1697        let mut rom = synth_nrom_rom(32, 8);
1698        rom.truncate(16 + 100);
1699        let err = parse(&rom).err().expect("must error");
1700        assert!(
1701            matches!(err, RomError::Truncated { .. }),
1702            "expected Truncated, got {err:?}"
1703        );
1704    }
1705
1706    #[test]
1707    fn parse_unsupported_mapper_errors() {
1708        let mut rom = synth_nrom_rom(32, 8);
1709        // Set mapper 248 — outside the coverage matrix.
1710        // iNES encoding: mapper_lo = (byte6 >> 4) & 0xF, mapper_hi = byte7 & 0xF0.
1711        // 248 = 0xF8 -> lo nibble = 8, hi nibble = 0xF. byte6 = 0x80, byte7 = 0xF0.
1712        rom[6] = 0x80;
1713        rom[7] = 0xF0;
1714        let err = parse(&rom).err().expect("must error");
1715        assert!(
1716            matches!(err, RomError::UnsupportedMapper(_)),
1717            "expected UnsupportedMapper, got {err:?}"
1718        );
1719    }
1720
1721    #[test]
1722    fn parse_fds_image_reports_fds_unsupported() {
1723        // fwNES container header.
1724        let mut fwnes = alloc::vec![0u8; 16 + 65500];
1725        fwnes[0..4].copy_from_slice(b"FDS\x1A");
1726        fwnes[4] = 1; // one disk side
1727        assert!(
1728            matches!(parse(&fwnes).err(), Some(RomError::FdsUnsupported)),
1729            "fwNES FDS header must report FdsUnsupported"
1730        );
1731        // Raw (headerless) disk side opening with the disk-info block.
1732        let mut raw = alloc::vec![0u8; 65500];
1733        raw[0] = 0x01;
1734        raw[1..15].copy_from_slice(b"*NINTENDO-HVC*");
1735        assert!(
1736            matches!(parse(&raw).err(), Some(RomError::FdsUnsupported)),
1737            "raw FDS disk side must report FdsUnsupported"
1738        );
1739    }
1740
1741    #[test]
1742    fn parse_mapper_5_dispatches_to_mmc5() {
1743        // Synthesize a 32 KiB PRG / 8 KiB CHR ROM with mapper id 5 (MMC5).
1744        // iNES: 5 -> low nibble = 5, byte6 = 0x50.
1745        let mut rom = synth_nrom_rom(32, 8);
1746        rom[6] = 0x50;
1747        let (cart, mut mapper) = parse(&rom).expect("MMC5 ROM must parse");
1748        assert_eq!(cart.mapper_id, 5);
1749        // Default PRG mode is 3 (4x8K) and $5117 = last bank ROM, so
1750        // $E000 reads the last 8 KiB. Just exercise the read path.
1751        let _ = mapper.cpu_read(0xE000);
1752    }
1753
1754    #[test]
1755    fn parse_random_bytes_does_not_panic() {
1756        // Cheap smoke fuzz; fixed seeds keep test deterministic.
1757        let seeds: [u64; 8] = [1, 17, 99, 12345, 0xDEAD_BEEF, 0xCAFE, 0x55AA, 0xFF];
1758        for &s in &seeds {
1759            let mut state = s;
1760            let mut bytes = Vec::with_capacity(64);
1761            for _ in 0..64 {
1762                state = state
1763                    .wrapping_mul(6_364_136_223_846_793_005)
1764                    .wrapping_add(1);
1765                bytes.push((state >> 32) as u8);
1766            }
1767            // Should not panic; result is don't-care.
1768            let _ = parse(&bytes);
1769        }
1770    }
1771
1772    #[test]
1773    fn parse_with_trainer_skips_trainer_bytes() {
1774        let mut rom = Vec::new();
1775        rom.extend_from_slice(&header::MAGIC);
1776        rom.push(2); // 32 KiB PRG
1777        rom.push(1); // 8 KiB CHR
1778        rom.push(0b0000_0100); // trainer bit set
1779        rom.push(0);
1780        rom.extend_from_slice(&[0u8; 8]);
1781        rom.extend_from_slice(&[0xAB; 512]); // trainer
1782        rom.extend_from_slice(&vec![0u8; 32 * 1024]); // PRG
1783        rom.extend_from_slice(&vec![0u8; 8 * 1024]); // CHR
1784        let (cart, _) = parse(&rom).unwrap();
1785        assert!(cart.has_trainer);
1786        assert_eq!(cart.prg_rom.len(), 32 * 1024);
1787    }
1788}
1789
1790/// Tripwire pinning every expansion-audio **level** constant.
1791///
1792/// ## Why this exists
1793///
1794/// Changing one of these constants changes the audio of every game on that
1795/// board — correctly, when the change is a deliberate recalibration. The
1796/// problem is *where the evidence of that lives*. The 60-ROM commercial oracle
1797/// (`tests/external_real_games.rs`) hashes real cartridge audio and is the only
1798/// gate that would notice, but it needs `--features commercial-roms` **and**
1799/// local gitignored ROM dumps, so neither CI nor the default
1800/// `--features test-roms` gate can run it. A golden vector nothing executes is
1801/// not a gate.
1802///
1803/// That gap bit for real. `VRC6_MIX_SCALE` and all three MMC5 constants were
1804/// recalibrated in v2.1.6 (`fd82485c`, 2026-07-11); the commercial-oracle
1805/// snapshots had last been blessed on 2026-06-13, 28 days earlier. Six rows sat
1806/// silently stale across several releases until someone ran the suite by hand.
1807///
1808/// This test **can** run in CI. It fails the moment a level constant moves,
1809/// with instructions naming the suites that must be re-blessed in the same
1810/// change. It asserts nothing about correctness — the oracles do that
1811/// (`audio_expansion.rs`'s `level_db_*` decibel tests, `docs/apu-2a03.md`
1812/// §Expansion-audio levels). Its only job is to make a silent change loud.
1813///
1814/// **If this test fails and the change was intentional:** update the value
1815/// here, then re-bless BOTH `cargo test -p rustynes-test-harness --features
1816/// test-roms --test audio_expansion` and `cargo test -p rustynes-test-harness
1817/// --features test-roms,commercial-roms --test external_real_games` (the latter
1818/// needs the local dumps; if you do not have them, say so in the PR rather than
1819/// leaving the rows stale).
1820#[cfg(test)]
1821mod expansion_level_tripwire {
1822    const RE_BLESS: &str = "expansion-audio level constant changed -- re-bless \
1823        `audio_expansion` AND the gitignored `external_real_games` \
1824        (--features commercial-roms) in this same change; see this module's docs";
1825
1826    #[test]
1827    fn expansion_audio_levels_are_pinned() {
1828        assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 650, "VRC6: {RE_BLESS}");
1829        assert_eq!(
1830            crate::m005_mmc5::MMC5_PULSE_SCALE,
1831            650,
1832            "MMC5 pulse: {RE_BLESS}"
1833        );
1834        assert_eq!(crate::m005_mmc5::MMC5_PCM_SCALE, 40, "MMC5 PCM: {RE_BLESS}");
1835        assert_eq!(
1836            crate::m019_namco163::NAMCO163_MIX_SCALE,
1837            261,
1838            "N163: {RE_BLESS}"
1839        );
1840        assert_eq!(
1841            crate::m069_sunsoft_fme7::SUNSOFT5B_MIX_SCALE_NUM,
1842            2549,
1843            "5B numerator: {RE_BLESS}"
1844        );
1845        assert_eq!(
1846            crate::m069_sunsoft_fme7::SUNSOFT5B_MIX_SCALE_DEN,
1847            138,
1848            "5B denominator: {RE_BLESS}"
1849        );
1850    }
1851
1852    /// `MMC5_MIX_BIAS` is derived from the two MMC5 scales, so it cannot drift
1853    /// independently — but pin the derivation itself, since an edit to the
1854    /// formula would move every MMC5 game's DC offset without touching a scale.
1855    #[test]
1856    fn mmc5_mix_bias_stays_the_midpoint_of_its_two_scales() {
1857        assert_eq!(
1858            crate::m005_mmc5::MMC5_MIX_BIAS,
1859            i16::midpoint(
1860                30 * crate::m005_mmc5::MMC5_PULSE_SCALE,
1861                127 * crate::m005_mmc5::MMC5_PCM_SCALE
1862            ),
1863            "MMC5 bias formula: {RE_BLESS}"
1864        );
1865    }
1866}