Skip to main content

rustynes_mappers/
m038_bitcorp38.rs

1//! Bit Corp `UNL-PCI556` (mapper 38) -- Crime Busters.
2//!
3//! A single PRG/CHR latch register at `$7000-$7FFF`, deliberately placed in
4//! the PRG-RAM window so the board needs no `$8000` write decode at all.
5//!
6//! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`,
7//! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart
8//! audio. Banking / mirroring semantics are cross-checked against the
9//! `GeraNES` reference emulator (cross-referenced, not copied)
10//! and the nesdev wiki, and validated by register-decode + save-state unit
11//! tests.
12//!
13//! See `docs/mappers.md` §Mapper coverage matrix.
14
15use crate::cartridge::Mirroring;
16use crate::mapper::{Mapper, MapperCaps, MapperError};
17use alloc::{boxed::Box, vec::Vec};
18use alloc::{format, vec};
19
20const PRG_BANK_32K: usize = 0x8000;
21const CHR_BANK_8K: usize = 0x2000;
22const NAMETABLE_SIZE: usize = 0x0400;
23const NAMETABLE_SIZE_U16: u16 = 0x0400;
24
25const SAVE_STATE_VERSION: u8 = 1;
26
27const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
28    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
29    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
30    let physical = mirroring.physical_bank(table);
31    physical * NAMETABLE_SIZE + local
32}
33
34// ===========================================================================
35// Mapper 38 — Bit Corp UNL-PCI556.
36//
37// Single 8-bit latch at $7000-$7FFF. Low 2 bits select a 32 KiB PRG bank;
38// bits 3-2 select an 8 KiB CHR bank. No bus conflicts (the register lives in
39// the $6000-$7FFF window, not in PRG-ROM). Mirroring is header-fixed; no IRQ.
40// ===========================================================================
41
42/// Mapper 38 (Bit Corp `UNL-PCI556`).
43pub struct Bitcorp38 {
44    prg_rom: Box<[u8]>,
45    chr_rom: Box<[u8]>,
46    vram: Box<[u8]>,
47    prg_bank: u8,
48    chr_bank: u8,
49    mirroring: Mirroring,
50}
51
52impl Bitcorp38 {
53    /// Construct a new mapper 38 board.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
58    /// 32 KiB or CHR-ROM is empty / not a multiple of 8 KiB.
59    pub fn new(
60        prg_rom: Box<[u8]>,
61        chr_rom: Box<[u8]>,
62        mirroring: Mirroring,
63    ) -> Result<Self, MapperError> {
64        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
65            return Err(MapperError::Invalid(format!(
66                "mapper 38 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
67                prg_rom.len()
68            )));
69        }
70        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
71            return Err(MapperError::Invalid(format!(
72                "mapper 38 CHR-ROM size {} is not a non-zero multiple of 8 KiB",
73                chr_rom.len()
74            )));
75        }
76        Ok(Self {
77            prg_rom,
78            chr_rom,
79            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
80            prg_bank: 0,
81            chr_bank: 0,
82            mirroring,
83        })
84    }
85}
86
87impl Mapper for Bitcorp38 {
88    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_038: "V (hardwired)"`, so a
89    /// per-game database correction of a wrong header bit is safe here.
90    fn has_hardwired_mirroring(&self) -> bool {
91        true
92    }
93
94    fn caps(&self) -> MapperCaps {
95        MapperCaps::NONE
96    }
97
98    fn cpu_read(&mut self, addr: u16) -> u8 {
99        if (0x8000..=0xFFFF).contains(&addr) {
100            let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
101            let bank = (self.prg_bank as usize) % count;
102            self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
103        } else {
104            0
105        }
106    }
107
108    fn cpu_write(&mut self, addr: u16, value: u8) {
109        if (0x7000..=0x7FFF).contains(&addr) {
110            self.prg_bank = value & 0x03;
111            self.chr_bank = (value >> 2) & 0x03;
112        }
113    }
114
115    fn ppu_read(&mut self, addr: u16) -> u8 {
116        let addr = addr & 0x3FFF;
117        match addr {
118            0x0000..=0x1FFF => {
119                let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
120                let bank = (self.chr_bank as usize) % count;
121                self.chr_rom[bank * CHR_BANK_8K + addr as usize]
122            }
123            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
124            _ => 0,
125        }
126    }
127
128    fn ppu_write(&mut self, addr: u16, value: u8) {
129        let addr = addr & 0x3FFF;
130        if let 0x2000..=0x3EFF = addr {
131            let off = nametable_offset(addr, self.mirroring);
132            self.vram[off] = value;
133        }
134    }
135
136    fn current_mirroring(&self) -> Mirroring {
137        self.mirroring
138    }
139
140    fn save_state(&self) -> Vec<u8> {
141        let mut out = Vec::with_capacity(3 + self.vram.len());
142        out.push(SAVE_STATE_VERSION);
143        out.push(self.prg_bank);
144        out.push(self.chr_bank);
145        out.extend_from_slice(&self.vram);
146        out
147    }
148
149    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
150        let expected = 3 + self.vram.len();
151        if data.len() != expected {
152            return Err(MapperError::WrongLength {
153                expected,
154                got: data.len(),
155            });
156        }
157        if data[0] != SAVE_STATE_VERSION {
158            return Err(MapperError::UnsupportedVersion(data[0]));
159        }
160        self.prg_bank = data[1];
161        self.chr_bank = data[2];
162        self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
163        Ok(())
164    }
165}
166
167#[cfg(test)]
168#[allow(clippy::cast_possible_truncation)]
169mod tests {
170    use super::*;
171
172    fn synth_prg_32k(banks: usize) -> Box<[u8]> {
173        let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
174        for b in 0..banks {
175            v[b * PRG_BANK_32K] = b as u8;
176        }
177        v.into_boxed_slice()
178    }
179
180    fn synth_chr_8k(banks: usize) -> Box<[u8]> {
181        let mut v = vec![0u8; banks * CHR_BANK_8K];
182        for b in 0..banks {
183            v[b * CHR_BANK_8K] = b as u8;
184        }
185        v.into_boxed_slice()
186    }
187
188    #[test]
189    fn m38_latch_selects_prg_and_chr() {
190        let mut m = Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
191        // value 0b0000_1110: PRG = 0b10 = 2, CHR = 0b11 = 3.
192        m.cpu_write(0x7123, 0b0000_1110);
193        assert_eq!(m.cpu_read(0x8000), 2);
194        assert_eq!(m.ppu_read(0x0000), 3);
195        // Writes outside $7000-$7FFF are ignored.
196        m.cpu_write(0x6000, 0xFF);
197        assert_eq!(m.cpu_read(0x8000), 2);
198    }
199
200    #[test]
201    fn m38_save_state_round_trip() {
202        let mut m = Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
203        m.cpu_write(0x7000, 0b0000_1101); // PRG 1, CHR 3
204        let blob = m.save_state();
205        let mut m2 =
206            Bitcorp38::new(synth_prg_32k(4), synth_chr_8k(4), Mirroring::Vertical).unwrap();
207        m2.load_state(&blob).unwrap();
208        assert_eq!(m2.cpu_read(0x8000), 1);
209        assert_eq!(m2.ppu_read(0x0000), 3);
210    }
211}