Skip to main content

rustynes_mappers/
m034_bnrom_nina001.rs

1//! BNROM and NINA-001 (mapper 34) -- two incompatible boards sharing one
2//! mapper number.
3//!
4//! iNES mapper 34 is overloaded. **BNROM** (Nintendo/Irem) has a single
5//! write-anywhere `$8000-$FFFF` register selecting a 32 KiB PRG bank, with
6//! CHR-RAM and no CHR banking. **NINA-001** (AVE) instead decodes three
7//! registers in the PRG-RAM window at `$7FFD-$7FFF` -- one 32 KiB PRG select
8//! and two 4 KiB CHR selects -- and carries CHR-ROM.
9//!
10//! The two are told apart by CHR-ROM presence (a NINA-001 board has it; a
11//! BNROM board cannot), captured in [`M34Variant`].
12//!
13//! See `docs/mappers.md` §Mapper coverage matrix.
14
15#![allow(
16    clippy::cast_possible_truncation,
17    clippy::cast_lossless,
18    clippy::missing_const_for_fn,
19    clippy::needless_pass_by_ref_mut,
20    clippy::manual_range_patterns,
21    clippy::match_same_arms,
22    clippy::too_many_arguments
23)]
24
25use crate::cartridge::Mirroring;
26use crate::mapper::{Mapper, MapperCaps, MapperError};
27use alloc::{boxed::Box, vec::Vec};
28use alloc::{format, vec};
29
30const CHR_BANK_4K: usize = 0x1000;
31const CHR_BANK_8K: usize = 0x2000;
32const NAMETABLE_SIZE: usize = 0x0400;
33const NAMETABLE_SIZE_U16: u16 = 0x0400;
34
35/// Version byte this board writes in its mapper save-state section.
36///
37/// **v1** (through v2.9.1) carried the PRG and CHR bank registers, the
38/// variant, the 8 KiB PRG-RAM and the 2 KiB nametable RAM. The 8 KiB CHR-RAM
39/// -- which every BNROM board has, *Deadly Towers* among them -- was left
40/// out, and the `.rns` container has no other section that carries cartridge
41/// RAM, so every save-state load, rewind step, run-ahead frame and netplay
42/// rollback kept the running game's tiles instead of the saved ones (the
43/// v2.9.2 cartridge-RAM sweep; the same omission core audit AUD-02 found on
44/// the Konami VRC boards). **v2** appends the CHR-RAM when present.
45/// Since v2.9.8 (ADR 0042)
46/// `load_state` reads v2 only and refuses a v1 blob, which it used to load
47/// with the RAM left untouched.
48const M34_SECTION_VERSION: u8 = 2;
49
50fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
51    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
52    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
53    let physical = mirroring.physical_bank(table);
54    physical * NAMETABLE_SIZE + local
55}
56
57/// Mapper 34 variant.
58#[derive(Debug, Clone, Copy)]
59pub enum M34Variant {
60    /// BNROM: PRG-bank-only, no CHR banking.
61    Bnrom,
62    /// NINA-001: PRG bank @ $7FFD, CHR banks @ $7FFE / $7FFF.
63    Nina001,
64}
65
66/// Mapper 34 (BNROM / NINA-001).
67pub struct M34 {
68    prg_rom: Box<[u8]>,
69    chr: Box<[u8]>,
70    chr_is_ram: bool,
71    vram: Box<[u8]>,
72    prg_ram: Box<[u8]>,
73    prg_bank: u8,
74    chr_bank_lo: u8,
75    chr_bank_hi: u8,
76    variant: M34Variant,
77    mirroring: Mirroring,
78}
79
80impl M34 {
81    /// Construct a new M34 mapper.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`MapperError::Invalid`] on size mismatch.
86    pub fn new(
87        prg_rom: Box<[u8]>,
88        chr_rom: Box<[u8]>,
89        mirroring: Mirroring,
90        variant: M34Variant,
91    ) -> Result<Self, MapperError> {
92        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(32 * 1024) {
93            return Err(MapperError::Invalid(format!(
94                "Mapper 34 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
95                prg_rom.len()
96            )));
97        }
98        let chr_is_ram = chr_rom.is_empty();
99        let chr: Box<[u8]> = if chr_is_ram {
100            vec![0u8; CHR_BANK_8K].into_boxed_slice()
101        } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
102            chr_rom
103        } else {
104            return Err(MapperError::Invalid(format!(
105                "Mapper 34 CHR-ROM size {} is not a multiple of 4 KiB",
106                chr_rom.len()
107            )));
108        };
109        Ok(Self {
110            prg_rom,
111            chr,
112            chr_is_ram,
113            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
114            prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
115            prg_bank: 0,
116            chr_bank_lo: 0,
117            chr_bank_hi: 0,
118            variant,
119            mirroring,
120        })
121    }
122}
123
124impl Mapper for M34 {
125    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_034: "Fixed V" (BNROM and NINA-001 alike)`, so a
126    /// per-game database correction of a wrong header bit is safe here.
127    fn has_hardwired_mirroring(&self) -> bool {
128        true
129    }
130
131    fn sram(&self) -> &[u8] {
132        &self.prg_ram
133    }
134    fn sram_mut(&mut self) -> &mut [u8] {
135        &mut self.prg_ram
136    }
137    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
138    // skips all four per-CPU-cycle dispatches for this board.
139    fn caps(&self) -> MapperCaps {
140        MapperCaps::NONE
141    }
142
143    fn cpu_read(&mut self, addr: u16) -> u8 {
144        match addr {
145            0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
146            0x8000..=0xFFFF => {
147                let total_32k = (self.prg_rom.len() / (32 * 1024)).max(1);
148                let bank = (self.prg_bank as usize) % total_32k;
149                self.prg_rom[(bank * 32 * 1024 + (addr as usize - 0x8000)) % self.prg_rom.len()]
150            }
151            _ => 0,
152        }
153    }
154
155    fn cpu_write(&mut self, addr: u16, value: u8) {
156        match (self.variant, addr) {
157            (M34Variant::Nina001, 0x7FFD) => self.prg_bank = value & 0x01,
158            (M34Variant::Nina001, 0x7FFE) => self.chr_bank_lo = value & 0x0F,
159            (M34Variant::Nina001, 0x7FFF) => self.chr_bank_hi = value & 0x0F,
160            (_, 0x6000..=0x7FFF) => {
161                let off = (addr - 0x6000) as usize % self.prg_ram.len();
162                self.prg_ram[off] = value;
163            }
164            (M34Variant::Bnrom, 0x8000..=0xFFFF) => self.prg_bank = value,
165            _ => {}
166        }
167    }
168
169    fn ppu_read(&mut self, addr: u16) -> u8 {
170        let addr = addr & 0x3FFF;
171        match (addr, self.variant) {
172            (0x0000..=0x0FFF, M34Variant::Nina001) => {
173                let total_4k = (self.chr.len() / CHR_BANK_4K).max(1);
174                let bank = (self.chr_bank_lo as usize) % total_4k;
175                self.chr[(bank * CHR_BANK_4K + addr as usize) % self.chr.len()]
176            }
177            (0x1000..=0x1FFF, M34Variant::Nina001) => {
178                let total_4k = (self.chr.len() / CHR_BANK_4K).max(1);
179                let bank = (self.chr_bank_hi as usize) % total_4k;
180                self.chr[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr.len()]
181            }
182            (0x0000..=0x1FFF, _) => self.chr[addr as usize % self.chr.len()],
183            (0x2000..=0x3EFF, _) => {
184                self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()]
185            }
186            _ => 0,
187        }
188    }
189
190    fn ppu_write(&mut self, addr: u16, value: u8) {
191        let addr = addr & 0x3FFF;
192        match addr {
193            0x0000..=0x1FFF => {
194                if self.chr_is_ram {
195                    let len = self.chr.len();
196                    self.chr[addr as usize % len] = value;
197                }
198            }
199            0x2000..=0x3EFF => {
200                let off = nametable_offset(addr, self.mirroring) % self.vram.len();
201                self.vram[off] = value;
202            }
203            _ => {}
204        }
205    }
206
207    fn current_mirroring(&self) -> Mirroring {
208        self.mirroring
209    }
210
211    fn save_state(&self) -> Vec<u8> {
212        let mut out =
213            Vec::with_capacity(8 + self.prg_ram.len() + self.vram.len() + self.ram_block_len());
214        out.push(M34_SECTION_VERSION);
215        out.push(self.prg_bank);
216        out.push(self.chr_bank_lo);
217        out.push(self.chr_bank_hi);
218        out.push(match self.variant {
219            M34Variant::Bnrom => 0,
220            M34Variant::Nina001 => 1,
221        });
222        out.extend_from_slice(&self.prg_ram);
223        out.extend_from_slice(&self.vram);
224        // --- v2 tail: the CHR-RAM, if any (see `M34_SECTION_VERSION`) ---
225        if self.chr_is_ram {
226            out.extend_from_slice(&self.chr);
227        }
228        out
229    }
230
231    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
232        let version = data.first().copied().unwrap_or(0);
233        // Only the current layout is read (v2.9.8, ADR 0042). A v1 blob, which
234        // stopped before the CHR-RAM block, is refused rather than loaded with the
235        // CHR-RAM left as it was.
236        if version != M34_SECTION_VERSION {
237            return Err(MapperError::UnsupportedVersion(version));
238        }
239        let ram_len = self.ram_block_len();
240        // The whole length is validated before the first field is written.
241        let core_len = 5 + self.prg_ram.len() + self.vram.len();
242        let expected = core_len + ram_len;
243        if data.len() != expected {
244            return Err(MapperError::WrongLength {
245                expected,
246                got: data.len(),
247            });
248        }
249        self.prg_bank = data[1];
250        self.chr_bank_lo = data[2];
251        self.chr_bank_hi = data[3];
252        self.variant = match data[4] {
253            0 => M34Variant::Bnrom,
254            1 => M34Variant::Nina001,
255            other => return Err(MapperError::Invalid(format!("variant {other}"))),
256        };
257        let mut cur = 5usize;
258        self.prg_ram
259            .copy_from_slice(&data[cur..cur + self.prg_ram.len()]);
260        cur += self.prg_ram.len();
261        self.vram.copy_from_slice(&data[cur..core_len]);
262        if self.chr_is_ram {
263            self.chr.copy_from_slice(&data[core_len..]);
264        }
265        Ok(())
266    }
267}
268
269impl M34 {
270    /// Bytes the v2 tail adds: the 8 KiB CHR-RAM when the cartridge has no
271    /// CHR-ROM (every BNROM board), else nothing. Derived from the loaded ROM,
272    /// so a save and its load (same ROM, checked by the `.rns` hash tag) agree.
273    fn ram_block_len(&self) -> usize {
274        if self.chr_is_ram { self.chr.len() } else { 0 }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    const PRG_BANK_8K: usize = 0x2000;
283
284    fn synth(banks_8k: usize) -> Box<[u8]> {
285        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
286        for b in 0..banks_8k {
287            v[b * PRG_BANK_8K] = b as u8;
288        }
289        v.into_boxed_slice()
290    }
291
292    fn synth_chr_4k(banks: usize) -> Box<[u8]> {
293        let mut v = vec![0u8; banks * CHR_BANK_4K];
294        for b in 0..banks {
295            v[b * CHR_BANK_4K] = b as u8;
296        }
297        v.into_boxed_slice()
298    }
299
300    #[test]
301    fn m34_bnrom_swap() {
302        let mut m = M34::new(
303            synth(8),
304            Box::new([]),
305            Mirroring::Vertical,
306            M34Variant::Bnrom,
307        )
308        .unwrap();
309        // Default bank 0; $8000 -> 0.
310        assert_eq!(m.cpu_read(0x8000), 0);
311        // Test write with conflict; 32K banks here means bank index 1 -> byte at offset 32K = bank 4 of 8K banks.
312        m.cpu_write(0x8000, 1);
313        // Bank 1 in 32K terms = offset 32K. PRG[32768] = byte 4 of synth(8) = 4.
314        assert_eq!(m.cpu_read(0x8000), 4);
315    }
316
317    #[test]
318    fn m34_nina001_variant_register_layout() {
319        // T-74-001 (Phase 7): NINA-001 (mapper 34 submapper 1) uses a distinct
320        // register layout from BNROM — PRG bank at $7FFD, CHR lo/hi at
321        // $7FFE/$7FFF — and must NOT respond to BNROM's $8000 PRG-bank write.
322        let mut m = M34::new(
323            synth(8),
324            synth_chr_4k(8),
325            Mirroring::Vertical,
326            M34Variant::Nina001,
327        )
328        .unwrap();
329        // PRG bank via $7FFD (1-bit). Bank 1 = 32K offset = 8K-bank 4 = byte 4.
330        m.cpu_write(0x7FFD, 1);
331        assert_eq!(m.cpu_read(0x8000), 4, "NINA-001 PRG bank selects via $7FFD");
332        // A BNROM-style $8000 write must be ignored on NINA-001.
333        m.cpu_write(0x8000, 0);
334        assert_eq!(m.cpu_read(0x8000), 4, "$8000 write is ignored on NINA-001");
335        // CHR lo/hi banks via $7FFE / $7FFF (each tagged with its index byte).
336        m.cpu_write(0x7FFE, 2);
337        assert_eq!(m.ppu_read(0x0000), 2, "NINA-001 CHR lo bank via $7FFE");
338        m.cpu_write(0x7FFF, 3);
339        assert_eq!(m.ppu_read(0x1000), 3, "NINA-001 CHR hi bank via $7FFF");
340    }
341
342    /// v2.9.2 cartridge-RAM sweep: the section carries the 8 KiB CHR-RAM of
343    /// a board with no CHR-ROM. The whole-machine pin is
344    /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
345    #[test]
346    fn m34_save_state_carries_chr_ram() {
347        let mut m = M34::new(
348            synth(8),
349            Box::new([]),
350            Mirroring::Vertical,
351            M34Variant::Bnrom,
352        )
353        .unwrap();
354        m.chr[0x0000] = 0x11;
355        m.chr[0x1FFF] = 0x22;
356        let blob = m.save_state();
357        let mut m2 = M34::new(
358            synth(8),
359            Box::new([]),
360            Mirroring::Vertical,
361            M34Variant::Bnrom,
362        )
363        .unwrap();
364        m2.load_state(&blob).expect("round-trip");
365        assert_eq!(m2.chr[0x0000], 0x11);
366        assert_eq!(m2.chr[0x1FFF], 0x22);
367    }
368
369    /// v2.9.8 (ADR 0042): a v1 blob (no RAM tail, written through v2.9.1)
370    /// is refused. Until then it loaded and left the RAM as it was.
371    #[test]
372    fn m34_v1_blob_is_refused() {
373        let mut m = M34::new(
374            synth(8),
375            Box::new([]),
376            Mirroring::Vertical,
377            M34Variant::Bnrom,
378        )
379        .unwrap();
380        m.prg_bank = 1;
381        let core_len = 5 + m.prg_ram.len() + m.vram.len();
382        let mut v1 = m.save_state()[..core_len].to_vec();
383        v1[0] = 1;
384        let mut m2 = M34::new(
385            synth(8),
386            Box::new([]),
387            Mirroring::Vertical,
388            M34Variant::Bnrom,
389        )
390        .unwrap();
391        assert!(matches!(
392            m2.load_state(&v1),
393            Err(MapperError::UnsupportedVersion(1))
394        ));
395    }
396
397    /// A v2 blob one byte short (inside the CHR-RAM tail) is rejected.
398    #[test]
399    fn m34_truncated_chr_ram_tail_is_rejected() {
400        let m = M34::new(
401            synth(8),
402            Box::new([]),
403            Mirroring::Vertical,
404            M34Variant::Bnrom,
405        )
406        .unwrap();
407        let blob = m.save_state();
408        let mut m2 = M34::new(
409            synth(8),
410            Box::new([]),
411            Mirroring::Vertical,
412            M34Variant::Bnrom,
413        )
414        .unwrap();
415        let err = m2
416            .load_state(&blob[..blob.len() - 1])
417            .expect_err("a truncated v2 blob must be rejected");
418        assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
419    }
420}