Skip to main content

rustynes_mappers/
m185_cnrom185.rs

1//! CNROM with CHR copy protection (mapper 185).
2//!
3//! Electrically a stock CNROM, but the board's CHR-ROM is used as a
4//! protection check: the game reads a known pattern back from CHR and, if the
5//! value is wrong, the board disables CHR entirely so the screen fills with
6//! garbage. Emulating it means modelling the *disable*, not just the banking
7//! -- and the exact value that counts as "correct" varies by submapper, which
8//! is why the decode matches on submapper rather than assuming one rule.
9//!
10//! Stock CNROM is in `m003_cnrom.rs`.
11//!
12//! A best-effort (Tier-2) board: register-decode correctness verified against
13//! the `GeraNES` reference emulator (cross-referenced, not copied)
14//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
15//! is direct slice indexing and every bank select wraps with `% count`, so a
16//! register write can never index out of bounds -- required for the `#![no_std]`
17//! chip stack, which cannot afford a panic on a register access.
18//!
19//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
20//! and `docs/mappers.md` §Mapper coverage matrix.
21
22use crate::cartridge::Mirroring;
23use crate::mapper::{Mapper, MapperCaps, MapperError};
24use alloc::{boxed::Box, vec::Vec};
25use alloc::{format, vec};
26
27const PRG_BANK_16K: usize = 0x4000;
28const PRG_BANK_32K: usize = 0x8000;
29const CHR_BANK_8K: usize = 0x2000;
30const NAMETABLE_SIZE: usize = 0x0400;
31const NAMETABLE_SIZE_U16: u16 = 0x0400;
32
33const SAVE_STATE_VERSION: u8 = 1;
34
35// ---------------------------------------------------------------------------
36// Shared nametable helper (mirrors the one in the other simple-mapper modules).
37// ---------------------------------------------------------------------------
38
39const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
40    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
41    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
42    let physical = mirroring.physical_bank(table);
43    physical * NAMETABLE_SIZE + local
44}
45
46/// Mapper 185 (`CNROM` with CHR-disable copy protection).
47pub struct CnRom185 {
48    prg_rom: Box<[u8]>,
49    chr_rom: Box<[u8]>,
50    vram: Box<[u8]>,
51    chr_reg_raw: u8,
52    chr_bank: u8,
53    /// CHR-ROM enable latch. Powers on ENABLED (Mesen2 `CnromProtect`); the
54    /// protection write may disable it. Initialising this to a derived-from-
55    /// `chr_reg_raw=0` value left CHR reading $FF before the first register
56    /// write, so the title screen never drew -> blank boot.
57    chr_enabled: bool,
58    sub_mapper: u8,
59    mirroring: Mirroring,
60}
61
62impl CnRom185 {
63    /// Construct a new mapper 185 board.
64    ///
65    /// `sub_mapper` selects the CHR-enable pattern (0 = default heuristic,
66    /// 4..=7 = exact-match `value & 0x03`).
67    ///
68    /// # Errors
69    ///
70    /// Returns [`MapperError::Invalid`] when PRG is not 16/32 KiB or CHR-ROM is
71    /// empty / not a multiple of 8 KiB.
72    pub fn new(
73        prg_rom: Box<[u8]>,
74        chr_rom: Box<[u8]>,
75        mirroring: Mirroring,
76        sub_mapper: u8,
77    ) -> Result<Self, MapperError> {
78        if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
79            return Err(MapperError::Invalid(format!(
80                "mapper 185 expects 16 or 32 KiB PRG, got {} bytes",
81                prg_rom.len()
82            )));
83        }
84        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
85            return Err(MapperError::Invalid(format!(
86                "mapper 185 expects non-empty CHR-ROM in 8 KiB units, got {} bytes",
87                chr_rom.len()
88            )));
89        }
90        Ok(Self {
91            prg_rom,
92            chr_rom,
93            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
94            chr_reg_raw: 0,
95            chr_bank: 0,
96            chr_enabled: true,
97            sub_mapper: sub_mapper & 0x0F,
98            mirroring,
99        })
100    }
101
102    // The per-submapper CHR-enable rule (Mesen2 CnromProtect): submapper 0 is a
103    // heuristic on the raw written latch; 4..=7 are exact low-2-bit matches.
104    #[allow(clippy::verbose_bit_mask)]
105    const fn chr_enable_for(&self, value: u8) -> bool {
106        match self.sub_mapper {
107            4 => (value & 0x03) == 0,
108            5 => (value & 0x03) == 1,
109            6 => (value & 0x03) == 2,
110            7 => (value & 0x03) == 3,
111            // Submapper 0 heuristic: enabled iff low nibble nonzero and != $13.
112            _ => (value & 0x0F) != 0 && value != 0x13,
113        }
114    }
115
116    fn read_prg(&self, addr: u16) -> u8 {
117        let off = (addr - 0x8000) as usize;
118        if self.prg_rom.len() == PRG_BANK_16K {
119            self.prg_rom[off & (PRG_BANK_16K - 1)]
120        } else {
121            self.prg_rom[off]
122        }
123    }
124}
125
126impl Mapper for CnRom185 {
127    fn caps(&self) -> MapperCaps {
128        MapperCaps::NONE
129    }
130
131    fn cpu_read(&mut self, addr: u16) -> u8 {
132        if (0x8000..=0xFFFF).contains(&addr) {
133            self.read_prg(addr)
134        } else {
135            0
136        }
137    }
138
139    fn cpu_write(&mut self, addr: u16, value: u8) {
140        if (0x8000..=0xFFFF).contains(&addr) {
141            // Bus conflict (mapper 185 always has AND-type bus conflicts).
142            let effective = value & self.read_prg(addr);
143            self.chr_reg_raw = effective;
144            self.chr_enabled = self.chr_enable_for(effective);
145            let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
146            let mask = u8::try_from((count - 1) | 0x03).unwrap_or(u8::MAX);
147            self.chr_bank = effective & mask;
148        }
149    }
150
151    fn ppu_read(&mut self, addr: u16) -> u8 {
152        let addr = addr & 0x3FFF;
153        match addr {
154            0x0000..=0x1FFF => {
155                if self.chr_enabled {
156                    let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
157                    let bank = (self.chr_bank as usize) % count;
158                    self.chr_rom[bank * CHR_BANK_8K + addr as usize]
159                } else {
160                    // CHR disabled by protection: the open bus reads $FF (D0 is
161                    // held high by a pull-up, which $FF already satisfies).
162                    0xFF
163                }
164            }
165            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
166            _ => 0,
167        }
168    }
169
170    fn ppu_write(&mut self, addr: u16, value: u8) {
171        let addr = addr & 0x3FFF;
172        if let 0x2000..=0x3EFF = addr {
173            let off = nametable_offset(addr, self.mirroring);
174            self.vram[off] = value;
175        }
176    }
177
178    fn current_mirroring(&self) -> Mirroring {
179        self.mirroring
180    }
181
182    fn save_state(&self) -> Vec<u8> {
183        let mut out = Vec::with_capacity(4 + self.vram.len());
184        out.push(SAVE_STATE_VERSION);
185        out.push(self.chr_reg_raw);
186        out.push(self.chr_bank);
187        out.push(self.sub_mapper);
188        out.extend_from_slice(&self.vram);
189        out
190    }
191
192    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
193        let expected = 4 + self.vram.len();
194        if data.len() != expected {
195            return Err(MapperError::WrongLength {
196                expected,
197                got: data.len(),
198            });
199        }
200        if data[0] != SAVE_STATE_VERSION {
201            return Err(MapperError::UnsupportedVersion(data[0]));
202        }
203        self.chr_reg_raw = data[1];
204        self.chr_bank = data[2];
205        self.sub_mapper = data[3] & 0x0F;
206        // chr_enabled is a deterministic function of the latched register +
207        // submapper, so it is reconstructed rather than serialised (keeps the
208        // save format stable). Power-on (chr_reg_raw == 0) restores to enabled
209        // only if the heuristic agrees; the first write re-evaluates anyway.
210        self.chr_enabled = self.chr_enable_for(self.chr_reg_raw);
211        self.vram.copy_from_slice(&data[4..4 + self.vram.len()]);
212        Ok(())
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::cast_possible_truncation)]
218mod tests {
219    use super::*;
220
221    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
222        let mut v = vec![0u8; banks * CHR_BANK_8K];
223        for b in 0..banks {
224            v[b * CHR_BANK_8K] = b as u8;
225        }
226        v.into_boxed_slice()
227    }
228
229    fn synth_prg(bytes: usize, fill: u8) -> Box<[u8]> {
230        vec![fill; bytes].into_boxed_slice()
231    }
232
233    #[test]
234    fn m185_chr_disable_protection_default() {
235        let mut m = CnRom185::new(
236            synth_prg(PRG_BANK_32K, 0xFF),
237            synth_chr_8k(4),
238            Mirroring::Vertical,
239            0,
240        )
241        .unwrap();
242        // Power-on: CHR is ENABLED before any register write (Mesen2), so the
243        // title screen draws. (The old derive-from-zero model read $FF here.)
244        assert_eq!(m.ppu_read(0x0000), 0);
245        // Submapper-0 heuristic: enabled iff (value & 0x0F) != 0 and value != $13.
246        // Write 1 -> enabled, bank = 1 & mask.
247        m.cpu_write(0x8000, 1);
248        assert_eq!(m.ppu_read(0x0000), 1);
249        // Write 0 -> CHR disabled -> reads $FF.
250        m.cpu_write(0x8000, 0);
251        assert_eq!(m.ppu_read(0x0000), 0xFF);
252        // Write $13 -> the documented disabled sentinel -> $FF.
253        m.cpu_write(0x8000, 0x13);
254        assert_eq!(m.ppu_read(0x0000), 0xFF);
255    }
256
257    #[test]
258    fn m185_submapper_exact_match() {
259        let mut m = CnRom185::new(
260            synth_prg(PRG_BANK_32K, 0xFF),
261            synth_chr_8k(4),
262            Mirroring::Vertical,
263            4, // enabled iff (value & 3) == 0
264        )
265        .unwrap();
266        m.cpu_write(0x8000, 0); // (0 & 3) == 0 -> enabled, bank 0
267        assert_eq!(m.ppu_read(0x0000), 0);
268        m.cpu_write(0x8000, 1); // (1 & 3) == 1 != 0 -> disabled
269        assert_eq!(m.ppu_read(0x0000), 0xFF);
270    }
271}