Skip to main content

rustynes_mappers/
m041_caltron41.rs

1//! Caltron 6-in-1 (mapper 41).
2//!
3//! Two-stage banking: an outer register at `$6000-$67FF` selects the game and
4//! carries both the mirroring bit and an enable for the inner register, which
5//! is written through `$8000-$FFFF` and supplies the low CHR bits. The inner
6//! write is subject to a bus conflict, and is ignored entirely unless the
7//! outer register has enabled it.
8//!
9//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
10//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
11//! audio. Banking / mirroring semantics are cross-checked against the
12//! `GeraNES` reference emulator (cross-referenced, not copied)
13//! and the nesdev wiki, and validated by register-decode + save-state unit
14//! tests.
15//!
16//! See `docs/mappers.md` §Mapper coverage matrix.
17
18use crate::cartridge::Mirroring;
19use crate::mapper::{Mapper, MapperCaps, MapperError};
20use alloc::{boxed::Box, vec::Vec};
21use alloc::{format, vec};
22
23const PRG_BANK_32K: usize = 0x8000;
24const CHR_BANK_8K: usize = 0x2000;
25const NAMETABLE_SIZE: usize = 0x0400;
26const NAMETABLE_SIZE_U16: u16 = 0x0400;
27
28const SAVE_STATE_VERSION: u8 = 1;
29
30const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
31    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
32    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
33    let physical = mirroring.physical_bank(table);
34    physical * NAMETABLE_SIZE + local
35}
36
37/// Mapper 41 (Caltron 6-in-1).
38pub struct Caltron41 {
39    prg_rom: Box<[u8]>,
40    chr_rom: Box<[u8]>,
41    vram: Box<[u8]>,
42    prg_bank: u8,
43    outer_chr: u8,
44    inner_chr: u8,
45    inner_enable: bool,
46    horizontal_mirroring: bool,
47}
48
49impl Caltron41 {
50    /// Construct a new Caltron 6-in-1 (mapper 41) board.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
55    /// 32 KiB or CHR-ROM is empty / not a multiple of 8 KiB.
56    pub fn new(
57        prg_rom: Box<[u8]>,
58        chr_rom: Box<[u8]>,
59        mirroring: Mirroring,
60    ) -> Result<Self, MapperError> {
61        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
62            return Err(MapperError::Invalid(format!(
63                "mapper 41 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
64                prg_rom.len()
65            )));
66        }
67        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
68            return Err(MapperError::Invalid(format!(
69                "mapper 41 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
70                chr_rom.len()
71            )));
72        }
73        // The board's mirroring is runtime-controlled; seed from the header's
74        // arrangement so the power-on state matches a sensible default.
75        let horizontal_mirroring = mirroring == Mirroring::Horizontal;
76        Ok(Self {
77            prg_rom,
78            chr_rom,
79            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
80            prg_bank: 0,
81            outer_chr: 0,
82            inner_chr: 0,
83            inner_enable: false,
84            horizontal_mirroring,
85        })
86    }
87
88    const fn chr_bank(&self) -> u8 {
89        (self.outer_chr << 2) | self.inner_chr
90    }
91
92    fn chr_offset(&self, addr: u16) -> usize {
93        let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
94        let bank = (self.chr_bank() as usize) % count;
95        bank * CHR_BANK_8K + addr as usize
96    }
97
98    fn read_prg(&self, addr: u16) -> u8 {
99        let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
100        let bank = (self.prg_bank as usize) % count;
101        self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
102    }
103}
104
105impl Mapper for Caltron41 {
106    fn caps(&self) -> MapperCaps {
107        MapperCaps::NONE
108    }
109
110    // The outer register sits in $6000-$67FF, which is "mapped" by the default
111    // `cpu_read_unmapped` (>= $6000), so no override is needed there.
112
113    fn cpu_read(&mut self, addr: u16) -> u8 {
114        if (0x8000..=0xFFFF).contains(&addr) {
115            self.read_prg(addr)
116        } else {
117            0
118        }
119    }
120
121    fn cpu_write(&mut self, addr: u16, value: u8) {
122        match addr {
123            0x6000..=0x67FF => {
124                // Outer register: decoded from address bits, data ignored.
125                let e = ((addr >> 2) & 0x01) as u8;
126                let pp = (addr & 0x03) as u8;
127                self.prg_bank = (e << 2) | pp;
128                self.inner_enable = e != 0;
129                self.outer_chr = ((addr >> 3) & 0x03) as u8;
130                self.horizontal_mirroring = ((addr >> 5) & 0x01) != 0;
131            }
132            0x8000..=0xFFFF if self.inner_enable => {
133                // Inner CHR register has bus conflicts.
134                let effective = value & self.read_prg(addr);
135                self.inner_chr = effective & 0x03;
136            }
137            _ => {}
138        }
139    }
140
141    fn ppu_read(&mut self, addr: u16) -> u8 {
142        let addr = addr & 0x3FFF;
143        match addr {
144            0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
145            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
146            _ => 0,
147        }
148    }
149
150    fn ppu_write(&mut self, addr: u16, value: u8) {
151        let addr = addr & 0x3FFF;
152        if let 0x2000..=0x3EFF = addr {
153            let off = nametable_offset(addr, self.current_mirroring());
154            self.vram[off] = value;
155        }
156    }
157
158    fn current_mirroring(&self) -> Mirroring {
159        if self.horizontal_mirroring {
160            Mirroring::Horizontal
161        } else {
162            Mirroring::Vertical
163        }
164    }
165
166    fn save_state(&self) -> Vec<u8> {
167        let mut out = Vec::with_capacity(6 + self.vram.len());
168        out.push(SAVE_STATE_VERSION);
169        out.push(self.prg_bank);
170        out.push(self.outer_chr);
171        out.push(self.inner_chr);
172        out.push(u8::from(self.inner_enable));
173        out.push(u8::from(self.horizontal_mirroring));
174        out.extend_from_slice(&self.vram);
175        out
176    }
177
178    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
179        let expected = 6 + self.vram.len();
180        if data.len() != expected {
181            return Err(MapperError::WrongLength {
182                expected,
183                got: data.len(),
184            });
185        }
186        if data[0] != SAVE_STATE_VERSION {
187            return Err(MapperError::UnsupportedVersion(data[0]));
188        }
189        self.prg_bank = data[1];
190        self.outer_chr = data[2];
191        self.inner_chr = data[3];
192        self.inner_enable = data[4] != 0;
193        self.horizontal_mirroring = data[5] != 0;
194        self.vram.copy_from_slice(&data[6..6 + self.vram.len()]);
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200#[allow(clippy::cast_possible_truncation)]
201mod tests {
202    use super::*;
203
204    fn synth_prg_32k(banks: usize) -> Box<[u8]> {
205        let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
206        for b in 0..banks {
207            v[b * PRG_BANK_32K] = b as u8;
208        }
209        v.into_boxed_slice()
210    }
211
212    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
213        let mut v = vec![0u8; banks * CHR_BANK_8K];
214        for b in 0..banks {
215            v[b * CHR_BANK_8K] = b as u8;
216        }
217        v.into_boxed_slice()
218    }
219
220    #[test]
221    fn m41_outer_register_decodes_from_address() {
222        let mut m =
223            Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
224        // addr layout 0110 0xxx xxMC CEPP.
225        // Pick $6000 | (M=1<<5) | (CC=0b11<<3) | (E=1<<2) | (PP=0b10).
226        // => A5=1 (horizontal), A4..3 = 0b11 (outer CHR 3), A2 = 1 (E set),
227        //    A1..0 = 0b10. PRG = (E<<2)|PP = 0b110 = 6.
228        let addr = 0x6000 | (1 << 5) | (0b11 << 3) | (1 << 2) | 0b10;
229        m.cpu_write(addr, 0x00);
230        assert_eq!(m.cpu_read(0x8000), 6);
231        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
232        // Inner CHR write (E set, so honoured). PRG bytes are 0xFF except at
233        // offset 0; write to $8001 (byte 0xFF) -> no conflict masking.
234        m.cpu_write(0x8001, 0b01); // inner CHR low = 1
235        // CHR bank = (outer 3 << 2) | inner 1 = 13.
236        assert_eq!(m.ppu_read(0x0000), 13);
237    }
238
239    #[test]
240    fn m41_inner_write_gated_by_enable() {
241        let mut m =
242            Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
243        // Outer write with E clear (A2 = 0): PP = 0, outer CHR = 1, E = 0.
244        let addr = 0x6000 | (0b01 << 3); // CC = 1, E = 0, PP = 0
245        m.cpu_write(addr, 0x00);
246        // Inner write must be ignored while disabled.
247        m.cpu_write(0x8001, 0b11);
248        // CHR bank = (outer 1 << 2) | inner 0 = 4.
249        assert_eq!(m.ppu_read(0x0000), 4);
250    }
251
252    #[test]
253    fn m41_inner_chr_has_bus_conflict() {
254        // PRG byte at offset 0 of bank 0 is the bank index (0). Writing the
255        // inner register at $8000 ANDs with that 0 -> inner CHR forced to 0.
256        let mut m =
257            Caltron41::new(synth_prg_32k(8), synth_chr_8k(16), Mirroring::Vertical).unwrap();
258        // Enable inner (E set), outer CHR 0, PRG bank 0 wraps so offset-0 byte
259        // is 0 (the bank index marker).
260        let addr = 0x6000 | (1 << 2); // E = 1, everything else 0 -> PRG bank 4
261        m.cpu_write(addr, 0x00);
262        // PRG bank is now 4 (E<<2). Offset 0 of bank 4 holds value 4.
263        // Write inner at $8000: data 0b11 AND prg_byte(4 = 0b100) = 0b00.
264        m.cpu_write(0x8000, 0b11);
265        assert_eq!(m.ppu_read(0x0000), 0); // outer 0, inner masked to 0
266    }
267}