Skip to main content

rustynes_mappers/
bmc_simple.rs

1//! Simple `BMC` multicart boards sharing one parameterised core: Waixing 164,
2//! `BMC-810544-C-A1`, `BMC-60311C`, `BMC-830425C`, `K-3046`, `G-146`, and BS-5.
3//!
4//! Each is a handful of latch registers with no IRQ and no audio, differing
5//! only in which address bits select which bank and in the NROM/UNROM-style
6//! mode each supports. Rather than seven near-identical implementations,
7//! [`SimpleBmc`] carries the common banking machinery and [`SimpleBoard`]
8//! selects the per-board decode -- so a fix to the shared wrap-and-index math
9//! lands on all of them at once.
10//!
11//! A best-effort (Tier-2) board: register-decode correctness verified against
12//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
13//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
14//! every bank select wraps with `% count`, so a register write can never index
15//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
16//! afford a panic on a register access.
17//!
18//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
19//! and `docs/mappers.md` §Mapper coverage matrix.
20
21#![allow(
22    clippy::cast_possible_truncation,
23    clippy::cast_lossless,
24    clippy::match_same_arms,
25    clippy::doc_markdown,
26    clippy::similar_names,
27    clippy::too_many_lines,
28    clippy::missing_const_for_fn,
29    clippy::struct_excessive_bools,
30    clippy::bool_to_int_with_if,
31    clippy::unreadable_literal
32)]
33
34use crate::cartridge::Mirroring;
35use crate::mapper::{Mapper, MapperCaps, MapperError};
36use alloc::{boxed::Box, format, vec, vec::Vec};
37
38const PRG_BANK_8K: usize = 0x2000;
39const PRG_BANK_16K: usize = 0x4000;
40const PRG_BANK_32K: usize = 0x8000;
41const CHR_BANK_2K: usize = 0x0800;
42const CHR_BANK_8K: usize = 0x2000;
43const NAMETABLE_SIZE: usize = 0x0400;
44const NAMETABLE_SIZE_U16: u16 = 0x0400;
45
46const SAVE_STATE_VERSION: u8 = 1;
47
48// ---------------------------------------------------------------------------
49// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
50// ---------------------------------------------------------------------------
51
52const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
53    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
54    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
55    let physical = mirroring.physical_bank(table);
56    physical * NAMETABLE_SIZE + local
57}
58
59const fn mirroring_to_byte(m: Mirroring) -> u8 {
60    match m {
61        Mirroring::Horizontal => 0,
62        Mirroring::Vertical => 1,
63        Mirroring::SingleScreenA => 2,
64        Mirroring::SingleScreenB => 3,
65        Mirroring::FourScreen => 4,
66        Mirroring::MapperControlled => 5,
67    }
68}
69
70const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
71    match b {
72        0 => Mirroring::Horizontal,
73        1 => Mirroring::Vertical,
74        2 => Mirroring::SingleScreenA,
75        3 => Mirroring::SingleScreenB,
76        4 => Mirroring::FourScreen,
77        5 => Mirroring::MapperControlled,
78        _ => fallback,
79    }
80}
81
82/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
83fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
84    if prg.is_empty() || !prg.len().is_multiple_of(PRG_BANK_8K) {
85        return Err(MapperError::Invalid(format!(
86            "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
87            prg.len()
88        )));
89    }
90    Ok(())
91}
92
93/// Which discrete BMC board the [`SimpleBmc`] body models.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SimpleBoard {
96    /// Mapper 164 (Waixing "Final Fantasy V", 32 KiB PRG).
97    M164,
98    /// Mapper 261 (BMC-810544-C-A1).
99    M261,
100    /// Mapper 289 (BMC-60311C).
101    M289,
102    /// Mapper 320 (BMC-830425C-4391T).
103    M320,
104    /// Mapper 336 (BMC-K-3046).
105    M336,
106    /// Mapper 349 (BMC-G-146).
107    M349,
108    /// Mapper 286 (Waixing BS-5).
109    M286,
110}
111
112/// A discrete BMC multicart with a simple (IRQ-free) register surface.
113pub struct SimpleBmc {
114    board: SimpleBoard,
115    prg_rom: Box<[u8]>,
116    chr: Box<[u8]>,
117    chr_is_ram: bool,
118    vram: Box<[u8]>,
119    mirroring: Mirroring,
120    /// 16 KiB PRG window for $8000 and $C000.
121    prg0: usize,
122    prg1: usize,
123    /// 8 KiB CHR window (164/261/289/320/336/349).
124    chr8: usize,
125    /// Per-2 KiB CHR windows (286).
126    chr2: [usize; 4],
127    /// Per-8 KiB PRG window for 286 (four 8 KiB windows).
128    prg8: [usize; 4],
129    // Board scratch registers.
130    reg_inner: u8,
131    reg_outer: u8,
132    reg_mode: u8,
133    dip: u8,
134}
135
136impl SimpleBmc {
137    const SAVE_LEN: usize = 4 + 8 + 8 + 4 + 1;
138
139    fn new(
140        board: SimpleBoard,
141        prg_rom: Box<[u8]>,
142        chr_rom: Box<[u8]>,
143        mirroring: Mirroring,
144        id: u16,
145    ) -> Result<Self, MapperError> {
146        check_prg(&prg_rom, id)?;
147        let chr_is_ram = chr_rom.is_empty();
148        let chr: Box<[u8]> = if chr_is_ram {
149            vec![0u8; CHR_BANK_8K].into_boxed_slice()
150        } else {
151            chr_rom
152        };
153        let mut m = Self {
154            board,
155            prg_rom,
156            chr,
157            chr_is_ram,
158            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
159            mirroring,
160            prg0: 0,
161            prg1: 0,
162            chr8: 0,
163            chr2: [0; 4],
164            prg8: [0; 4],
165            reg_inner: 0,
166            reg_outer: 0,
167            reg_mode: 0,
168            dip: 0,
169        };
170        m.reset_banks();
171        Ok(m)
172    }
173
174    fn prg_count_16k(&self) -> usize {
175        (self.prg_rom.len() / PRG_BANK_16K).max(1)
176    }
177
178    fn prg_count_8k(&self) -> usize {
179        (self.prg_rom.len() / PRG_BANK_8K).max(1)
180    }
181
182    fn chr_count_8k(&self) -> usize {
183        (self.chr.len() / CHR_BANK_8K).max(1)
184    }
185
186    /// Initial / power-on bank layout per board.
187    fn reset_banks(&mut self) {
188        let last16 = self.prg_count_16k() - 1;
189        match self.board {
190            SimpleBoard::M164 => {
191                // $5000/$5100 split 32 KiB PRG select; power-on 0x0F.
192                self.reg_inner = 0x0F;
193                self.update_m164();
194            }
195            SimpleBoard::M286 => {
196                let last8 = self.prg_count_8k() - 1;
197                let last_chr2 = (self.chr.len() / CHR_BANK_2K).max(1) - 1;
198                self.prg8.fill(last8);
199                self.chr2.fill(last_chr2);
200            }
201            SimpleBoard::M320 => self.update_m320(),
202            SimpleBoard::M289 => self.update_m289(),
203            _ => {
204                self.prg0 = 0;
205                self.prg1 = last16;
206            }
207        }
208    }
209
210    fn update_m164(&mut self) {
211        // 32 KiB PRG window selected by the 8-bit split register.
212        let count32 = (self.prg_rom.len() / PRG_BANK_32K).max(1);
213        let bank32 = (self.reg_inner as usize) % count32;
214        self.prg0 = bank32 * 2;
215        self.prg1 = bank32 * 2 + 1;
216    }
217
218    fn update_m289(&mut self) {
219        let page = self.reg_outer as usize
220            | (if self.reg_mode & 0x04 != 0 {
221                0
222            } else {
223                self.reg_inner as usize
224            });
225        match self.reg_mode & 0x03 {
226            0 => {
227                self.prg0 = page;
228                self.prg1 = page;
229            }
230            1 => {
231                let b = page & 0xFE;
232                self.prg0 = b;
233                self.prg1 = b | 1;
234            }
235            2 => {
236                self.prg0 = page;
237                self.prg1 = self.reg_outer as usize | 7;
238            }
239            _ => {}
240        }
241        self.mirroring = if self.reg_mode & 0x08 != 0 {
242            Mirroring::Horizontal
243        } else {
244            Mirroring::Vertical
245        };
246    }
247
248    fn update_m320(&mut self) {
249        let outer = (self.reg_outer as usize) << 3;
250        if self.reg_mode != 0 {
251            // UNROM mode.
252            self.prg0 = (self.reg_inner as usize & 0x07) | outer;
253            self.prg1 = 0x07 | outer;
254        } else {
255            // UOROM mode.
256            self.prg0 = (self.reg_inner as usize) | outer;
257            self.prg1 = 0x0F | outer;
258        }
259    }
260
261    fn prg_byte(&self, slot16: usize, addr: u16) -> u8 {
262        let count = self.prg_count_16k();
263        let bank = slot16 % count;
264        self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
265    }
266}
267
268impl Mapper for SimpleBmc {
269    fn caps(&self) -> MapperCaps {
270        MapperCaps::NONE
271    }
272
273    fn cpu_read(&mut self, addr: u16) -> u8 {
274        if self.board == SimpleBoard::M286 {
275            if let 0x8000..=0xFFFF = addr {
276                let slot = (addr as usize - 0x8000) / PRG_BANK_8K;
277                let count = self.prg_count_8k();
278                let bank = self.prg8[slot] % count;
279                return self.prg_rom[bank * PRG_BANK_8K + (addr as usize & 0x1FFF)];
280            }
281            return 0;
282        }
283        match addr {
284            0x8000..=0xBFFF => self.prg_byte(self.prg0, addr),
285            0xC000..=0xFFFF => self.prg_byte(self.prg1, addr),
286            _ => 0,
287        }
288    }
289
290    fn cpu_write(&mut self, addr: u16, value: u8) {
291        match self.board {
292            SimpleBoard::M164 => {
293                if (0x5000..=0x5FFF).contains(&addr) {
294                    match addr & 0x7300 {
295                        0x5000 => self.reg_inner = (self.reg_inner & 0xF0) | (value & 0x0F),
296                        0x5100 => self.reg_inner = (self.reg_inner & 0x0F) | ((value & 0x0F) << 4),
297                        _ => {}
298                    }
299                    self.update_m164();
300                }
301            }
302            SimpleBoard::M261 => {
303                if addr >= 0x8000 {
304                    let bank = ((addr >> 6) & 0xFFFE) as usize;
305                    if addr & 0x40 != 0 {
306                        self.prg0 = bank;
307                        self.prg1 = bank | 1;
308                    } else {
309                        let b = bank | ((addr >> 5) & 0x01) as usize;
310                        self.prg0 = b;
311                        self.prg1 = b;
312                    }
313                    self.chr8 = (addr & 0x0F) as usize;
314                    self.mirroring = if addr & 0x10 != 0 {
315                        Mirroring::Horizontal
316                    } else {
317                        Mirroring::Vertical
318                    };
319                }
320            }
321            SimpleBoard::M289 => {
322                if addr >= 0x8000 {
323                    self.reg_inner = value & 0x07;
324                } else {
325                    match addr & 0xE001 {
326                        0x6000 => self.reg_mode = value & 0x0F,
327                        0x6001 => self.reg_outer = value,
328                        _ => {}
329                    }
330                }
331                self.update_m289();
332            }
333            SimpleBoard::M320 => {
334                if addr >= 0x8000 {
335                    self.reg_inner = value & 0x0F;
336                    if addr & 0xFFE0 == 0xF0E0 {
337                        self.reg_outer = (addr & 0x0F) as u8;
338                        self.reg_mode = ((addr >> 4) & 0x01) as u8;
339                    }
340                    self.update_m320();
341                }
342            }
343            SimpleBoard::M336 => {
344                if addr >= 0x8000 {
345                    let inner = value as usize & 0x07;
346                    let outer = value as usize & 0x38;
347                    self.prg0 = outer | inner;
348                    self.prg1 = outer | 7;
349                }
350            }
351            SimpleBoard::M349 => {
352                if addr >= 0x8000 {
353                    let a = addr as usize;
354                    if a & 0x800 != 0 {
355                        self.prg0 = (a & 0x1F) | (a & ((a & 0x40) >> 6));
356                        self.prg1 = (a & 0x18) | 0x07;
357                    } else if a & 0x40 != 0 {
358                        self.prg0 = a & 0x1F;
359                        self.prg1 = a & 0x1F;
360                    } else {
361                        let b = a & 0x1E;
362                        self.prg0 = b;
363                        self.prg1 = b | 1;
364                    }
365                    self.mirroring = if a & 0x80 != 0 {
366                        Mirroring::Horizontal
367                    } else {
368                        Mirroring::Vertical
369                    };
370                }
371            }
372            SimpleBoard::M286 => {
373                let bank = ((addr >> 10) & 0x03) as usize;
374                match addr & 0xF000 {
375                    0x8000 => self.chr2[bank] = (addr & 0x1F) as usize,
376                    0xA000 if addr & (1u16 << (self.dip + 4)) != 0 => {
377                        self.prg8[bank] = (addr & 0x0F) as usize;
378                    }
379                    _ => {}
380                }
381            }
382        }
383    }
384
385    fn ppu_read(&mut self, addr: u16) -> u8 {
386        let addr = addr & 0x3FFF;
387        match addr {
388            0x0000..=0x1FFF => {
389                if self.chr_is_ram {
390                    return self.chr[addr as usize & (CHR_BANK_8K - 1)];
391                }
392                if self.board == SimpleBoard::M286 {
393                    let slot = (addr as usize) / CHR_BANK_2K;
394                    let count = (self.chr.len() / CHR_BANK_2K).max(1);
395                    let bank = self.chr2[slot] % count;
396                    return self.chr[bank * CHR_BANK_2K + (addr as usize & (CHR_BANK_2K - 1))];
397                }
398                let bank = self.chr8 % self.chr_count_8k();
399                self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
400            }
401            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
402            _ => 0,
403        }
404    }
405
406    fn ppu_write(&mut self, addr: u16, value: u8) {
407        let addr = addr & 0x3FFF;
408        match addr {
409            0x0000..=0x1FFF if self.chr_is_ram => {
410                self.chr[addr as usize & (CHR_BANK_8K - 1)] = value;
411            }
412            0x2000..=0x3EFF => {
413                let off = nametable_offset(addr, self.mirroring);
414                self.vram[off] = value;
415            }
416            _ => {}
417        }
418    }
419
420    fn current_mirroring(&self) -> Mirroring {
421        self.mirroring
422    }
423
424    fn save_state(&self) -> Vec<u8> {
425        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
426        let mut out = Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + chr_ram);
427        out.push(SAVE_STATE_VERSION);
428        out.extend_from_slice(&(self.prg0 as u32).to_le_bytes());
429        out.extend_from_slice(&(self.prg1 as u32).to_le_bytes());
430        out.extend_from_slice(&(self.chr8 as u32).to_le_bytes());
431        for c in &self.chr2 {
432            out.extend_from_slice(&(*c as u32).to_le_bytes());
433        }
434        for p in &self.prg8 {
435            out.extend_from_slice(&(*p as u32).to_le_bytes());
436        }
437        out.push(self.reg_inner);
438        out.push(self.reg_outer);
439        out.push(self.reg_mode);
440        out.push(self.dip);
441        out.push(mirroring_to_byte(self.mirroring));
442        out.extend_from_slice(&self.vram);
443        if self.chr_is_ram {
444            out.extend_from_slice(&self.chr);
445        }
446        out
447    }
448
449    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
450        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
451        // header(1) + prg0/prg1/chr8(12) + chr2(16) + prg8(16) + 4 regs + mirror(1)
452        let scratch = 1 + 12 + 16 + 16 + 4 + 1;
453        let expected = scratch + self.vram.len() + chr_ram;
454        if data.len() != expected {
455            return Err(MapperError::WrongLength {
456                expected,
457                got: data.len(),
458            });
459        }
460        if data[0] != SAVE_STATE_VERSION {
461            return Err(MapperError::UnsupportedVersion(data[0]));
462        }
463        // v2.9.0 (re-audit NC-06): the BS-5 DIP has four settings (bits 4-7
464        // of the `$A000` AND mask, nesdev mapper 286), decoded as
465        // `1 << (dip + 4)`. A raw restored value of 12 or more overflowed that
466        // shift on the next `$A000` write (a panic under overflow checks, a
467        // wrong decode in release). Checked before anything is assigned. No
468        // board sets it to anything but 0 today, and no other board reads it.
469        let dip = data[scratch - 2];
470        if dip > 3 {
471            return Err(MapperError::Invalid(format!(
472                "mapper state DIP setting {dip} is not one of the board's four (0-3)"
473            )));
474        }
475        let rd = |c: usize| {
476            u32::from_le_bytes([data[c], data[c + 1], data[c + 2], data[c + 3]]) as usize
477        };
478        let mut c = 1;
479        self.prg0 = rd(c);
480        self.prg1 = rd(c + 4);
481        self.chr8 = rd(c + 8);
482        c += 12;
483        for s in &mut self.chr2 {
484            *s = rd(c);
485            c += 4;
486        }
487        for s in &mut self.prg8 {
488            *s = rd(c);
489            c += 4;
490        }
491        self.reg_inner = data[c];
492        self.reg_outer = data[c + 1];
493        self.reg_mode = data[c + 2];
494        self.dip = data[c + 3];
495        self.mirroring = byte_to_mirroring(data[c + 4], self.mirroring);
496        c += 5;
497        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
498        c += self.vram.len();
499        if self.chr_is_ram {
500            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
501        }
502        Ok(())
503    }
504}
505
506macro_rules! simple_ctor {
507    ($fn_name:ident, $board:expr, $id:expr, $doc:expr) => {
508        #[doc = $doc]
509        ///
510        /// # Errors
511        /// [`MapperError::Invalid`] on a bad PRG/CHR size.
512        pub fn $fn_name(
513            prg_rom: Box<[u8]>,
514            chr_rom: Box<[u8]>,
515            mirroring: Mirroring,
516        ) -> Result<SimpleBmc, MapperError> {
517            SimpleBmc::new($board, prg_rom, chr_rom, mirroring, $id)
518        }
519    };
520}
521
522simple_ctor!(
523    new_m164,
524    SimpleBoard::M164,
525    164,
526    "Mapper 164 (Waixing Final Fantasy V, 32 KiB-PRG split register)."
527);
528simple_ctor!(
529    new_m261,
530    SimpleBoard::M261,
531    261,
532    "Mapper 261 (BMC-810544-C-A1 address-as-data multicart)."
533);
534simple_ctor!(
535    new_m289,
536    SimpleBoard::M289,
537    289,
538    "Mapper 289 (BMC-60311C NROM/UNROM multicart)."
539);
540simple_ctor!(
541    new_m320,
542    SimpleBoard::M320,
543    320,
544    "Mapper 320 (BMC-830425C-4391T UNROM/UOROM multicart)."
545);
546simple_ctor!(
547    new_m336,
548    SimpleBoard::M336,
549    336,
550    "Mapper 336 (BMC-K-3046 UNROM-style multicart)."
551);
552simple_ctor!(
553    new_m349,
554    SimpleBoard::M349,
555    349,
556    "Mapper 349 (BMC-G-146 NROM/UNROM/NROM-256 multicart)."
557);
558simple_ctor!(
559    new_m286,
560    SimpleBoard::M286,
561    286,
562    "Mapper 286 (Waixing BS-5 Olympic multicart)."
563);
564
565// ===========================================================================
566// Kaiser FDS-conversion boards with a CPU-cycle (M2) IRQ:
567//   56/142 (Kaiser202 / KS202 / KS7032), 303 (Kaiser7017), 253 (Waixing253).
568// Plus the simple Kaiser PRG-window boards 305 (KS7031), 306 (KS7016),
569// 312 (KS7013B). The CPU-cycle IRQ ones declare MapperCaps::CYCLE_IRQ.
570// ===========================================================================
571
572#[cfg(test)]
573#[allow(clippy::cast_possible_truncation)]
574mod tests {
575    use super::*;
576
577    fn synth_prg_8k(banks: usize) -> Box<[u8]> {
578        let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
579        for b in 0..banks {
580            v[b * PRG_BANK_8K] = b as u8;
581        }
582        v.into_boxed_slice()
583    }
584
585    fn synth_prg_16k(banks: usize) -> Box<[u8]> {
586        let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
587        for b in 0..banks {
588            v[b * PRG_BANK_16K] = b as u8;
589        }
590        v.into_boxed_slice()
591    }
592
593    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
594        let mut v = vec![0u8; banks * CHR_BANK_8K];
595        for b in 0..banks {
596            v[b * CHR_BANK_8K] = b as u8;
597        }
598        v.into_boxed_slice()
599    }
600
601    fn synth_chr_2k(banks: usize) -> Box<[u8]> {
602        let mut v = vec![0u8; banks * CHR_BANK_2K];
603        for b in 0..banks {
604            v[b * CHR_BANK_2K] = b as u8;
605        }
606        v.into_boxed_slice()
607    }
608
609    #[test]
610    fn waixing164_split_prg_register() {
611        let mut m = new_m164(synth_prg_16k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
612        m.cpu_write(0x5000, 0x02); // low nibble = 2
613        m.cpu_write(0x5100, 0x00); // high nibble = 0 -> 32 KiB bank 2
614        // 32 KiB bank 2 -> 16 KiB banks 4 and 5.
615        assert_eq!(m.cpu_read(0x8000), 4);
616        assert_eq!(m.cpu_read(0xC000), 5);
617    }
618
619    #[test]
620    fn bmc_simple_save_state_round_trip() {
621        let mut m = new_m164(synth_prg_16k(16), Box::new([]), Mirroring::Vertical).unwrap();
622        m.cpu_write(0x5000, 0x03);
623        m.ppu_write(0x0007, 0x2B);
624        let blob = m.save_state();
625        let mut m2 = new_m164(synth_prg_16k(16), Box::new([]), Mirroring::Vertical).unwrap();
626        m2.load_state(&blob).unwrap();
627        assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
628        assert_eq!(m2.ppu_read(0x0007), 0x2B);
629    }
630
631    #[test]
632    fn bmc810544_address_decode() {
633        let mut m = new_m261(synth_prg_16k(32), synth_chr_8k(16), Mirroring::Vertical).unwrap();
634        // addr bit 6 set -> 32 KiB mode: bank = (addr>>6)&0xFFFE.
635        m.cpu_write(0x8040 | (4 << 6), 0); // bank = (4<<6 ... ) wait compute below.
636        // Simpler: write a precise address.
637        m.cpu_write(0x8000 | (2 << 6) | 0x40, 0); // (addr>>6)&0xFFFE
638        let lo = m.cpu_read(0x8000);
639        let hi = m.cpu_read(0xC000);
640        assert_eq!(hi, lo.wrapping_add(1)); // 32 KiB -> consecutive 16 KiB banks.
641    }
642
643    #[test]
644    fn bmc60311_nrom_mode() {
645        let mut m = new_m289(synth_prg_16k(8), synth_chr_8k(1), Mirroring::Vertical).unwrap();
646        m.cpu_write(0x6001, 0x02); // outer = 2
647        m.cpu_write(0x6000, 0x00); // mode 0 = NROM-128 (mirror inner/outer)
648        m.cpu_write(0x8000, 0x01); // inner = 1 -> page = 2|1 = 3
649        assert_eq!(m.cpu_read(0x8000), 3);
650        assert_eq!(m.cpu_read(0xC000), 3); // mirrored.
651    }
652
653    #[test]
654    fn bmc830425_unrom_mode() {
655        let mut m = new_m320(synth_prg_16k(32), synth_chr_8k(1), Mirroring::Vertical).unwrap();
656        m.cpu_write(0xF0E0 | 0x10 | 0x02, 0x03); // outer=2, mode=1 (UNROM), inner=3
657        // UNROM: prg0 = (3&7)|(2<<3)=19; prg1 = 7|(2<<3)=23.
658        assert_eq!(m.cpu_read(0x8000), 19);
659        assert_eq!(m.cpu_read(0xC000), 23);
660    }
661
662    #[test]
663    fn bmc_k3046_unrom() {
664        let mut m = new_m336(synth_prg_16k(16), synth_chr_8k(1), Mirroring::Vertical).unwrap();
665        m.cpu_write(0x8000, 0x0A); // inner=2, outer=8 -> prg0=8|2=10, prg1=8|7=15
666        assert_eq!(m.cpu_read(0x8000), 10);
667        assert_eq!(m.cpu_read(0xC000), 15);
668    }
669
670    #[test]
671    fn bmc_g146_32k_mode() {
672        let mut m = new_m349(synth_prg_16k(32), synth_chr_8k(1), Mirroring::Vertical).unwrap();
673        // bit 11 clear, bit 6 clear -> 32 KiB mode: prg0=addr&0x1E, prg1=that|1.
674        m.cpu_write(0x8000 | 0x04, 0); // addr&0x1E = 4
675        assert_eq!(m.cpu_read(0x8000), 4);
676        assert_eq!(m.cpu_read(0xC000), 5);
677    }
678
679    #[test]
680    fn bs5_chr_bank_decode() {
681        let mut m = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
682        // $8000 with bank in bits 10-11, CHR index in bits 0-4.
683        m.cpu_write(0x8000 | (1 << 10) | 0x05, 0); // chr bank 1 -> index 5
684        assert_eq!(m.ppu_read(0x0800), 5); // 2 KiB slot 1 -> CHR bank 5.
685    }
686
687    #[test]
688    fn bs5_save_state_round_trip() {
689        let mut m = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
690        m.cpu_write(0x8000 | 0x03, 0);
691        let blob = m.save_state();
692        let mut m2 = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
693        m2.load_state(&blob).unwrap();
694        assert_eq!(m2.ppu_read(0x0000), m.ppu_read(0x0000));
695    }
696
697    /// v2.9.0 re-audit NC-06: the BS-5 DIP value is bounded on restore.
698    ///
699    /// The DIP picks which of A4-A7 gates the `$A000` PRG write (nesdev: "The
700    /// AND mask to which the PRG-ROM select responds is determined in bits 4-7
701    /// by a DIP switch", four settings), decoded as `1 << (dip + 4)`. It was
702    /// restored raw, so a corrupt state with `dip >= 12` made the next `$A000`
703    /// write overflow the shift (or the add, at `0xFF`) — a panic in every
704    /// build with overflow checks (dev, test, fuzz), and a silently wrong
705    /// decode in release. Offset 48 is `dip` in the state layout
706    /// (version + 12 + 16 + 16 + three registers).
707    #[test]
708    fn bs5_load_state_rejects_a_dip_the_board_cannot_have() {
709        const DIP: usize = 1 + 12 + 16 + 16 + 3;
710        let m = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
711        let good = m.save_state();
712        assert_eq!(good[DIP], 0, "fixture: the DIP defaults to setting #1");
713        for dip in [4u8, 12, 0x41, 0x80, 0xFF] {
714            let mut bad = good.clone();
715            bad[DIP] = dip;
716            let mut m2 = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
717            assert!(
718                matches!(m2.load_state(&bad), Err(MapperError::Invalid(_))),
719                "DIP {dip:#04x} must be rejected"
720            );
721        }
722        // Each of the four real settings loads and gates on its own line.
723        for dip in 0..=3u8 {
724            let mut ok = good.clone();
725            ok[DIP] = dip;
726            let mut m2 = new_m286(synth_prg_8k(16), synth_chr_2k(16), Mirroring::Vertical).unwrap();
727            m2.load_state(&ok).unwrap();
728            for a in 0xA000..=0xAFFFu16 {
729                m2.cpu_write(a, 0);
730            }
731        }
732    }
733}