Skip to main content

rustynes_mappers/
m039_subor39.rs

1//! Subor / Study & Game 32-in-1 (mapper 39).
2//!
3//! About as simple as a board gets: the whole written byte selects a 32 KiB
4//! PRG bank, with CHR-RAM and hardwired mirroring. No masking, no bus
5//! conflict, no IRQ.
6//!
7//! A best-effort (Tier-2) board: register-decode correctness verified against
8//! the `GeraNES` reference emulator (cross-referenced, not copied)
9//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
10//! is direct slice indexing and every bank select wraps with `% count`, so a
11//! register write can never index out of bounds -- required for the `#![no_std]`
12//! chip stack, which cannot afford a panic on a register access.
13//!
14//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
15//! and `docs/mappers.md` §Mapper coverage matrix.
16
17use crate::cartridge::Mirroring;
18use crate::mapper::{Mapper, MapperCaps, MapperError};
19use alloc::{boxed::Box, vec::Vec};
20use alloc::{format, vec};
21
22const PRG_BANK_32K: usize = 0x8000;
23const CHR_BANK_8K: usize = 0x2000;
24const NAMETABLE_SIZE: usize = 0x0400;
25const NAMETABLE_SIZE_U16: u16 = 0x0400;
26
27const SAVE_STATE_VERSION: u8 = 1;
28
29// ---------------------------------------------------------------------------
30// Shared nametable helper (mirrors the one in the other simple-mapper modules).
31// ---------------------------------------------------------------------------
32
33const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
34    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
35    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
36    let physical = mirroring.physical_bank(table);
37    physical * NAMETABLE_SIZE + local
38}
39
40/// Mapper 39 (Subor `BNROM`-like, 32 KiB PRG).
41pub struct Subor39 {
42    prg_rom: Box<[u8]>,
43    chr: Box<[u8]>,
44    vram: Box<[u8]>,
45    chr_is_ram: bool,
46    prg_bank: u8,
47    mirroring: Mirroring,
48}
49
50impl Subor39 {
51    /// Construct a new mapper 39 board.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
56    /// 32 KiB, or CHR-ROM (when present) is not a multiple of 8 KiB.
57    pub fn new(
58        prg_rom: Box<[u8]>,
59        chr_rom: Box<[u8]>,
60        mirroring: Mirroring,
61    ) -> Result<Self, MapperError> {
62        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
63            return Err(MapperError::Invalid(format!(
64                "mapper 39 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
65                prg_rom.len()
66            )));
67        }
68        let chr_is_ram = chr_rom.is_empty();
69        let chr: Box<[u8]> = if chr_is_ram {
70            vec![0u8; CHR_BANK_8K].into_boxed_slice()
71        } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
72            chr_rom
73        } else {
74            return Err(MapperError::Invalid(format!(
75                "mapper 39 CHR-ROM size {} is not a multiple of 8 KiB",
76                chr_rom.len()
77            )));
78        };
79        Ok(Self {
80            prg_rom,
81            chr,
82            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
83            chr_is_ram,
84            prg_bank: 0,
85            mirroring,
86        })
87    }
88}
89
90impl Mapper for Subor39 {
91    fn caps(&self) -> MapperCaps {
92        MapperCaps::NONE
93    }
94
95    fn cpu_read(&mut self, addr: u16) -> u8 {
96        if (0x8000..=0xFFFF).contains(&addr) {
97            let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
98            let bank = (self.prg_bank as usize) % count;
99            self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
100        } else {
101            0
102        }
103    }
104
105    fn cpu_write(&mut self, addr: u16, value: u8) {
106        if (0x8000..=0xFFFF).contains(&addr) {
107            self.prg_bank = value;
108        }
109    }
110
111    fn ppu_read(&mut self, addr: u16) -> u8 {
112        let addr = addr & 0x3FFF;
113        match addr {
114            // CHR is fixed to bank 0 (8 KiB).
115            0x0000..=0x1FFF => self.chr[addr as usize],
116            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
117            _ => 0,
118        }
119    }
120
121    fn ppu_write(&mut self, addr: u16, value: u8) {
122        let addr = addr & 0x3FFF;
123        match addr {
124            0x0000..=0x1FFF => {
125                if self.chr_is_ram {
126                    self.chr[addr as usize] = value;
127                }
128            }
129            0x2000..=0x3EFF => {
130                let off = nametable_offset(addr, self.mirroring);
131                self.vram[off] = value;
132            }
133            _ => {}
134        }
135    }
136
137    fn current_mirroring(&self) -> Mirroring {
138        self.mirroring
139    }
140
141    fn save_state(&self) -> Vec<u8> {
142        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
143        let mut out = Vec::with_capacity(2 + self.vram.len() + chr_extra);
144        out.push(SAVE_STATE_VERSION);
145        out.push(self.prg_bank);
146        out.extend_from_slice(&self.vram);
147        if self.chr_is_ram {
148            out.extend_from_slice(&self.chr);
149        }
150        out
151    }
152
153    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
154        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
155        let expected = 2 + self.vram.len() + chr_extra;
156        if data.len() != expected {
157            return Err(MapperError::WrongLength {
158                expected,
159                got: data.len(),
160            });
161        }
162        if data[0] != SAVE_STATE_VERSION {
163            return Err(MapperError::UnsupportedVersion(data[0]));
164        }
165        self.prg_bank = data[1];
166        let mut cursor = 2;
167        self.vram
168            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
169        cursor += self.vram.len();
170        if self.chr_is_ram {
171            self.chr
172                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
173        }
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179#[allow(clippy::cast_possible_truncation)]
180mod tests {
181    use super::*;
182
183    fn synth_prg_32k(banks: usize) -> Box<[u8]> {
184        let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
185        for b in 0..banks {
186            v[b * PRG_BANK_32K] = b as u8;
187        }
188        v.into_boxed_slice()
189    }
190
191    #[test]
192    fn m39_full_byte_selects_32k() {
193        let mut m = Subor39::new(synth_prg_32k(4), Box::new([]), Mirroring::Vertical).unwrap();
194        m.cpu_write(0x8000, 2);
195        assert_eq!(m.cpu_read(0x8000), 2);
196        // No bus conflict: value sticks regardless of underlying byte.
197        m.cpu_write(0xFFFF, 1);
198        assert_eq!(m.cpu_read(0x8000), 1);
199    }
200}