Skip to main content

rustynes_mappers/
m176_bmc_fk23c.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the BMC-FK23C banking is derived from Mesen2 (GPL-3.0-or-later), `Waixing/Fk23C.h`. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! `FK23C` / `BMC-FK23C` (mapper 176) -- the most widely reused pirate ASIC.
6//!
7//! An MMC3 core wrapped in four outer registers at `$5000-$5FFF` that can
8//! *override* the MMC3 entirely: depending on the mode bits the chip either
9//! passes banking through to the inner MMC3 or substitutes its own 16/32 KiB
10//! layout, and it can redirect CHR to RAM. That flexibility is why one chip
11//! backs so many different Chinese multicarts -- the same silicon is
12//! configured per-cartridge by the outer registers rather than by a board
13//! respin.
14//!
15//! A best-effort (Tier-2) board: register-decode correctness verified against
16//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
17//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
18//! every bank select wraps with `% count`, so a register write can never index
19//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
20//! afford a panic on a register access.
21//!
22//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
23//! and `docs/mappers.md` §Mapper coverage matrix.
24
25#![allow(
26    clippy::cast_possible_truncation,
27    clippy::cast_lossless,
28    clippy::match_same_arms,
29    clippy::doc_markdown,
30    clippy::similar_names,
31    clippy::too_many_lines,
32    clippy::missing_const_for_fn,
33    clippy::struct_excessive_bools,
34    clippy::bool_to_int_with_if,
35    clippy::unreadable_literal
36)]
37
38use crate::a12_filter::A12RiseFilter;
39use crate::cartridge::Mirroring;
40use crate::mapper::{Mapper, MapperCaps, MapperError};
41use alloc::{boxed::Box, format, vec, vec::Vec};
42
43const PRG_BANK_8K: usize = 0x2000;
44const CHR_BANK_1K: usize = 0x0400;
45const NAMETABLE_SIZE: usize = 0x0400;
46const NAMETABLE_SIZE_U16: u16 = 0x0400;
47
48// v2 adds the FS005 RAM Configuration Register byte and the submapper-2 CHR-RAM
49// overlay, both of which change the serialized length; v3 (v2.9.7) packs the
50// A12 filter into the old `last_a12` byte. Only v3 loads since v2.9.8 (ADR 0042).
51const SAVE_STATE_VERSION: u8 = 3;
52
53// ---------------------------------------------------------------------------
54// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
55// ---------------------------------------------------------------------------
56
57const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
58    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
59    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
60    let physical = mirroring.physical_bank(table);
61    physical * NAMETABLE_SIZE + local
62}
63
64const fn mirroring_to_byte(m: Mirroring) -> u8 {
65    match m {
66        Mirroring::Horizontal => 0,
67        Mirroring::Vertical => 1,
68        Mirroring::SingleScreenA => 2,
69        Mirroring::SingleScreenB => 3,
70        Mirroring::FourScreen => 4,
71        Mirroring::MapperControlled => 5,
72    }
73}
74
75const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
76    match b {
77        0 => Mirroring::Horizontal,
78        1 => Mirroring::Vertical,
79        2 => Mirroring::SingleScreenA,
80        3 => Mirroring::SingleScreenB,
81        4 => Mirroring::FourScreen,
82        5 => Mirroring::MapperControlled,
83        _ => fallback,
84    }
85}
86
87/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
88fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
89    if prg.is_empty() || !prg.len().is_multiple_of(PRG_BANK_8K) {
90        return Err(MapperError::Invalid(format!(
91            "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
92            prg.len()
93        )));
94    }
95    Ok(())
96}
97
98// ===========================================================================
99// Fk23c (mapper 176) — Waixing FK23C 8/16 Mbit BMC ASIC.
100//
101// A $5000-$5003 config bank wrapping a full MMC3 register surface (eight bank
102// registers + the $8000/$A000/$C000/$E000 protocol + an A12 scanline IRQ) with
103// an outer-bank / extended-MMC3 / CNROM-CHR mode. This is the
104// register-decode-faithful BestEffort port: the MMC3 PRG/CHR layout plus the
105// FK23C $5000 banking modes (0-2 MMC3, 3 = 32 KiB, 4 = whole-256 KiB) and the
106// $5001/$5002 outer PRG/CHR base bits. Register map per the NESdev wiki FK23C /
107// mapper-176 documentation; the banking implementation is derived from Mesen2's
108// `Waixing/Fk23C.h` (GPL-3.0-or-later). See NOTICE + docs/originality-and-provenance.md §1.
109// ===========================================================================
110
111/// Waixing FK23C 8/16 Mbit BMC ASIC (mapper 176).
112pub struct Fk23c {
113    prg_rom: Box<[u8]>,
114    chr: Box<[u8]>,
115    chr_is_ram: bool,
116    vram: Box<[u8]>,
117    wram: Box<[u8]>,
118    mirroring: Mirroring,
119    prg_count_8k: usize,
120    chr_count_1k: usize,
121    // MMC3 core.
122    regs: [u8; 8],
123    bank_select: u8,
124    prg_mode: bool,
125    chr_mode: bool,
126    irq_counter: u8,
127    irq_latch: u8,
128    irq_reload: bool,
129    irq_enabled: bool,
130    irq_pending: bool,
131    /// v2.9.7 — MMC3's A12 rise filter (see `a12_filter`): the counter
132    /// clocks once per scanline on the eight-pulse stream the PPU reports.
133    a12: A12RiseFilter,
134    // FK23C config ($5000-$5003).
135    prg_banking_mode: u8,
136    outer_chr_64k: bool,
137    select_chr_ram: bool,
138    mmc3_chr_mode: bool,
139    cnrom_chr_mode: bool,
140    extended_mmc3: bool,
141    prg_base: u16,
142    chr_base: u8,
143    cnrom_chr_reg: u8,
144    // ---- FS005 (submapper 2) ------------------------------------------------
145    /// NES 2.0 submapper. Only `2` (WAIXING-FS005/FS006) changes behaviour; every
146    /// other value keeps the submapper-0/1 FK23C decode this type already had.
147    submapper: u8,
148    /// RAM Configuration Register (`$A001`), submapper 2 only. Latched verbatim;
149    /// the individual fields are decoded on use so that the "functions like MMC3
150    /// `$A001` until bit 5 is set" rule needs no shadow state.
151    ram_cfg: u8,
152    /// 8 KiB of CHR-RAM overlaying the first eight 1 KiB CHR banks when the
153    /// mixed CHR-ROM/CHR-RAM mode is armed (`$A001.5` set, `$A001.2` set).
154    /// Empty on every other submapper, so nothing else pays for it.
155    chr_ram: Box<[u8]>,
156}
157
158impl Fk23c {
159    /// Number of 1 KiB CHR banks the FS005 mixed-memory mode redirects to
160    /// CHR-RAM: "the first 8 KiB of CHR space".
161    const FS005_CHR_RAM_BANKS: usize = 8;
162
163    // 8 regs + 9 MMC3 scalars + 6 config bools + 2 prg_base + 3 (chr_base,
164    // cnrom_chr_reg, mirroring) + 1 ram_cfg.
165    const SAVE_LEN: usize = 8 + 9 + 6 + 2 + 3 + 1;
166
167    fn new(
168        prg_rom: Box<[u8]>,
169        chr_rom: Box<[u8]>,
170        mirroring: Mirroring,
171        submapper: u8,
172    ) -> Result<Self, MapperError> {
173        check_prg(&prg_rom, 176)?;
174        let chr_is_ram = chr_rom.is_empty();
175        let chr: Box<[u8]> = if chr_is_ram {
176            vec![0u8; 0x40000].into_boxed_slice() // up to 256 KiB CHR-RAM.
177        } else {
178            if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
179                return Err(MapperError::Invalid(format!(
180                    "mapper 176 CHR-ROM size {} is not a multiple of 1 KiB",
181                    chr_rom.len()
182                )));
183            }
184            chr_rom
185        };
186        let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
187        let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
188        Ok(Self {
189            prg_rom,
190            chr,
191            chr_is_ram,
192            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
193            wram: vec![0u8; 0x8000].into_boxed_slice(),
194            mirroring,
195            prg_count_8k,
196            chr_count_1k,
197            regs: [0; 8],
198            bank_select: 0,
199            prg_mode: false,
200            chr_mode: false,
201            irq_counter: 0,
202            irq_latch: 0,
203            irq_reload: false,
204            irq_enabled: false,
205            irq_pending: false,
206            a12: A12RiseFilter::new(),
207            prg_banking_mode: 0,
208            outer_chr_64k: false,
209            select_chr_ram: false,
210            mmc3_chr_mode: true,
211            cnrom_chr_mode: false,
212            extended_mmc3: false,
213            prg_base: 0,
214            chr_base: 0,
215            cnrom_chr_reg: 0,
216            submapper,
217            ram_cfg: 0,
218            chr_ram: if submapper == 2 {
219                vec![0u8; Self::FS005_CHR_RAM_BANKS * CHR_BANK_1K].into_boxed_slice()
220            } else {
221                Box::default()
222            },
223        })
224    }
225
226    // ---- FS005 (submapper 2) register decode --------------------------------
227    //
228    // Implemented from the NESdev wiki "INES Mapper 176" page (Registers ->
229    // Mirroring Register / RAM Configuration Register / Solder Pad / Protection)
230    // plus the NESdev forum thread that settles the `$46`/`$47` question. These
231    // are hardware facts read from public documentation, not a port: unlike the
232    // FK23C banking transforms above, no reference-emulator source was consulted
233    // for any of the code below.
234
235    /// True on the WAIXING-FS005/FS006 board (NES 2.0 submapper 2).
236    const fn is_fs005(&self) -> bool {
237        self.submapper == 2
238    }
239
240    /// True once `$A001.5` has turned `$A001` from an MMC3-compatible WRAM-protect
241    /// register into the FS005 RAM Configuration Register. Every other field of
242    /// `$A001` is documented as "ignored if Bit 5 is clear", so this gates them all.
243    const fn ram_cfg_enabled(&self) -> bool {
244        self.is_fs005() && (self.ram_cfg & 0x20) != 0
245    }
246
247    /// True while the mapper registers are visible in `$5000-$5FFF`.
248    ///
249    /// `$A001.6` clear (with the RAM Configuration Register enabled) swaps that
250    /// window for WRAM, which is the whole mechanism behind the Waixing
251    /// copy-protection sequence: the game stashes three bytes through the window
252    /// while it is RAM, re-enables the registers, and then reads the same bytes
253    /// back through `$6000-$7FFF` to derive the code it jumps to.
254    const fn outer_regs_enabled(&self) -> bool {
255        !self.ram_cfg_enabled() || (self.ram_cfg & 0x40) != 0
256    }
257
258    /// 8 KiB WRAM bank visible at `$6000-$7FFF`. Fixed at bank 0 until the RAM
259    /// Configuration Register is enabled, which is what grows the board's WRAM
260    /// from 8 KiB to 32 KiB.
261    const fn wram_bank(&self) -> usize {
262        if self.ram_cfg_enabled() {
263            (self.ram_cfg & 0x03) as usize
264        } else {
265            0
266        }
267    }
268
269    /// Offset of the `$5000-$5FFF` WRAM window: the second 4 KiB of WRAM bank 2,
270    /// fixed there regardless of which bank `$6000-$7FFF` currently shows.
271    const FS005_REG_WINDOW_OFF: usize = 2 * 0x2000 + 0x1000;
272
273    /// True when a resolved 1 KiB CHR bank should come from CHR-RAM rather than
274    /// CHR-ROM (`$A001.2`, the mapper-195-like mixed-memory mode).
275    const fn chr_bank_is_ram(&self, bank: usize) -> bool {
276        self.ram_cfg_enabled() && (self.ram_cfg & 0x04) != 0 && bank < Self::FS005_CHR_RAM_BANKS
277    }
278
279    fn prg_bank_mmc3(&self, slot: usize) -> usize {
280        let last = self.prg_count_8k - 1;
281        let second_last = last.saturating_sub(1);
282        let r6 = self.regs[6] as usize;
283        let r7 = self.regs[7] as usize;
284        match (slot, self.prg_mode) {
285            (0, false) => r6,
286            (0, true) => second_last,
287            (1, _) => r7,
288            (2, false) => second_last,
289            (2, true) => r6,
290            (3, _) => last,
291            _ => 0,
292        }
293    }
294
295    fn resolve_prg(&self, slot: usize) -> usize {
296        let outer = (self.prg_base as usize) << 1;
297        let bank = match self.prg_banking_mode {
298            0..=2 => {
299                if self.extended_mmc3 {
300                    self.prg_bank_mmc3(slot) | outer
301                } else {
302                    let inner_mask = 0x3F >> self.prg_banking_mode;
303                    let outer = outer & !inner_mask;
304                    (self.prg_bank_mmc3(slot) & inner_mask) | outer
305                }
306            }
307            3 => {
308                // 32 KiB fixed window from the outer base.
309                (outer & !0x03) + slot
310            }
311            _ => {
312                // mode 4: whole 256 KiB.
313                ((self.prg_base as usize & 0xFFE) << 1 & !0x07) + slot
314            }
315        };
316        bank % self.prg_count_8k
317    }
318
319    fn chr_bank_mmc3(&self, slot: usize) -> usize {
320        let banks: [usize; 8] = if self.chr_mode {
321            [
322                self.regs[2] as usize,
323                self.regs[3] as usize,
324                self.regs[4] as usize,
325                self.regs[5] as usize,
326                self.regs[0] as usize & !1,
327                (self.regs[0] as usize & !1) | 1,
328                self.regs[1] as usize & !1,
329                (self.regs[1] as usize & !1) | 1,
330            ]
331        } else {
332            [
333                self.regs[0] as usize & !1,
334                (self.regs[0] as usize & !1) | 1,
335                self.regs[1] as usize & !1,
336                (self.regs[1] as usize & !1) | 1,
337                self.regs[2] as usize,
338                self.regs[3] as usize,
339                self.regs[4] as usize,
340                self.regs[5] as usize,
341            ]
342        };
343        banks[slot & 0x07]
344    }
345
346    fn resolve_chr(&self, slot: usize) -> usize {
347        let bank = if self.mmc3_chr_mode {
348            let outer = (self.chr_base as usize) << 3;
349            if self.extended_mmc3 {
350                self.chr_bank_mmc3(slot) | outer
351            } else {
352                let inner_mask = if self.outer_chr_64k { 0x7F } else { 0xFF };
353                let outer = outer & !inner_mask;
354                (self.chr_bank_mmc3(slot) & inner_mask) | outer
355            }
356        } else {
357            // CNROM mode: 8 KiB blocks from the CNROM CHR reg + base.
358            let inner_mask = if self.cnrom_chr_mode {
359                if self.outer_chr_64k { 1 } else { 3 }
360            } else {
361                0
362            };
363            (((self.cnrom_chr_reg as usize & inner_mask) | self.chr_base as usize) << 3) + slot
364        };
365        bank % self.chr_count_1k
366    }
367
368    fn write_5000(&mut self, addr: u16, value: u8) {
369        match addr & 0x03 {
370            0 => {
371                self.prg_banking_mode = value & 0x07;
372                self.outer_chr_64k = value & 0x10 != 0;
373                self.select_chr_ram = value & 0x20 != 0;
374                self.mmc3_chr_mode = value & 0x40 == 0;
375                // $5xx0.3 = PRG A21, $5xx0.7 = PRG A22. `prg_base` bit N carries
376                // PRG A(14+N), so A21 -> bit 7 and A22 -> bit 8. Documented as
377                // submapper-2-only; on every other submapper those two bits have
378                // no PRG meaning, so leave the base alone rather than folding in
379                // address lines the board does not wire.
380                if self.is_fs005() {
381                    self.prg_base = (self.prg_base & !0x180)
382                        | (((value as u16) & 0x80) << 1)
383                        | (((value as u16) & 0x08) << 4);
384                }
385            }
386            1 => self.prg_base = (self.prg_base & !0x7F) | (value as u16 & 0x7F),
387            2 => {
388                // $5xx2.6-7 = PRG A24..A23 and $5xx2.5 = PRG A25 (submapper 2
389                // only), i.e. `prg_base` bits 9, 10 and 11 respectively.
390                if self.is_fs005() {
391                    self.prg_base = (self.prg_base & !0xE00)
392                        | (((value as u16) & 0x40) << 3)
393                        | (((value as u16) & 0x80) << 3)
394                        | (((value as u16) & 0x20) << 6);
395                }
396                self.chr_base = value;
397                self.cnrom_chr_reg = 0;
398            }
399            _ => {
400                self.extended_mmc3 = value & 0x02 != 0;
401                self.cnrom_chr_mode = value & 0x44 != 0;
402            }
403        }
404    }
405
406    fn write_mmc3(&mut self, addr: u16, value: u8) {
407        if self.cnrom_chr_mode && (addr <= 0x9FFF || addr >= 0xC000) {
408            self.cnrom_chr_reg = value & 0x03;
409        }
410        // The 8025 decodes its MMC3-compatible registers with mask $E003, not the
411        // stock MMC3 $E001 -- verified on real hardware per the NESdev wiki, and
412        // load-bearing: some multicart games depend on a write to $9FFF doing
413        // nothing, which only holds if bit 1 participates in the decode.
414        match addr & 0xE003 {
415            0x8000 => {
416                // Submapper 2 swaps bank-select values $46 and $47 -- that is,
417                // register 6 and register 7 exchange places, but ONLY while the
418                // PRG-invert bit ($8000.6) is set. Plain $06/$07 are unswapped.
419                let mut reg = value & 0x0F;
420                if self.is_fs005() && (value & 0x40) != 0 && matches!(reg, 6 | 7) {
421                    reg ^= 1;
422                }
423                self.bank_select = reg;
424                self.prg_mode = value & 0x40 != 0;
425                self.chr_mode = value & 0x80 != 0;
426            }
427            0x8001 => {
428                let idx = (self.bank_select & 0x07) as usize;
429                self.regs[idx] = value;
430            }
431            0xA000 => {
432                // Two mirroring bits; single-screen is only wired once the RAM
433                // Configuration Register is enabled, so a stray $A000 write on a
434                // submapper-0/1 multicart cannot reach a single-screen page.
435                self.mirroring = match (value & 0x03, self.ram_cfg_enabled()) {
436                    (2, true) => Mirroring::SingleScreenA,
437                    (3, true) => Mirroring::SingleScreenB,
438                    // Everything else keeps the MMC3-compatible decode, which
439                    // looks at bit 0 ONLY. Folding bit 1 into this arm would
440                    // turn $02/$06 from Vertical into Horizontal on submapper
441                    // 0/1 -- a silent regression for the FK23C multicarts, which
442                    // are oracle-gated Curated.
443                    (v, _) if v & 0x01 == 0 => Mirroring::Vertical,
444                    _ => Mirroring::Horizontal,
445                };
446            }
447            0xA001 if self.is_fs005() => {
448                self.ram_cfg = value;
449                // Enabling or disabling single-screen support can invalidate the
450                // mirroring latched from a previous $A000 write; re-derive it
451                // rather than leaving a single-screen page selected on a board
452                // that no longer offers one.
453                // Fall back to what the MMC3-compatible decode would have said
454                // for the SAME $A000 write, which is recoverable without keeping
455                // the raw byte: single-screen A can only have come from selector
456                // 2 (bit 0 clear -> Vertical) and B from selector 3 (bit 0 set ->
457                // Horizontal). Forcing Horizontal for both was wrong for A, and
458                // wrong the same way the $A000 arm above used to be.
459                self.mirroring = match self.mirroring {
460                    Mirroring::SingleScreenA if !self.ram_cfg_enabled() => Mirroring::Vertical,
461                    Mirroring::SingleScreenB if !self.ram_cfg_enabled() => Mirroring::Horizontal,
462                    other => other,
463                };
464            }
465            0xC000 => self.irq_latch = value,
466            0xC001 => {
467                self.irq_counter = 0;
468                self.irq_reload = true;
469            }
470            0xE000 => {
471                self.irq_enabled = false;
472                self.irq_pending = false;
473            }
474            0xE001 => self.irq_enabled = true,
475            _ => {}
476        }
477    }
478}
479
480impl Mapper for Fk23c {
481    // Battery save: the whole 32 KiB WRAM (four 8 KiB `$6000` banks, the FS005
482    // register window included -- it lives in this array on the board). Until
483    // v2.7.1 the trait default returned an empty slice here, so FK23C and
484    // FS005 carts, including every mapper-30 header that declares CHR-ROM
485    // (routed to this board as 176/2), wrote an empty `.sav`. Found by
486    // `tests/battery_sram_exposed.rs`; not in the core audit.
487    fn sram(&self) -> &[u8] {
488        &self.wram
489    }
490    fn sram_mut(&mut self) -> &mut [u8] {
491        &mut self.wram
492    }
493
494    fn caps(&self) -> MapperCaps {
495        MapperCaps {
496            // v2.9.7: the A12 filter's clock (`notify_cpu_cycle`); the bus
497            // calls it only on boards that declare this.
498            cpu_cycle_hook: true,
499            audio: false,
500            frame_event_hook: false,
501            irq_source: true,
502        }
503    }
504
505    fn cpu_read(&mut self, addr: u16) -> u8 {
506        match addr {
507            // With the outer bank registers switched off, $5000-$5FFF is not a
508            // register window at all: it reads the second 4 KiB of WRAM bank 2.
509            0x5000..=0x5FFF if !self.outer_regs_enabled() => {
510                self.wram[Self::FS005_REG_WINDOW_OFF + (addr as usize & 0x0FFF)]
511            }
512            0x6000..=0x7FFF => self.wram[self.wram_bank() * 0x2000 + (addr as usize & 0x1FFF)],
513            0x8000..=0x9FFF => {
514                let b = self.resolve_prg(0);
515                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
516            }
517            0xA000..=0xBFFF => {
518                let b = self.resolve_prg(1);
519                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
520            }
521            0xC000..=0xDFFF => {
522                let b = self.resolve_prg(2);
523                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
524            }
525            0xE000..=0xFFFF => {
526                let b = self.resolve_prg(3);
527                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
528            }
529            _ => 0,
530        }
531    }
532
533    fn cpu_write(&mut self, addr: u16, value: u8) {
534        match addr {
535            0x5000..=0x5FFF => {
536                if self.outer_regs_enabled() {
537                    self.write_5000(addr, value);
538                } else {
539                    self.wram[Self::FS005_REG_WINDOW_OFF + (addr as usize & 0x0FFF)] = value;
540                }
541            }
542            0x6000..=0x7FFF => {
543                let off = self.wram_bank() * 0x2000 + (addr as usize & 0x1FFF);
544                self.wram[off] = value;
545            }
546            0x8000..=0xFFFF => self.write_mmc3(addr, value),
547            _ => {}
548        }
549    }
550
551    fn ppu_read(&mut self, addr: u16) -> u8 {
552        let addr = addr & 0x3FFF;
553        match addr {
554            0x0000..=0x1FFF => {
555                // The FS005 mixed-memory overlay outranks the flat CHR window.
556                // `select_chr_ram` is `$5xx0.5`, which on submapper 2 selects
557                // NROM CHR mode rather than the SFC-12B CHR-RAM window, so a
558                // game can have both set -- and if the flat path won, `ppu_write`
559                // would keep writing the overlay while `ppu_read` could never
560                // see it. Resolve the bank first and let the overlay answer.
561                let slot = (addr as usize) / CHR_BANK_1K;
562                let b = self.resolve_chr(slot);
563                if self.chr_bank_is_ram(b) {
564                    return self.chr_ram[b * CHR_BANK_1K + (addr as usize & 0x3FF)];
565                }
566                if self.chr_is_ram || self.select_chr_ram {
567                    return self.chr[addr as usize & (self.chr.len() - 1)];
568                }
569                self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)]
570            }
571            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
572            _ => 0,
573        }
574    }
575
576    fn ppu_write(&mut self, addr: u16, value: u8) {
577        let addr = addr & 0x3FFF;
578        match addr {
579            // Only the CHR-RAM variant accepts CHR writes. When the cart
580            // provided CHR-ROM (`chr_is_ram == false`), the `select_chr_ram`
581            // banking bit selects a flat-CHR read window but must NOT make
582            // the ROM mutable: writing it here would corrupt CHR-ROM and
583            // (since `save_state` only serializes `self.chr` when
584            // `chr_is_ram`) would not round-trip across a save-state. Gate
585            // the write on `chr_is_ram` so behaviour + serialization agree.
586            0x0000..=0x1FFF if self.chr_is_ram => {
587                self.chr[addr as usize & (self.chr.len() - 1)] = value;
588            }
589            // FS005 mixed CHR-ROM/CHR-RAM: a bank the RAM Configuration Register
590            // has redirected into the overlay IS writable even on a CHR-ROM cart,
591            // and `chr_ram` is serialized, so behaviour and save-state agree.
592            0x0000..=0x1FFF => {
593                let slot = (addr as usize) / CHR_BANK_1K;
594                let b = self.resolve_chr(slot);
595                if self.chr_bank_is_ram(b) {
596                    self.chr_ram[b * CHR_BANK_1K + (addr as usize & 0x3FF)] = value;
597                }
598            }
599            0x2000..=0x3EFF => {
600                let off = nametable_offset(addr, self.mirroring);
601                self.vram[off] = value;
602            }
603            _ => {}
604        }
605    }
606
607    fn notify_cpu_cycle(&mut self) {
608        self.a12.tick();
609    }
610
611    fn notify_a12(&mut self, level: bool) {
612        // v2.9.7 — MMC3's filter; see `a12_filter` for why a bare
613        // rising-edge test is no longer enough.
614        if !self.a12.edge(level) {
615            return;
616        }
617        if self.irq_counter == 0 || self.irq_reload {
618            self.irq_counter = self.irq_latch;
619            self.irq_reload = false;
620        } else {
621            self.irq_counter = self.irq_counter.wrapping_sub(1);
622        }
623        if self.irq_counter == 0 && self.irq_enabled {
624            self.irq_pending = true;
625        }
626    }
627
628    fn irq_pending(&self) -> bool {
629        self.irq_pending
630    }
631
632    fn irq_acknowledge(&mut self) {
633        self.irq_pending = false;
634    }
635
636    fn current_mirroring(&self) -> Mirroring {
637        self.mirroring
638    }
639
640    fn save_state(&self) -> Vec<u8> {
641        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
642        let mut out =
643            Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram);
644        out.push(SAVE_STATE_VERSION);
645        out.extend_from_slice(&self.regs);
646        out.push(self.bank_select);
647        out.push(u8::from(self.prg_mode));
648        out.push(u8::from(self.chr_mode));
649        out.push(self.irq_counter);
650        out.push(self.irq_latch);
651        out.push(u8::from(self.irq_reload));
652        out.push(u8::from(self.irq_enabled));
653        out.push(u8::from(self.irq_pending));
654        out.push(self.a12.to_byte());
655        out.push(self.prg_banking_mode);
656        out.push(u8::from(self.outer_chr_64k));
657        out.push(u8::from(self.select_chr_ram));
658        out.push(u8::from(self.mmc3_chr_mode));
659        out.push(u8::from(self.cnrom_chr_mode));
660        out.push(u8::from(self.extended_mmc3));
661        out.extend_from_slice(&self.prg_base.to_le_bytes());
662        out.push(self.chr_base);
663        out.push(self.cnrom_chr_reg);
664        out.push(mirroring_to_byte(self.mirroring));
665        out.push(self.ram_cfg);
666        out.extend_from_slice(&self.vram);
667        out.extend_from_slice(&self.wram);
668        if self.chr_is_ram {
669            out.extend_from_slice(&self.chr);
670        }
671        // Zero-length on every submapper but 2, so this is a no-op there.
672        out.extend_from_slice(&self.chr_ram);
673        out
674    }
675
676    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
677        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
678        // Only the current version is read (v2.9.8, ADR 0042). v1 (no
679        // `ram_cfg` byte, no CHR-RAM overlay) used to load with both zeroed,
680        // and v2 with its bare A12 level in the filter byte. The version is
681        // checked before the length, so an old blob reports the version it is.
682        match data.first() {
683            None => {
684                return Err(MapperError::WrongLength {
685                    expected: 1,
686                    got: 0,
687                });
688            }
689            Some(&v) if v != SAVE_STATE_VERSION => {
690                return Err(MapperError::UnsupportedVersion(v));
691            }
692            Some(_) => {}
693        }
694        let expected =
695            1 + Self::SAVE_LEN + self.vram.len() + self.wram.len() + chr_ram + self.chr_ram.len();
696        if data.len() != expected {
697            return Err(MapperError::WrongLength {
698                expected,
699                got: data.len(),
700            });
701        }
702        let mut c = 1;
703        self.regs.copy_from_slice(&data[c..c + 8]);
704        c += 8;
705        self.bank_select = data[c];
706        self.prg_mode = data[c + 1] != 0;
707        self.chr_mode = data[c + 2] != 0;
708        self.irq_counter = data[c + 3];
709        self.irq_latch = data[c + 4];
710        self.irq_reload = data[c + 5] != 0;
711        self.irq_enabled = data[c + 6] != 0;
712        self.irq_pending = data[c + 7] != 0;
713        self.a12 = A12RiseFilter::from_byte(data[c + 8]);
714        c += 9;
715        self.prg_banking_mode = data[c];
716        self.outer_chr_64k = data[c + 1] != 0;
717        self.select_chr_ram = data[c + 2] != 0;
718        self.mmc3_chr_mode = data[c + 3] != 0;
719        self.cnrom_chr_mode = data[c + 4] != 0;
720        self.extended_mmc3 = data[c + 5] != 0;
721        c += 6;
722        self.prg_base = u16::from_le_bytes([data[c], data[c + 1]]);
723        c += 2;
724        self.chr_base = data[c];
725        self.cnrom_chr_reg = data[c + 1];
726        self.mirroring = byte_to_mirroring(data[c + 2], self.mirroring);
727        c += 3;
728        self.ram_cfg = data[c];
729        c += 1;
730        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
731        c += self.vram.len();
732        self.wram.copy_from_slice(&data[c..c + self.wram.len()]);
733        c += self.wram.len();
734        if self.chr_is_ram {
735            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
736            c += self.chr.len();
737        }
738        let n = self.chr_ram.len();
739        self.chr_ram.copy_from_slice(&data[c..c + n]);
740        Ok(())
741    }
742}
743
744/// Mapper 176 (8025 enhanced-MMC3 ASIC: Waixing FK23C, and FS005 on submapper 2).
745///
746/// `submapper` is the NES 2.0 submapper number. Only `2` (WAIXING-FS005/FS006)
747/// selects different behaviour; every other value -- including the `0` an iNES 1.0
748/// image implies -- keeps the FK23C decode.
749///
750/// # Errors
751/// [`MapperError::Invalid`] on a bad PRG/CHR size.
752pub fn new_m176(
753    prg_rom: Box<[u8]>,
754    chr_rom: Box<[u8]>,
755    mirroring: Mirroring,
756    submapper: u8,
757) -> Result<Fk23c, MapperError> {
758    Fk23c::new(prg_rom, chr_rom, mirroring, submapper)
759}
760
761// ===========================================================================
762// Coolboy (mapper 268) — COOLBOY / MINDKIDS MMC3-clone.
763//
764// An MMC3 core wrapped by four $6000-$7FFF outer-bank registers that supply
765// PRG/CHR base bits + a wider/narrower mask + an extended-bank mode. The
766// COOLBOY/MINDKIDS banking transforms are a register-decode BestEffort model;
767// the register map is per the nesdev wiki COOLBOY / mapper-268 board notes, and
768// the implementation is derived from Mesen2's `Mmc3Variants/MMC3_Coolboy.h`
769// (GPL-3.0-or-later) and the FCEUX banking transforms (GPL-2.0-or-later).
770// See NOTICE + docs/originality-and-provenance.md §1.
771// ===========================================================================
772
773#[cfg(test)]
774#[allow(clippy::cast_possible_truncation)]
775mod tests {
776
777    /// v2.9.7 — the IRQ counter clocks once per scanline on the A12 stream the
778    /// PPU now reports (eight pulses per line), through MMC3's filter. Before,
779    /// it clocked on every rise and depended on the PPU delivering only one.
780    #[test]
781    fn irq_counts_scanlines_not_raw_a12_pulses() {
782        let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
783        assert_eq!(crate::a12_filter::irqs_over_scanlines(&mut m, 16), 2);
784    }
785
786    use super::*;
787
788    fn synth_prg_8k(banks: usize) -> Box<[u8]> {
789        let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
790        for b in 0..banks {
791            v[b * PRG_BANK_8K] = b as u8;
792        }
793        v.into_boxed_slice()
794    }
795
796    fn synth_chr_1k(banks: usize) -> Box<[u8]> {
797        let mut v = vec![0u8; banks * CHR_BANK_1K];
798        for b in 0..banks {
799            v[b * CHR_BANK_1K] = b as u8;
800        }
801        v.into_boxed_slice()
802    }
803
804    #[test]
805    fn fk23c_truncated_save_state_rejected() {
806        let m = new_m176(synth_prg_8k(16), synth_chr_1k(32), Mirroring::Vertical, 0).unwrap();
807        let mut blob = m.save_state();
808        blob.pop();
809        let mut m2 = new_m176(synth_prg_8k(16), synth_chr_1k(32), Mirroring::Vertical, 0).unwrap();
810        assert!(m2.load_state(&blob).is_err());
811    }
812
813    #[test]
814    fn fk23c_mmc3_prg_and_a12_irq() {
815        let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
816        m.cpu_write(0x8000, 0x06); // select R6
817        m.cpu_write(0x8001, 5);
818        assert_eq!(m.cpu_read(0x8000), 5); // R6 @ $8000
819        assert_eq!(m.cpu_read(0xE000), 31); // last @ $E000
820
821        m.cpu_write(0xC000, 2); // latch
822        m.cpu_write(0xC001, 0); // reload
823        m.cpu_write(0xE001, 0); // enable
824        for _ in 0..3 {
825            m.notify_a12(false);
826            // v2.9.7: three CPU cycles low, as MMC3's A12 filter requires.
827            for _ in 0..3 {
828                m.notify_cpu_cycle();
829            }
830            m.notify_a12(true);
831        }
832        assert!(m.irq_pending());
833        m.cpu_write(0xE000, 0); // disable + ack
834        assert!(!m.irq_pending());
835    }
836
837    #[test]
838    fn fk23c_outer_prg_base() {
839        let mut m = new_m176(synth_prg_8k(128), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
840        // $5001 sets PRG base low bits; $5000 mode 0 = MMC3 with outer.
841        m.cpu_write(0x5001, 0x08); // prg_base low = 8 -> outer = 16 (8<<1)
842        m.cpu_write(0x8000, 0x06); // R6
843        m.cpu_write(0x8001, 0); // R6 = 0
844        // mode 0 inner_mask = 0x3F, outer = 16 & ~0x3F = 0 -> bank 0. base only
845        // affects above the inner window; just confirm read is in range + no panic.
846        let _ = m.cpu_read(0x8000);
847        assert!(m.cpu_read(0x8000) < 128);
848    }
849
850    #[test]
851    fn fk23c_save_state_round_trip() {
852        let mut m = new_m176(synth_prg_8k(32), Box::new([]), Mirroring::Vertical, 0).unwrap();
853        m.cpu_write(0x5000, 0x20); // select CHR-RAM
854        m.cpu_write(0x8000, 0x06);
855        m.cpu_write(0x8001, 7);
856        m.ppu_write(0x0040, 0x99);
857        m.cpu_write(0x6000, 0x5A); // WRAM
858        let blob = m.save_state();
859        let mut m2 = new_m176(synth_prg_8k(32), Box::new([]), Mirroring::Vertical, 0).unwrap();
860        m2.load_state(&blob).unwrap();
861        assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
862        assert_eq!(m2.ppu_read(0x0040), 0x99);
863        assert_eq!(m2.cpu_read(0x6000), 0x5A);
864    }
865
866    #[test]
867    fn fk23c_chr_rom_not_writable_via_select_chr_ram() {
868        // FK23C: even with `select_chr_ram` set, a CHR-ROM cart must not be
869        // mutated (regression: `ppu_write` wrote through `self.chr`, which
870        // corrupted CHR-ROM and was never serialized).
871        let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
872        m.cpu_write(0x5000, 0x20); // select_chr_ram = true
873        let before = m.ppu_read(0x0010);
874        m.ppu_write(0x0010, before.wrapping_add(1));
875        assert_eq!(m.ppu_read(0x0010), before, "CHR-ROM must not be mutable");
876    }
877
878    // ---- FS005 (submapper 2) ------------------------------------------------
879
880    fn fs005() -> Fk23c {
881        new_m176(synth_prg_8k(32), synth_chr_1k(256), Mirroring::Vertical, 2).unwrap()
882    }
883
884    #[test]
885    fn fs005_a001_bit5_switches_a001_from_mmc3_to_ram_config() {
886        let mut m = fs005();
887        // Bit 5 clear: $A001 behaves as on the MMC3, so the WRAM bank select in
888        // bits 0-1 is inert and $6000 still shows bank 0.
889        m.cpu_write(0xA001, 0x02);
890        m.cpu_write(0x6000, 0xAA);
891        m.cpu_write(0xA001, 0x00);
892        assert_eq!(
893            m.cpu_read(0x6000),
894            0xAA,
895            "bank select must be ignored while $A001.5 is clear"
896        );
897
898        // Bit 5 set: the same bits now pick one of four 8 KiB banks, so the byte
899        // written to bank 0 is no longer visible from bank 2.
900        m.cpu_write(0xA001, 0x22);
901        assert_ne!(m.cpu_read(0x6000), 0xAA, "bank 2 must not alias bank 0");
902        m.cpu_write(0x6000, 0x55);
903        m.cpu_write(0xA001, 0x20); // back to bank 0
904        assert_eq!(m.cpu_read(0x6000), 0xAA);
905        m.cpu_write(0xA001, 0x22); // bank 2 again
906        assert_eq!(m.cpu_read(0x6000), 0x55);
907    }
908
909    #[test]
910    fn fs005_protection_sequence_round_trips_through_the_register_window() {
911        // The documented Waixing protection dance: park $5000-$5FFF over WRAM,
912        // stash bytes there, re-enable the registers, and read the same bytes
913        // back through $6000-$7FFF with WRAM bank 2 selected.
914        let mut m = fs005();
915        m.cpu_write(0xA001, 0xA1); // enable cfg, outer regs OFF
916        m.cpu_write(0x5000, 0x11);
917        m.cpu_write(0x5010, 0x22);
918        m.cpu_write(0x5013, 0x33);
919        m.cpu_write(0xA001, 0xE2); // outer regs ON, WRAM bank 2 at $6000
920
921        assert_eq!(m.cpu_read(0x7000), 0x11);
922        assert_eq!(m.cpu_read(0x7010), 0x22);
923        assert_eq!(m.cpu_read(0x7013), 0x33);
924    }
925
926    #[test]
927    fn fs005_disabled_register_window_does_not_reach_the_mapper() {
928        // The point of the window swap is that those writes must NOT be decoded
929        // as mode-register writes. $5000 = $07 would otherwise select an unused
930        // PRG banking mode and change what $8000 reads.
931        let mut m = fs005();
932        m.cpu_write(0x8000, 0x06);
933        m.cpu_write(0x8001, 5);
934        let before = m.cpu_read(0x8000);
935
936        m.cpu_write(0xA001, 0xA1); // registers off
937        m.cpu_write(0x5000, 0x04); // would be NROM-256 mode if it landed
938        assert_eq!(
939            m.cpu_read(0x8000),
940            before,
941            "a disabled window must not bank"
942        );
943
944        m.cpu_write(0xA001, 0xE0); // registers back on
945        m.cpu_write(0x5000, 0x04);
946        assert_ne!(m.cpu_read(0x8000), before, "an enabled window must bank");
947    }
948
949    #[test]
950    fn fs005_a000_selects_single_screen_only_while_ram_config_is_enabled() {
951        let mut m = fs005();
952        m.cpu_write(0xA000, 0x02);
953        // Vertical, NOT Horizontal: with single-screen unarmed this falls to the
954        // MMC3-compatible decode, which reads bit 0 only, and bit 0 of $02 is
955        // clear. An earlier version of this test asserted Horizontal here and so
956        // encoded the very bug it was meant to guard -- it would have defended
957        // the regression rather than caught it.
958        assert_eq!(
959            m.current_mirroring(),
960            Mirroring::Vertical,
961            "single-screen is unwired until $A001.5 is set, so bit 0 decides"
962        );
963
964        m.cpu_write(0xA001, 0x20);
965        m.cpu_write(0xA000, 0x02);
966        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
967        m.cpu_write(0xA000, 0x03);
968        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
969        m.cpu_write(0xA000, 0x00);
970        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
971
972        // Turning the RAM Configuration Register back off must not strand a
973        // single-screen page on a board that no longer offers one.
974        m.cpu_write(0xA000, 0x03);
975        m.cpu_write(0xA001, 0x00);
976        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
977    }
978
979    #[test]
980    fn fs005_swaps_bank_select_46_and_47_but_not_06_and_07() {
981        let mut m = fs005();
982        // $46/$47: PRG-invert set, so registers 6 and 7 exchange places.
983        m.cpu_write(0x8000, 0x46);
984        m.cpu_write(0x8001, 9);
985        m.cpu_write(0x8000, 0x47);
986        m.cpu_write(0x8001, 4);
987        // Under the swap, $46 wrote R7 (-> $A000) and $47 wrote R6 (-> $C000,
988        // because the PRG-invert bit is set).
989        assert_eq!(m.cpu_read(0xA000), 9);
990        assert_eq!(m.cpu_read(0xC000), 4);
991
992        // $06/$07 are explicitly NOT swapped.
993        let mut m = fs005();
994        m.cpu_write(0x8000, 0x06);
995        m.cpu_write(0x8001, 9);
996        m.cpu_write(0x8000, 0x07);
997        m.cpu_write(0x8001, 4);
998        assert_eq!(m.cpu_read(0x8000), 9, "R6 at $8000 with PRG-invert clear");
999        assert_eq!(m.cpu_read(0xA000), 4, "R7 at $A000");
1000    }
1001
1002    #[test]
1003    fn fk23c_bank_select_46_47_is_not_swapped_on_submapper_0() {
1004        // The swap is an FS005 property; the FK23C multicarts must be unchanged.
1005        let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
1006        m.cpu_write(0x8000, 0x46);
1007        m.cpu_write(0x8001, 9);
1008        assert_eq!(m.cpu_read(0xC000), 9, "R6 at $C000 with PRG-invert set");
1009    }
1010
1011    #[test]
1012    fn mmc3_registers_decode_with_mask_e003_so_9fff_does_nothing() {
1013        let mut m = fs005();
1014        m.cpu_write(0x8000, 0x06);
1015        m.cpu_write(0x8001, 5);
1016        let before = m.cpu_read(0x8000);
1017        // $9FFF & $E003 == $8003, which is not a register. Under the stock MMC3
1018        // $E001 mask it would alias $8001 and rewrite R6.
1019        m.cpu_write(0x9FFF, 0x1F);
1020        assert_eq!(m.cpu_read(0x8000), before);
1021    }
1022
1023    #[test]
1024    fn fs005_mixed_chr_makes_the_first_8k_writable_on_a_chr_rom_cart() {
1025        let mut m = fs005();
1026        m.cpu_write(0xA001, 0x24); // cfg enabled + first 8 KiB is CHR-RAM
1027
1028        let before = m.ppu_read(0x0010);
1029        m.ppu_write(0x0010, before.wrapping_add(1));
1030        assert_eq!(
1031            m.ppu_read(0x0010),
1032            before.wrapping_add(1),
1033            "the redirected banks are RAM and must accept writes"
1034        );
1035
1036        // With the mode off, the same address is CHR-ROM again and unwritable --
1037        // and reads the ROM byte, not the overlay.
1038        m.cpu_write(0xA001, 0x20);
1039        assert_eq!(m.ppu_read(0x0010), before);
1040        m.ppu_write(0x0010, before.wrapping_add(2));
1041        assert_eq!(m.ppu_read(0x0010), before, "CHR-ROM must stay immutable");
1042    }
1043
1044    #[test]
1045    fn fs005_save_state_round_trips_ram_config_and_chr_overlay() {
1046        let mut m = fs005();
1047        m.cpu_write(0xA001, 0x26); // cfg on, mixed CHR on, WRAM bank 2
1048        m.ppu_write(0x0010, 0x5A);
1049        m.cpu_write(0x6000, 0xC3);
1050        m.cpu_write(0x8000, 0x46);
1051        m.cpu_write(0x8001, 9);
1052        let blob = m.save_state();
1053
1054        let mut m2 = fs005();
1055        m2.load_state(&blob).unwrap();
1056        assert_eq!(m2.ppu_read(0x0010), 0x5A, "CHR-RAM overlay must round-trip");
1057        assert_eq!(m2.cpu_read(0x6000), 0xC3, "banked WRAM must round-trip");
1058        assert_eq!(m2.cpu_read(0xA000), m.cpu_read(0xA000));
1059        assert_eq!(m2.current_mirroring(), m.current_mirroring());
1060    }
1061
1062    #[test]
1063    fn fs005_state_blob_is_longer_than_the_fk23c_one() {
1064        // Guards the SAVE_LEN / overlay arithmetic: submapper 2 carries an extra
1065        // 8 KiB of CHR-RAM, so the two lengths must not be equal by accident.
1066        let a = new_m176(synth_prg_8k(32), synth_chr_1k(256), Mirroring::Vertical, 0)
1067            .unwrap()
1068            .save_state()
1069            .len();
1070        let b = fs005().save_state().len();
1071        assert_eq!(b - a, Fk23c::FS005_CHR_RAM_BANKS * CHR_BANK_1K);
1072    }
1073
1074    #[test]
1075    fn a000_keeps_the_mmc3_bit0_decode_when_single_screen_is_not_armed() {
1076        // Regression: folding bit 1 into the fallback turned $02/$06 from
1077        // Vertical into Horizontal on the FK23C multicarts, which are
1078        // oracle-gated Curated. The MMC3-compatible decode is bit 0 ONLY.
1079        for sub in [0u8, 2] {
1080            let mut m = new_m176(
1081                synth_prg_8k(32),
1082                synth_chr_1k(64),
1083                Mirroring::Horizontal,
1084                sub,
1085            )
1086            .unwrap();
1087            for v in [0x00u8, 0x02, 0x04, 0x06] {
1088                m.cpu_write(0xA000, v);
1089                assert_eq!(
1090                    m.current_mirroring(),
1091                    Mirroring::Vertical,
1092                    "submapper {sub}: ${v:02X} is Vertical (bit 0 clear)"
1093                );
1094            }
1095            for v in [0x01u8, 0x03, 0x05, 0x07] {
1096                m.cpu_write(0xA000, v);
1097                assert_eq!(
1098                    m.current_mirroring(),
1099                    Mirroring::Horizontal,
1100                    "submapper {sub}: ${v:02X} is Horizontal (bit 0 set)"
1101                );
1102            }
1103        }
1104    }
1105
1106    #[test]
1107    fn fs005_mixed_chr_overlay_outranks_the_flat_chr_window() {
1108        // $5xx0.5 (`select_chr_ram`) and the $A001.2 overlay can both be set.
1109        // If the flat window won the read, `ppu_write` would keep writing the
1110        // overlay while `ppu_read` could never observe it -- writes into a void.
1111        let mut m = fs005();
1112        m.cpu_write(0xA001, 0x24); // cfg enabled + first 8 KiB is CHR-RAM
1113        m.cpu_write(0x5000, 0x20); // and select_chr_ram set as well
1114        m.ppu_write(0x0010, 0x77);
1115        assert_eq!(
1116            m.ppu_read(0x0010),
1117            0x77,
1118            "the overlay must answer the read, not the flat CHR window"
1119        );
1120    }
1121
1122    #[test]
1123    fn fk23c_refuses_v1_and_v2_states() {
1124        // v1 had neither the RAM Configuration byte nor the CHR-RAM overlay,
1125        // and v2 stored a bare A12 level where v3 stores the filter. Both
1126        // loaded until v2.9.8 (ADR 0042), which reads the current layout only.
1127        let mut m = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
1128        m.cpu_write(0x8000, 0x06);
1129        m.cpu_write(0x8001, 5);
1130        let v2 = m.save_state();
1131
1132        // Legacy form: drop the ram_cfg byte. Its offset is right after the
1133        // mirroring byte that ends the scalar block.
1134        let off = 1 + Fk23c::SAVE_LEN - 1;
1135        let mut v1 = Vec::with_capacity(v2.len() - 1);
1136        v1.extend_from_slice(&v2[..off]);
1137        v1.extend_from_slice(&v2[off + 1..]);
1138        v1[0] = 1;
1139
1140        let mut m2 = new_m176(synth_prg_8k(32), synth_chr_1k(64), Mirroring::Vertical, 0).unwrap();
1141        assert!(matches!(
1142            m2.load_state(&v1),
1143            Err(MapperError::UnsupportedVersion(1))
1144        ));
1145        let mut old_v2 = v2.clone();
1146        old_v2[0] = 2;
1147        assert!(matches!(
1148            m2.load_state(&old_v2),
1149            Err(MapperError::UnsupportedVersion(2))
1150        ));
1151        m2.load_state(&v2).expect("the current state loads");
1152        assert_eq!(m2.cpu_read(0x8000), 5, "bank state round-trips");
1153    }
1154
1155    #[test]
1156    fn fs005_disabling_ram_config_falls_back_per_the_mmc3_bit0_decode() {
1157        // Single-screen A can only have come from selector 2 (bit 0 clear ->
1158        // Vertical) and B from selector 3 (bit 0 set -> Horizontal). Forcing
1159        // Horizontal for both was wrong for A.
1160        let mut m = fs005();
1161        m.cpu_write(0xA001, 0x20); // arm the RAM Configuration Register
1162        m.cpu_write(0xA000, 0x02); // selector 2 -> single-screen A
1163        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
1164        m.cpu_write(0xA001, 0x00); // disarm
1165        assert_eq!(
1166            m.current_mirroring(),
1167            Mirroring::Vertical,
1168            "A came from a bit-0-clear write, so it falls back to Vertical"
1169        );
1170
1171        let mut m = fs005();
1172        m.cpu_write(0xA001, 0x20);
1173        m.cpu_write(0xA000, 0x03); // selector 3 -> single-screen B
1174        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
1175        m.cpu_write(0xA001, 0x00);
1176        assert_eq!(
1177            m.current_mirroring(),
1178            Mirroring::Horizontal,
1179            "B came from a bit-0-set write, so it falls back to Horizontal"
1180        );
1181    }
1182}