Skip to main content

rustynes_mappers/
m003_cnrom.rs

1//! `CNROM` (iNES mapper 3) implementation.
2//!
3//! `CNROM` has a fixed 16/32 KiB PRG-ROM (`NROM`-style) and a switchable
4//! 8 KiB CHR-ROM bank selected by writes to `$8000-$FFFF`. Most `CNROM`
5//! cartridges exhibit bus conflicts: the value placed on the data bus by
6//! the ROM-resident byte at the written address is `AND`ed with the value
7//! the CPU is writing. Per `docs/mappers.md` §Bus conflicts, we model
8//! this so affected ROMs (a small but real set) behave correctly.
9
10use crate::cartridge::Mirroring;
11use crate::mapper::{Mapper, MapperCaps, MapperError};
12use alloc::{boxed::Box, vec::Vec};
13use alloc::{format, vec};
14
15const PRG_BANK_16K: usize = 0x4000;
16const PRG_BANK_32K: usize = 0x8000;
17const CHR_BANK_8K: usize = 0x2000;
18const NAMETABLE_SIZE: usize = 0x0400;
19const NAMETABLE_SIZE_U16: u16 = 0x0400;
20
21const SAVE_STATE_VERSION: u8 = 1;
22
23/// `CNROM` mapper.
24pub struct CnRom {
25    prg_rom: Box<[u8]>,
26    chr_rom: Box<[u8]>,
27    vram: Box<[u8]>,
28    chr_bank: u8,
29    mirroring: Mirroring,
30}
31
32impl CnRom {
33    /// Construct a new `CNROM` mapper.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`MapperError::Invalid`] when the PRG-ROM is not 16/32 KiB or
38    /// CHR-ROM is empty / not a multiple of 8 KiB.
39    pub fn new(
40        prg_rom: Box<[u8]>,
41        chr_rom: Box<[u8]>,
42        mirroring: Mirroring,
43    ) -> Result<Self, MapperError> {
44        if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
45            return Err(MapperError::Invalid(format!(
46                "CNROM expects 16 or 32 KiB PRG, got {} bytes",
47                prg_rom.len()
48            )));
49        }
50        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
51            return Err(MapperError::Invalid(format!(
52                "CNROM expects non-empty CHR-ROM in 8 KiB units, got {} bytes",
53                chr_rom.len()
54            )));
55        }
56        Ok(Self {
57            prg_rom,
58            chr_rom,
59            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
60            chr_bank: 0,
61            mirroring,
62        })
63    }
64
65    const fn nametable_offset(&self, addr: u16) -> usize {
66        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
67        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
68        let physical = self.mirroring.physical_bank(table);
69        physical * NAMETABLE_SIZE + local
70    }
71
72    fn read_prg(&self, addr: u16) -> u8 {
73        let off = (addr - 0x8000) as usize;
74        if self.prg_rom.len() == PRG_BANK_16K {
75            self.prg_rom[off & (PRG_BANK_16K - 1)]
76        } else {
77            self.prg_rom[off]
78        }
79    }
80}
81
82impl Mapper for CnRom {
83    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
84    // skips all four per-CPU-cycle dispatches for this board.
85    fn caps(&self) -> MapperCaps {
86        MapperCaps::NONE
87    }
88
89    fn cpu_read(&mut self, addr: u16) -> u8 {
90        if (0x8000..=0xFFFF).contains(&addr) {
91            self.read_prg(addr)
92        } else {
93            0
94        }
95    }
96
97    fn cpu_write(&mut self, addr: u16, value: u8) {
98        if (0x8000..=0xFFFF).contains(&addr) {
99            // Bus conflict: AND the written value with the byte present at
100            // the same PRG address.
101            let prg_byte = self.read_prg(addr);
102            let effective = value & prg_byte;
103            // CHR-bank size depends on cart; for stock CNROM it's 8 KiB
104            // banks selected by the low 2 bits (some clones use more).
105            let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
106            let mask = u8::try_from((bank_count - 1) | 0x03).unwrap_or(u8::MAX);
107            self.chr_bank = effective & mask;
108        }
109    }
110
111    fn chr_phys(&self, addr: u16) -> Option<u32> {
112        // CNROM is CHR-ROM only; the same 8 KiB-bank offset `ppu_read` resolves.
113        let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
114        let bank = usize::from(self.chr_bank) % bank_count;
115        u32::try_from(bank * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok()
116    }
117
118    fn ppu_read(&mut self, addr: u16) -> u8 {
119        let addr = addr & 0x3FFF;
120        match addr {
121            0x0000..=0x1FFF => {
122                let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
123                let bank = (self.chr_bank as usize) % bank_count;
124                self.chr_rom[bank * CHR_BANK_8K + (addr as usize)]
125            }
126            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
127            _ => 0,
128        }
129    }
130
131    fn ppu_write(&mut self, addr: u16, value: u8) {
132        let addr = addr & 0x3FFF;
133        if let 0x2000..=0x3EFF = addr {
134            let off = self.nametable_offset(addr);
135            self.vram[off] = value;
136        }
137        // CHR-ROM writes ignored.
138    }
139
140    fn current_mirroring(&self) -> Mirroring {
141        self.mirroring
142    }
143
144    // CNROM has fixed solder-pad mirroring — a game-DB header correction is valid.
145    fn has_hardwired_mirroring(&self) -> bool {
146        true
147    }
148
149    fn save_state(&self) -> Vec<u8> {
150        let mut out = Vec::with_capacity(2 + self.vram.len());
151        out.push(SAVE_STATE_VERSION);
152        out.push(self.chr_bank);
153        out.extend_from_slice(&self.vram);
154        out
155    }
156
157    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
158        let expected = 2 + self.vram.len();
159        if data.len() != expected {
160            return Err(MapperError::WrongLength {
161                expected,
162                got: data.len(),
163            });
164        }
165        if data[0] != SAVE_STATE_VERSION {
166            return Err(MapperError::UnsupportedVersion(data[0]));
167        }
168        self.chr_bank = data[1];
169        self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
170        Ok(())
171    }
172}
173
174#[cfg(test)]
175#[allow(clippy::cast_possible_truncation)]
176mod tests {
177    use super::*;
178
179    fn synth_prg(bytes: usize, fill: u8) -> Box<[u8]> {
180        vec![fill; bytes].into_boxed_slice()
181    }
182
183    fn synth_chr(banks: usize) -> Box<[u8]> {
184        let mut v = vec![0u8; banks * CHR_BANK_8K];
185        for b in 0..banks {
186            v[b * CHR_BANK_8K] = b as u8;
187        }
188        v.into_boxed_slice()
189    }
190
191    #[test]
192    fn cnrom_chr_bank_select_with_no_conflict() {
193        let mut m = CnRom::new(
194            synth_prg(PRG_BANK_32K, 0xFF),
195            synth_chr(4),
196            Mirroring::Vertical,
197        )
198        .unwrap();
199        // PRG byte is 0xFF everywhere, so AND has no effect.
200        m.cpu_write(0x8000, 2);
201        assert_eq!(m.ppu_read(0x0000), 2);
202    }
203
204    #[test]
205    fn cnrom_bus_conflict_masks_write_with_prg_byte() {
206        // PRG byte at $8000 = 0x01. Writing 0x03 -> conflict yields 0x01.
207        let mut prg = vec![0u8; PRG_BANK_32K];
208        prg[0] = 0x01;
209        let mut m = CnRom::new(prg.into_boxed_slice(), synth_chr(4), Mirroring::Vertical).unwrap();
210        m.cpu_write(0x8000, 0x03);
211        assert_eq!(m.ppu_read(0x0000), 1);
212    }
213
214    #[test]
215    fn cnrom_save_state_round_trip() {
216        let mut m = CnRom::new(
217            synth_prg(PRG_BANK_32K, 0xFF),
218            synth_chr(4),
219            Mirroring::Vertical,
220        )
221        .unwrap();
222        m.cpu_write(0x8000, 3);
223        let blob = m.save_state();
224        let mut m2 = CnRom::new(
225            synth_prg(PRG_BANK_32K, 0xFF),
226            synth_chr(4),
227            Mirroring::Vertical,
228        )
229        .unwrap();
230        m2.load_state(&blob).unwrap();
231        assert_eq!(m2.ppu_read(0x0000), 3);
232    }
233}