Skip to main content

rustynes_mappers/
m184_sunsoft1.rs

1//! Sunsoft-1 (iNES mapper 184) implementation.
2//!
3//! An early Sunsoft discrete board (Atlantis no Nazo, Kid Niki / Wanpaku
4//! Kokkun no Gourmet World, The Wing of Madoola, Fuun Shaolin Kyo). PRG-ROM is
5//! fixed (NROM-style 16/32 KiB). A single register in the `$6000-$7FFF` window
6//! switches **two 4 KiB CHR banks** — one at PPU `$0000`, one at `$1000`:
7//!
8//! ```text
9//!   $6000-$7FFF [.HHH .LLL]  L = CHR 4 KiB bank @ $0000 (bits 0-2)
10//!                            H = CHR 4 KiB bank @ $1000 (bits 4-6)
11//! ```
12//!
13//! The high 4 KiB bank's selectable range is board-specific; the common decode
14//! used by every licensed game treats it as a 3-bit field `(v >> 4) & 7`.
15//!
16//! There is **no IRQ**. Mirroring is fixed from the iNES header.
17//!
18//! See `docs/mappers.md` §Mapper coverage matrix.
19
20#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
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_4K: usize = 0x1000;
30const NAMETABLE_SIZE: usize = 0x0400;
31const NAMETABLE_SIZE_U16: u16 = 0x0400;
32
33const SAVE_STATE_VERSION: u8 = 1;
34
35/// Sunsoft-1 mapper (iNES mapper 184).
36pub struct Sunsoft1 {
37    prg_rom: Box<[u8]>,
38    chr_rom: Box<[u8]>,
39    vram: Box<[u8]>,
40    /// CHR 4 KiB bank at $0000 and at $1000.
41    chr_bank: [u8; 2],
42    mirroring: Mirroring,
43}
44
45impl Sunsoft1 {
46    /// Construct a new Sunsoft-1 (mapper 184) board.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`MapperError::Invalid`] when the PRG-ROM is not 16/32 KiB or
51    /// CHR-ROM is empty / not a multiple of 4 KiB.
52    pub fn new(
53        prg_rom: Box<[u8]>,
54        chr_rom: Box<[u8]>,
55        mirroring: Mirroring,
56    ) -> Result<Self, MapperError> {
57        if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
58            return Err(MapperError::Invalid(format!(
59                "Sunsoft-1 expects 16 or 32 KiB PRG, got {} bytes",
60                prg_rom.len()
61            )));
62        }
63        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_4K) {
64            return Err(MapperError::Invalid(format!(
65                "Sunsoft-1 expects non-empty CHR-ROM in 4 KiB units, got {} bytes",
66                chr_rom.len()
67            )));
68        }
69        Ok(Self {
70            prg_rom,
71            chr_rom,
72            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
73            chr_bank: [0, 0],
74            mirroring,
75        })
76    }
77
78    const fn nametable_offset(&self, addr: u16) -> usize {
79        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
80        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
81        let physical = self.mirroring.physical_bank(table);
82        physical * NAMETABLE_SIZE + local
83    }
84
85    fn read_prg(&self, addr: u16) -> u8 {
86        let off = (addr - 0x8000) as usize;
87        if self.prg_rom.len() == PRG_BANK_16K {
88            self.prg_rom[off & (PRG_BANK_16K - 1)]
89        } else {
90            self.prg_rom[off]
91        }
92    }
93
94    fn chr_offset(&self, addr: u16) -> usize {
95        let len = self.chr_rom.len().max(1);
96        let half = (addr >> 12) & 0x01; // 0 = $0000, 1 = $1000
97        let bank = (self.chr_bank[half as usize] as usize) * CHR_BANK_4K;
98        (bank + (addr as usize & (CHR_BANK_4K - 1))) % len
99    }
100}
101
102impl Mapper for Sunsoft1 {
103    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_184: the board has no mirroring control`, so a
104    /// per-game database correction of a wrong header bit is safe here.
105    fn has_hardwired_mirroring(&self) -> bool {
106        true
107    }
108
109    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
110    // skips all four per-CPU-cycle dispatches for this board.
111    fn caps(&self) -> MapperCaps {
112        MapperCaps::NONE
113    }
114
115    fn cpu_read(&mut self, addr: u16) -> u8 {
116        if (0x8000..=0xFFFF).contains(&addr) {
117            self.read_prg(addr)
118        } else {
119            0
120        }
121    }
122
123    fn cpu_write(&mut self, addr: u16, value: u8) {
124        if (0x6000..=0x7FFF).contains(&addr) {
125            self.chr_bank[0] = value & 0x07;
126            self.chr_bank[1] = (value >> 4) & 0x07;
127        }
128    }
129
130    fn ppu_read(&mut self, addr: u16) -> u8 {
131        let addr = addr & 0x3FFF;
132        match addr {
133            0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
134            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
135            _ => 0,
136        }
137    }
138
139    fn ppu_write(&mut self, addr: u16, value: u8) {
140        let addr = addr & 0x3FFF;
141        if let 0x2000..=0x3EFF = addr {
142            let off = self.nametable_offset(addr);
143            self.vram[off] = value;
144        }
145        // CHR-ROM writes ignored.
146    }
147
148    fn current_mirroring(&self) -> Mirroring {
149        self.mirroring
150    }
151
152    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
153        let mut info = crate::mapper::MapperDebugInfo {
154            mapper_id: 184,
155            name: "Sunsoft-1 (184)".into(),
156            mirroring: crate::mapper::mirroring_name(self.mirroring),
157            ..Default::default()
158        };
159        info.chr_banks
160            .push(("CHR4k@0".into(), format!("{:#04x}", self.chr_bank[0])));
161        info.chr_banks
162            .push(("CHR4k@1".into(), format!("{:#04x}", self.chr_bank[1])));
163        info
164    }
165
166    fn save_state(&self) -> Vec<u8> {
167        let mut out = Vec::with_capacity(3 + self.vram.len());
168        out.push(SAVE_STATE_VERSION);
169        out.extend_from_slice(&self.chr_bank);
170        out.extend_from_slice(&self.vram);
171        out
172    }
173
174    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
175        let expected = 3 + self.vram.len();
176        if data.len() != expected {
177            return Err(MapperError::WrongLength {
178                expected,
179                got: data.len(),
180            });
181        }
182        if data[0] != SAVE_STATE_VERSION {
183            return Err(MapperError::UnsupportedVersion(data[0]));
184        }
185        self.chr_bank.copy_from_slice(&data[1..3]);
186        self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
187        Ok(())
188    }
189}
190
191#[cfg(test)]
192#[allow(clippy::cast_possible_truncation)]
193mod tests {
194    use super::*;
195
196    fn synth_prg(bytes: usize) -> Box<[u8]> {
197        let mut v = vec![0u8; bytes];
198        for (i, b) in v.iter_mut().enumerate() {
199            *b = (i & 0xFF) as u8;
200        }
201        v.into_boxed_slice()
202    }
203
204    fn synth_chr_4k(banks: usize) -> Box<[u8]> {
205        let mut v = vec![0u8; banks * CHR_BANK_4K];
206        for b in 0..banks {
207            v[b * CHR_BANK_4K] = b as u8;
208        }
209        v.into_boxed_slice()
210    }
211
212    #[test]
213    fn two_4k_chr_banks() {
214        let mut m = Sunsoft1::new(
215            synth_prg(PRG_BANK_32K),
216            synth_chr_4k(8),
217            Mirroring::Vertical,
218        )
219        .unwrap();
220        // low = bits 0-2, high = bits 4-6. value 0b0101_0011 -> low=3, high=5.
221        m.cpu_write(0x6000, 0b0101_0011);
222        assert_eq!(m.ppu_read(0x0000), 3);
223        assert_eq!(m.ppu_read(0x1000), 5);
224    }
225
226    #[test]
227    fn write_outside_window_ignored() {
228        let mut m = Sunsoft1::new(
229            synth_prg(PRG_BANK_32K),
230            synth_chr_4k(8),
231            Mirroring::Vertical,
232        )
233        .unwrap();
234        m.cpu_write(0x6000, 0b0010_0001); // low=1, high=2
235        assert_eq!(m.ppu_read(0x0000), 1);
236        assert_eq!(m.ppu_read(0x1000), 2);
237        m.cpu_write(0x8000, 0b0111_0111); // ROM write — must not change banks
238        assert_eq!(m.ppu_read(0x0000), 1);
239        assert_eq!(m.ppu_read(0x1000), 2);
240    }
241
242    #[test]
243    fn prg_16k_mirrors() {
244        let mut m = Sunsoft1::new(
245            synth_prg(PRG_BANK_16K),
246            synth_chr_4k(4),
247            Mirroring::Vertical,
248        )
249        .unwrap();
250        assert_eq!(m.cpu_read(0x8000), m.cpu_read(0xC000));
251    }
252
253    #[test]
254    fn save_state_round_trip() {
255        let mut m = Sunsoft1::new(
256            synth_prg(PRG_BANK_32K),
257            synth_chr_4k(8),
258            Mirroring::Vertical,
259        )
260        .unwrap();
261        m.cpu_write(0x6000, 0b0110_0010);
262        let blob = m.save_state();
263        let mut m2 = Sunsoft1::new(
264            synth_prg(PRG_BANK_32K),
265            synth_chr_4k(8),
266            Mirroring::Vertical,
267        )
268        .unwrap();
269        m2.load_state(&blob).unwrap();
270        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
271        assert_eq!(m.ppu_read(0x1000), m2.ppu_read(0x1000));
272    }
273}