Skip to main content

rustynes_mappers/
m089_sunsoft2.rs

1//! Sunsoft-2 IC on the Sunsoft-3 board (iNES mapper 89).
2//!
3//! The one-screen-mirroring, CHR-ROM-banking variant of the Sunsoft-2 IC
4//! (Tenka no Goikenban: Mito Koumon). A single `$8000-$FFFF` write register
5//! switches a 16 KiB PRG bank at `$8000-$BFFF`, an 8 KiB CHR bank, and the
6//! one-screen mirroring select. The last 16 KiB PRG bank is fixed at
7//! `$C000-$FFFF`.
8//!
9//! Register (nesdev `INES_Mapper_089.xhtml`), `$8000-$FFFF`:
10//!
11//! ```text
12//!   [CPPP MCCC]  C = CHR 8 KiB bank high bit / A16 (bit 7)
13//!               PPP = PRG 16 KiB bank (bits 4-6)
14//!               M = one-screen mirroring select (bit 3: 0 = A, 1 = B)
15//!               CCC = CHR 8 KiB bank low 3 bits (bits 0-2)
16//!                   -> chr bank = ((v >> 7) & 1) << 3 | (v & 7)
17//! ```
18//!
19//! There is **no IRQ**.
20//!
21//! See `docs/mappers.md` §Mapper coverage matrix.
22
23#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
24
25use crate::cartridge::Mirroring;
26use crate::mapper::{Mapper, MapperCaps, MapperError};
27use alloc::{boxed::Box, vec::Vec};
28use alloc::{format, vec};
29
30const PRG_BANK_16K: usize = 0x4000;
31const CHR_BANK_8K: usize = 0x2000;
32const NAMETABLE_SIZE: usize = 0x0400;
33const NAMETABLE_SIZE_U16: u16 = 0x0400;
34
35const SAVE_STATE_VERSION: u8 = 1;
36
37/// Sunsoft-2 IC (iNES mapper 89).
38pub struct Sunsoft2 {
39    prg_rom: Box<[u8]>,
40    chr: Box<[u8]>,
41    vram: Box<[u8]>,
42    chr_is_ram: bool,
43    prg_bank: u8,
44    chr_bank: u8,
45    mirroring: Mirroring,
46}
47
48impl Sunsoft2 {
49    /// Construct a new Sunsoft-2 (mapper 89) board.
50    ///
51    /// `prg_rom` must be a non-zero multiple of 16 KiB. CHR-RAM is selected when
52    /// `chr_rom` is empty; otherwise CHR-ROM length must be a multiple of 8 KiB.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`MapperError::Invalid`] when sizes don't match the constraints.
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_16K) {
63            return Err(MapperError::Invalid(format!(
64                "Sunsoft-2 PRG-ROM size {} is not a non-zero multiple of 16 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                "Sunsoft-2 expects an 8 KiB multiple of CHR; got {} bytes",
76                chr_rom.len()
77            )));
78        };
79        // Power-on mirroring select defaults to single-screen A; the header
80        // arrangement is ignored on this one-screen board.
81        let _ = mirroring;
82        Ok(Self {
83            prg_rom,
84            chr,
85            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
86            chr_is_ram,
87            prg_bank: 0,
88            chr_bank: 0,
89            mirroring: Mirroring::SingleScreenA,
90        })
91    }
92
93    const fn nametable_offset(&self, addr: u16) -> usize {
94        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
95        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
96        let physical = self.mirroring.physical_bank(table);
97        physical * NAMETABLE_SIZE + local
98    }
99
100    fn read_prg(&self, addr: u16) -> u8 {
101        let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
102        let bank = if addr < 0xC000 {
103            (self.prg_bank as usize) % bank_count
104        } else {
105            bank_count - 1 // fixed last 16 KiB
106        };
107        let off = (addr as usize) & (PRG_BANK_16K - 1);
108        self.prg_rom[bank * PRG_BANK_16K + off]
109    }
110
111    fn chr_offset(&self, addr: u16) -> usize {
112        let len = self.chr.len().max(1);
113        let bank = (self.chr_bank as usize) * CHR_BANK_8K;
114        (bank + (addr as usize)) % len
115    }
116}
117
118impl Mapper for Sunsoft2 {
119    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
120    // skips all four per-CPU-cycle dispatches for this board.
121    fn caps(&self) -> MapperCaps {
122        MapperCaps::NONE
123    }
124
125    fn cpu_read(&mut self, addr: u16) -> u8 {
126        if (0x8000..=0xFFFF).contains(&addr) {
127            self.read_prg(addr)
128        } else {
129            0
130        }
131    }
132
133    fn cpu_write(&mut self, addr: u16, value: u8) {
134        if (0x8000..=0xFFFF).contains(&addr) {
135            // The Sunsoft-2 board has **bus conflicts** (nesdev INES_Mapper_089
136            // marks the register "BUS CONFLICTS"; the AND-with-ROM-byte masking
137            // was also cross-referenced against the `GeraNES` reference
138            // emulator — documented behavior, independently expressed here, no
139            // code copied). The register shares the address
140            // space with PRG-ROM, so a store drives the written byte ANDed with
141            // the ROM byte already present at that address. *Tenka no Goikenban:
142            // Mito Koumon* relies on this — without the mask the raw value
143            // selects the wrong CHR/PRG bank and the background nametable never
144            // latches. Decode every field from the masked value.
145            let value = value & self.read_prg(addr);
146            // Register layout `CPPP MCCC` (nesdev INES_Mapper_089): bit 7 is the
147            // CHR-bank high bit (A16), bits 4-6 the 16 KiB PRG bank, bit 3 the
148            // one-screen mirroring select, bits 0-2 the CHR-bank low 3 bits.
149            self.prg_bank = (value >> 4) & 0x07;
150            self.chr_bank = (((value >> 7) & 0x01) << 3) | (value & 0x07);
151            self.mirroring = if (value & 0x08) != 0 {
152                Mirroring::SingleScreenB
153            } else {
154                Mirroring::SingleScreenA
155            };
156        }
157    }
158
159    fn ppu_read(&mut self, addr: u16) -> u8 {
160        let addr = addr & 0x3FFF;
161        match addr {
162            0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
163            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
164            _ => 0,
165        }
166    }
167
168    fn ppu_write(&mut self, addr: u16, value: u8) {
169        let addr = addr & 0x3FFF;
170        match addr {
171            0x0000..=0x1FFF => {
172                if self.chr_is_ram {
173                    let off = self.chr_offset(addr);
174                    self.chr[off] = value;
175                }
176            }
177            0x2000..=0x3EFF => {
178                let off = self.nametable_offset(addr);
179                self.vram[off] = value;
180            }
181            _ => {}
182        }
183    }
184
185    fn current_mirroring(&self) -> Mirroring {
186        self.mirroring
187    }
188
189    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
190        let mut info = crate::mapper::MapperDebugInfo {
191            mapper_id: 89,
192            name: "Sunsoft-2 (89)".into(),
193            mirroring: crate::mapper::mirroring_name(self.mirroring),
194            ..Default::default()
195        };
196        info.prg_banks
197            .push(("PRG16k".into(), format!("{:#04x}", self.prg_bank)));
198        info.chr_banks
199            .push(("CHR8k".into(), format!("{:#04x}", self.chr_bank)));
200        info
201    }
202
203    fn save_state(&self) -> Vec<u8> {
204        let mut out = Vec::with_capacity(
205            4 + self.vram.len() + if self.chr_is_ram { self.chr.len() } else { 0 },
206        );
207        out.push(SAVE_STATE_VERSION);
208        out.push(self.prg_bank);
209        out.push(self.chr_bank);
210        out.push(match self.mirroring {
211            Mirroring::SingleScreenB => 1,
212            _ => 0,
213        });
214        out.extend_from_slice(&self.vram);
215        if self.chr_is_ram {
216            out.extend_from_slice(&self.chr);
217        }
218        out
219    }
220
221    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
222        let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 };
223        let expected = 4 + self.vram.len() + need_chr;
224        if data.len() != expected {
225            return Err(MapperError::WrongLength {
226                expected,
227                got: data.len(),
228            });
229        }
230        if data[0] != SAVE_STATE_VERSION {
231            return Err(MapperError::UnsupportedVersion(data[0]));
232        }
233        self.prg_bank = data[1];
234        self.chr_bank = data[2];
235        self.mirroring = if data[3] != 0 {
236            Mirroring::SingleScreenB
237        } else {
238            Mirroring::SingleScreenA
239        };
240        let mut cursor = 4;
241        self.vram
242            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
243        cursor += self.vram.len();
244        if self.chr_is_ram {
245            self.chr
246                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
247        }
248        Ok(())
249    }
250}
251
252#[cfg(test)]
253#[allow(clippy::cast_possible_truncation)]
254mod tests {
255    use super::*;
256
257    // PRG is filled with 0xFF (so a register write whose target byte is 0xFF is
258    // unmasked by the bus conflict — used by the decode tests), except offset 0
259    // of each 16 KiB bank holds the bank index as a readable marker.
260    fn synth_prg(banks_16k: usize) -> Box<[u8]> {
261        let mut v = vec![0xFFu8; banks_16k * PRG_BANK_16K];
262        for b in 0..banks_16k {
263            v[b * PRG_BANK_16K] = b as u8;
264        }
265        v.into_boxed_slice()
266    }
267
268    fn synth_chr(banks: usize) -> Box<[u8]> {
269        let mut v = vec![0u8; banks * CHR_BANK_8K];
270        for b in 0..banks {
271            v[b * CHR_BANK_8K] = b as u8;
272        }
273        v.into_boxed_slice()
274    }
275
276    #[test]
277    fn prg_bank_and_fixed_tail() {
278        let mut m = Sunsoft2::new(synth_prg(8), synth_chr(4), Mirroring::Vertical).unwrap();
279        // Default PRG bank 0 at $8000, fixed {-1}=7 at $C000.
280        assert_eq!(m.cpu_read(0x8000), 0);
281        assert_eq!(m.cpu_read(0xC000), 7);
282        // PRG bits 4-6: value 0x30 -> bank 3. Write at offset 1 (ROM byte 0xFF)
283        // so the bus conflict leaves the value unmasked.
284        m.cpu_write(0x8001, 0x30);
285        assert_eq!(m.cpu_read(0x8000), 3);
286        assert_eq!(m.cpu_read(0xC000), 7);
287    }
288
289    #[test]
290    fn chr_bank_combines_a16() {
291        let mut m = Sunsoft2::new(synth_prg(2), synth_chr(16), Mirroring::Vertical).unwrap();
292        // CHR high bit / A16 is value bit 7: 0b1000_0010 -> ((1)<<3)|(2) = 10.
293        // Write at offset 1 (ROM byte 0xFF) so the bus conflict is a no-op.
294        m.cpu_write(0x8001, 0b1000_0010);
295        assert_eq!(m.ppu_read(0x0000), 10);
296        // Low 3 only: 0b0000_0101 -> 5.
297        m.cpu_write(0x8001, 0b0000_0101);
298        assert_eq!(m.ppu_read(0x0000), 5);
299    }
300
301    #[test]
302    fn one_screen_mirroring_select() {
303        let mut m = Sunsoft2::new(synth_prg(2), synth_chr(4), Mirroring::Vertical).unwrap();
304        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
305        m.cpu_write(0x8001, 0x08); // bit 3 -> SingleScreenB (ROM byte 0xFF: unmasked)
306        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
307        m.cpu_write(0x8001, 0x00);
308        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
309    }
310
311    #[test]
312    fn bus_conflict_masks_write_with_prg_byte() {
313        // The Sunsoft-2 board has bus conflicts: a register write is ANDed with
314        // the PRG-ROM byte already present at the written address (the fix for
315        // *Tenka no Goikenban: Mito Koumon*'s empty background). Place a known
316        // mask byte in PRG and confirm the decoded banks reflect value & byte.
317        let mut prg = vec![0xFFu8; 8 * PRG_BANK_16K];
318        for b in 0..8 {
319            prg[b * PRG_BANK_16K] = b as u8;
320        }
321        // Bank 0 is selected at power-on, so $8000 (offset 0) holds 0x00 -> any
322        // write there masks to 0 (bank 0, CHR 0, mirroring A).
323        let mut m =
324            Sunsoft2::new(prg.into_boxed_slice(), synth_chr(16), Mirroring::Vertical).unwrap();
325        m.cpu_write(0x8000, 0xFF); // 0xFF & 0x00 == 0x00
326        assert_eq!(m.prg_bank, 0);
327        assert_eq!(m.chr_bank, 0);
328        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
329
330        // Put a partial mask 0b0011_1000 at $9000 and write 0b1111_1111 there:
331        // masked value 0b0011_1000 -> PRG bits 4-6 = 0b011 = 3, mirroring bit 3
332        // set -> SingleScreenB, CHR = 0.
333        let mut prg = vec![0xFFu8; 8 * PRG_BANK_16K];
334        prg[0x1000] = 0b0011_1000; // $9000 in bank 0
335        let mut m =
336            Sunsoft2::new(prg.into_boxed_slice(), synth_chr(16), Mirroring::Vertical).unwrap();
337        m.cpu_write(0x9000, 0xFF);
338        assert_eq!(m.prg_bank, 3, "PRG bank decoded from masked value");
339        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
340        assert_eq!(m.chr_bank, 0);
341    }
342
343    #[test]
344    fn save_state_round_trip() {
345        let mut m = Sunsoft2::new(synth_prg(8), synth_chr(16), Mirroring::Vertical).unwrap();
346        m.cpu_write(0x8001, 0b1011_1010);
347        let blob = m.save_state();
348        let mut m2 = Sunsoft2::new(synth_prg(8), synth_chr(16), Mirroring::Vertical).unwrap();
349        m2.load_state(&blob).unwrap();
350        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
351        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
352        assert_eq!(m.current_mirroring(), m2.current_mirroring());
353    }
354}