Skip to main content

rustynes_mappers/
m093_sunsoft3r.rs

1//! Sunsoft-3R / Sunsoft-2 IC (iNES mapper 93) implementation.
2//!
3//! The Sunsoft-2 IC on the Sunsoft-3R board (Shanghai, Fantasy Zone). A simple
4//! UxROM-style discrete-logic board: a single `$8000-$FFFF` write register
5//! `[.PPP ...E]` selects a 16 KiB switchable PRG bank at `$8000-$BFFF`
6//! (bits 4-6) and a CHR-RAM enable bit (bit 0). The last 16 KiB PRG bank is
7//! fixed at `$C000-$FFFF`. CHR is 8 KiB of RAM. Mirroring is fixed from the
8//! iNES header (the one-screen-mirroring / CHR-ROM-banking variant is mapper
9//! 89 on the Sunsoft-3 board).
10//!
11//! When the CHR-RAM enable bit is 0, CHR writes are ignored and reads are
12//! open bus (no licensed game uses this disabled mode); the IC powers up
13//! enabled.
14//!
15//! Register (nesdev `INES_Mapper_093.xhtml`, BUS CONFLICTS), `$8000-$FFFF`:
16//!
17//! ```text
18//!   [.PPP ...E]  P = PRG reg (16 KiB @ $8000)
19//!               E = CHR-RAM enable (0 = disabled, 1 = normal)
20//! ```
21//!
22//! See `docs/mappers.md` §Mapper coverage matrix.
23
24#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
25
26use crate::cartridge::Mirroring;
27use crate::mapper::{Mapper, MapperCaps, MapperError};
28use alloc::{boxed::Box, vec::Vec};
29use alloc::{format, vec};
30
31const PRG_BANK_16K: usize = 0x4000;
32const CHR_BANK_8K: usize = 0x2000;
33const NAMETABLE_SIZE: usize = 0x0400;
34const NAMETABLE_SIZE_U16: u16 = 0x0400;
35
36const SAVE_STATE_VERSION: u8 = 1;
37
38/// Sunsoft-3R / Sunsoft-2 IC mapper (iNES mapper 93).
39pub struct Sunsoft3r {
40    prg_rom: Box<[u8]>,
41    chr_ram: Box<[u8]>,
42    vram: Box<[u8]>,
43    prg_bank: u8,
44    chr_ram_enabled: bool,
45    mirroring: Mirroring,
46}
47
48impl Sunsoft3r {
49    /// Construct a new Sunsoft-3R mapper.
50    ///
51    /// `prg_rom` must be a non-zero multiple of 16 KiB. CHR is always 8 KiB of
52    /// RAM (any CHR-ROM in the header is ignored — this board is CHR-RAM only).
53    ///
54    /// # Errors
55    ///
56    /// Returns [`MapperError::Invalid`] when the PRG-ROM size is invalid.
57    pub fn new(prg_rom: Box<[u8]>, mirroring: Mirroring) -> Result<Self, MapperError> {
58        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
59            return Err(MapperError::Invalid(format!(
60                "Sunsoft-3R PRG-ROM size {} is not a non-zero multiple of 16 KiB",
61                prg_rom.len()
62            )));
63        }
64        Ok(Self {
65            prg_rom,
66            chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
67            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
68            prg_bank: 0,
69            chr_ram_enabled: true,
70            mirroring,
71        })
72    }
73
74    /// PRG-ROM read, shared by [`Mapper::cpu_read`] and the bus-conflict mask in
75    /// [`Mapper::cpu_write`] (which needs `&self`, not the trait's `&mut self`).
76    fn read_prg(&self, addr: u16) -> u8 {
77        let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
78        match addr {
79            0x8000..=0xBFFF => {
80                let bank = (self.prg_bank as usize) % bank_count;
81                let off = (addr - 0x8000) as usize;
82                self.prg_rom[bank * PRG_BANK_16K + off]
83            }
84            0xC000..=0xFFFF => {
85                // `.max(1)` above makes this subtraction safe for any accepted image.
86                let last = bank_count - 1;
87                let off = (addr - 0xC000) as usize;
88                self.prg_rom[last * PRG_BANK_16K + off]
89            }
90            _ => 0,
91        }
92    }
93
94    const fn nametable_offset(&self, addr: u16) -> usize {
95        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
96        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
97        let physical = self.mirroring.physical_bank(table);
98        physical * NAMETABLE_SIZE + local
99    }
100}
101
102impl Mapper for Sunsoft3r {
103    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
104    // skips all four per-CPU-cycle dispatches for this board.
105    fn caps(&self) -> MapperCaps {
106        MapperCaps::NONE
107    }
108
109    fn cpu_read(&mut self, addr: u16) -> u8 {
110        self.read_prg(addr)
111    }
112
113    fn cpu_write(&mut self, addr: u16, value: u8) {
114        if let 0x8000..=0xFFFF = addr {
115            // The Sunsoft-3R board has **bus conflicts** — this module's own
116            // header already cites nesdev `INES_Mapper_093.xhtml` "BUS
117            // CONFLICTS", but the mask was missing, so the doc and the code
118            // disagreed. The register shares the address space with PRG-ROM, so
119            // a store drives the written byte ANDed with the ROM byte already at
120            // that address. Same treatment as the sibling Sunsoft-2 board in
121            // `m089_sunsoft2.rs`. The AND-with-ROM-byte masking is the
122            // nesdev-documented bus-conflict behavior and was cross-referenced
123            // against the `GeraNES` reference emulator; the Rust below is an
124            // independent expression of that documented behavior (no code
125            // copied).
126            // Decode every field from the masked value.
127            let value = value & self.read_prg(addr);
128            // [.PPP ...E]: bits 4-6 = 16K PRG bank, bit 0 = CHR-RAM enable.
129            self.prg_bank = (value >> 4) & 0x07;
130            self.chr_ram_enabled = (value & 0x01) != 0;
131        }
132    }
133
134    fn ppu_read(&mut self, addr: u16) -> u8 {
135        let addr = addr & 0x3FFF;
136        match addr {
137            0x0000..=0x1FFF => {
138                if self.chr_ram_enabled {
139                    self.chr_ram[addr as usize]
140                } else {
141                    // CHR disabled: reads are open bus (0 here).
142                    0
143                }
144            }
145            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
146            _ => 0,
147        }
148    }
149
150    fn ppu_write(&mut self, addr: u16, value: u8) {
151        let addr = addr & 0x3FFF;
152        match addr {
153            0x0000..=0x1FFF => {
154                if self.chr_ram_enabled {
155                    self.chr_ram[addr as usize] = value;
156                }
157            }
158            0x2000..=0x3EFF => {
159                let off = self.nametable_offset(addr);
160                self.vram[off] = value;
161            }
162            _ => {}
163        }
164    }
165
166    fn current_mirroring(&self) -> Mirroring {
167        self.mirroring
168    }
169
170    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
171        let mut info = crate::mapper::MapperDebugInfo {
172            mapper_id: 93,
173            name: "Sunsoft-3R (93)".into(),
174            mirroring: crate::mapper::mirroring_name(self.mirroring),
175            ..Default::default()
176        };
177        info.prg_banks
178            .push(("PRG".into(), format!("{:#04x}", self.prg_bank)));
179        info.extra.push((
180            "CHR-RAM".into(),
181            if self.chr_ram_enabled {
182                "enabled".into()
183            } else {
184                "disabled".into()
185            },
186        ));
187        info
188    }
189
190    fn save_state(&self) -> Vec<u8> {
191        let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr_ram.len());
192        out.push(SAVE_STATE_VERSION);
193        out.push(self.prg_bank);
194        out.push(u8::from(self.chr_ram_enabled));
195        out.extend_from_slice(&self.vram);
196        out.extend_from_slice(&self.chr_ram);
197        out
198    }
199
200    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
201        let expected = 3 + self.vram.len() + self.chr_ram.len();
202        if data.len() != expected {
203            return Err(MapperError::WrongLength {
204                expected,
205                got: data.len(),
206            });
207        }
208        if data[0] != SAVE_STATE_VERSION {
209            return Err(MapperError::UnsupportedVersion(data[0]));
210        }
211        self.prg_bank = data[1];
212        self.chr_ram_enabled = data[2] != 0;
213        let mut cursor = 3;
214        self.vram
215            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
216        cursor += self.vram.len();
217        self.chr_ram
218            .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
219        Ok(())
220    }
221}
222
223#[cfg(test)]
224#[allow(clippy::cast_possible_truncation)]
225mod tests {
226    use super::*;
227
228    /// Filled `0xFF` — NOT `0x00` — with only the first byte of each bank
229    /// carrying its index as a marker.
230    ///
231    /// This board has bus conflicts: `cpu_write` ANDs the written byte with the
232    /// ROM byte at that address. A `0x00` fill would silently mask every
233    /// register write to zero and make these tests assert the wrong behavior,
234    /// so register writes below target `$8001` (a `0xFF` byte, mask
235    /// transparent) rather than `$8000` (the bank marker). Same convention as
236    /// the sibling `m089_sunsoft2.rs`.
237    fn synth_prg(banks: usize) -> Box<[u8]> {
238        let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
239        for b in 0..banks {
240            v[b * PRG_BANK_16K] = b as u8;
241        }
242        v.into_boxed_slice()
243    }
244
245    #[test]
246    fn defaults_first_prg_last_fixed() {
247        let mut m = Sunsoft3r::new(synth_prg(8), Mirroring::Vertical).unwrap();
248        assert_eq!(m.cpu_read(0x8000), 0);
249        assert_eq!(m.cpu_read(0xC000), 7); // last 16K fixed
250    }
251
252    #[test]
253    fn prg_bank_select_bits_4_to_6() {
254        let mut m = Sunsoft3r::new(synth_prg(8), Mirroring::Vertical).unwrap();
255        // [.PPP ...E]: PRG=5 (bits 4-6) + CHR enable (bit 0) -> 0b0101_0001.
256        m.cpu_write(0x8001, 0b0101_0001);
257        assert_eq!(m.cpu_read(0x8000), 5);
258        assert_eq!(m.cpu_read(0xC000), 7); // fixed unchanged
259    }
260
261    #[test]
262    fn chr_ram_enable_gates_access() {
263        let mut m = Sunsoft3r::new(synth_prg(2), Mirroring::Horizontal).unwrap();
264        // Default enabled: round-trip works.
265        m.ppu_write(0x0010, 0xCD);
266        assert_eq!(m.ppu_read(0x0010), 0xCD);
267        // Disable CHR-RAM (bit 0 = 0): writes ignored, reads open bus (0).
268        m.cpu_write(0x8001, 0x00);
269        m.ppu_write(0x0020, 0xEE);
270        assert_eq!(m.ppu_read(0x0020), 0);
271        // Re-enable: previously-written byte still there.
272        m.cpu_write(0x8001, 0x01);
273        assert_eq!(m.ppu_read(0x0010), 0xCD);
274    }
275
276    #[test]
277    fn save_state_round_trip() {
278        let mut m = Sunsoft3r::new(synth_prg(4), Mirroring::Vertical).unwrap();
279        m.cpu_write(0x8001, 0b0010_0001);
280        m.ppu_write(0x0001, 0x77);
281        let blob = m.save_state();
282        let mut m2 = Sunsoft3r::new(synth_prg(4), Mirroring::Vertical).unwrap();
283        m2.load_state(&blob).unwrap();
284        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
285        assert_eq!(m.ppu_read(0x0001), m2.ppu_read(0x0001));
286    }
287}