Skip to main content

rustynes_mappers/
m067_sunsoft3.rs

1//! Sunsoft-3 (iNES mapper 67) implementation.
2//!
3//! Used by Fantasy Zone II (J), Mito Koumon II, and the Vs. game Vs. Platoon.
4//!
5//! # Banking (`nesdev_wiki/INES_Mapper_067.xhtml`)
6//!
7//! - `$8000-$BFFF`: 16 KiB switchable PRG bank (selected via `$F800`).
8//! - `$C000-$FFFF`: 16 KiB PRG bank, fixed to the last bank.
9//! - PPU `$0000`/`$0800`/`$1000`/`$1800`: four 2 KiB CHR banks.
10//!
11//! # Registers (each occupies a `$0800`-aligned range)
12//!
13//! | Addr    | Purpose                                                       |
14//! |---------|---------------------------------------------------------------|
15//! | `$8800` | CHR bank 0 (2 KiB @ `$0000`)                                   |
16//! | `$9800` | CHR bank 1 (2 KiB @ `$0800`)                                   |
17//! | `$A800` | CHR bank 2 (2 KiB @ `$1000`)                                   |
18//! | `$B800` | CHR bank 3 (2 KiB @ `$1800`)                                   |
19//! | `$C800` | IRQ load (write twice: high then low)                         |
20//! | `$D800` | IRQ enable (bit 4) + resets the `$C800` write toggle          |
21//! | `$E800` | mirroring (bits 0-1: 0=V, 1=H, 2=1scA, 3=1scB)                |
22//! | `$F800` | PRG bank (bits 0-3) @ `$8000-$BFFF`                            |
23//! | `$8000` | (and mirrors) interrupt acknowledge                           |
24//!
25//! # IRQ
26//!
27//! A 16-bit down-counter (loaded directly via the write-twice `$C800`
28//! register, NOT a separate reload latch) decrements every CPU cycle while
29//! enabled. When it wraps `$0000`→`$FFFF` the mapper asserts an IRQ and
30//! pauses itself (clears its own enable). Any write to `$D800` resets the
31//! `$C800` write toggle so the next `$C800` write is the high byte. Writes to
32//! `$D800` do NOT acknowledge the IRQ; only `$8000` (and its mirrors) ack.
33//!
34//! Reuses the CPU-cycle IRQ family pattern (`m021_vrc4.rs`, `m073_vrc3.rs`).
35
36#![allow(
37    clippy::cast_possible_truncation,
38    clippy::cast_lossless,
39    clippy::missing_const_for_fn,
40    clippy::struct_excessive_bools,
41    clippy::doc_markdown
42)]
43
44use crate::cartridge::Mirroring;
45use crate::mapper::{Mapper, MapperCaps, MapperError};
46use alloc::{boxed::Box, vec::Vec};
47use alloc::{format, vec};
48
49const PRG_BANK_16K: usize = 0x4000;
50const CHR_BANK_2K: usize = 0x0800;
51const NAMETABLE_SIZE: usize = 0x0400;
52const NAMETABLE_SIZE_U16: u16 = 0x0400;
53
54const SAVE_STATE_VERSION: u8 = 1;
55
56/// Sunsoft-3 mapper (iNES mapper 67).
57pub struct Sunsoft3 {
58    prg_rom: Box<[u8]>,
59    chr: Box<[u8]>,
60    vram: Box<[u8]>,
61    chr_is_ram: bool,
62
63    prg_bank: u8,
64    chr_banks: [u8; 4],
65    mirroring: Mirroring,
66
67    irq_counter: u16,
68    irq_enabled: bool,
69    irq_pending: bool,
70    // Write-twice toggle for $C800: false = next write is high byte.
71    irq_write_low_next: bool,
72}
73
74impl Sunsoft3 {
75    /// Construct a new Sunsoft-3 mapper.
76    ///
77    /// `prg_rom` must be a non-zero multiple of 16 KiB; CHR-ROM (when present)
78    /// must be a multiple of 2 KiB. CHR-RAM (8 KiB) is allocated when no
79    /// CHR-ROM is supplied.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`MapperError::Invalid`] on size mismatch.
84    pub fn new(
85        prg_rom: Box<[u8]>,
86        chr_rom: Box<[u8]>,
87        mirroring: Mirroring,
88    ) -> Result<Self, MapperError> {
89        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
90            return Err(MapperError::Invalid(format!(
91                "Sunsoft-3 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
92                prg_rom.len()
93            )));
94        }
95        let chr_is_ram = chr_rom.is_empty();
96        let chr: Box<[u8]> = if chr_is_ram {
97            vec![0u8; 4 * CHR_BANK_2K].into_boxed_slice()
98        } else if chr_rom.len().is_multiple_of(CHR_BANK_2K) {
99            chr_rom
100        } else {
101            return Err(MapperError::Invalid(format!(
102                "Sunsoft-3 CHR-ROM size {} is not a multiple of 2 KiB",
103                chr_rom.len()
104            )));
105        };
106        Ok(Self {
107            prg_rom,
108            chr,
109            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
110            chr_is_ram,
111            prg_bank: 0,
112            chr_banks: [0; 4],
113            mirroring,
114            irq_counter: 0,
115            irq_enabled: false,
116            irq_pending: false,
117            irq_write_low_next: false,
118        })
119    }
120
121    fn prg_offset(&self, addr: u16) -> usize {
122        let total = (self.prg_rom.len() / PRG_BANK_16K).max(1);
123        let last = total - 1;
124        let bank = match addr {
125            0x8000..=0xBFFF => (self.prg_bank as usize) % total,
126            _ => last, // $C000-$FFFF fixed to last bank
127        };
128        bank * PRG_BANK_16K + (addr as usize & 0x3FFF)
129    }
130
131    fn chr_offset(&self, addr: u16) -> usize {
132        let addr = (addr & 0x1FFF) as usize;
133        let total_2k = (self.chr.len() / CHR_BANK_2K).max(1);
134        let slot = addr / CHR_BANK_2K;
135        let bank = (self.chr_banks[slot] as usize) % total_2k;
136        bank * CHR_BANK_2K + (addr & (CHR_BANK_2K - 1))
137    }
138
139    fn nametable_offset(&self, addr: u16) -> usize {
140        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
141        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
142        let physical = self.mirroring.physical_bank(table);
143        physical * NAMETABLE_SIZE + local
144    }
145}
146
147impl Mapper for Sunsoft3 {
148    // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio.
149    fn caps(&self) -> MapperCaps {
150        MapperCaps::CYCLE_IRQ
151    }
152
153    fn cpu_read(&mut self, addr: u16) -> u8 {
154        match addr {
155            0x8000..=0xFFFF => {
156                let off = self.prg_offset(addr);
157                self.prg_rom[off % self.prg_rom.len()]
158            }
159            _ => 0,
160        }
161    }
162
163    fn cpu_write(&mut self, addr: u16, value: u8) {
164        // Each register occupies a $0800-aligned window; decode the top of
165        // the $8000-$FFFF range.
166        match addr & 0xF800 {
167            0x8000 => {
168                // Interrupt acknowledge ($8000 mask).
169                self.irq_pending = false;
170            }
171            0x8800 => self.chr_banks[0] = value,
172            0x9800 => self.chr_banks[1] = value,
173            0xA800 => self.chr_banks[2] = value,
174            0xB800 => self.chr_banks[3] = value,
175            0xC800 => {
176                // Write-twice 16-bit counter (high then low). Directly sets
177                // the live counter.
178                if self.irq_write_low_next {
179                    self.irq_counter = (self.irq_counter & 0xFF00) | (value as u16);
180                } else {
181                    self.irq_counter = (self.irq_counter & 0x00FF) | ((value as u16) << 8);
182                }
183                self.irq_write_low_next = !self.irq_write_low_next;
184            }
185            0xD800 => {
186                self.irq_enabled = (value & 0x10) != 0;
187                // Reset the $C800 write toggle (next write is the high byte).
188                self.irq_write_low_next = false;
189            }
190            0xE800 => {
191                self.mirroring = match value & 0x03 {
192                    0 => Mirroring::Vertical,
193                    1 => Mirroring::Horizontal,
194                    2 => Mirroring::SingleScreenA,
195                    _ => Mirroring::SingleScreenB,
196                };
197            }
198            0xF800 => self.prg_bank = value & 0x0F,
199            _ => {}
200        }
201    }
202
203    fn ppu_read(&mut self, addr: u16) -> u8 {
204        let addr = addr & 0x3FFF;
205        match addr {
206            0x0000..=0x1FFF => {
207                let off = self.chr_offset(addr);
208                self.chr[off % self.chr.len()]
209            }
210            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
211            _ => 0,
212        }
213    }
214
215    fn ppu_write(&mut self, addr: u16, value: u8) {
216        let addr = addr & 0x3FFF;
217        match addr {
218            0x0000..=0x1FFF => {
219                if self.chr_is_ram {
220                    let off = self.chr_offset(addr);
221                    let len = self.chr.len();
222                    self.chr[off % len] = value;
223                }
224            }
225            0x2000..=0x3EFF => {
226                let off = self.nametable_offset(addr) % self.vram.len();
227                self.vram[off] = value;
228            }
229            _ => {}
230        }
231    }
232
233    fn notify_cpu_cycle(&mut self) {
234        if !self.irq_enabled {
235            return;
236        }
237        if self.irq_counter == 0 {
238            // Wrap $0000 -> $FFFF: assert + pause.
239            self.irq_counter = 0xFFFF;
240            self.irq_pending = true;
241            self.irq_enabled = false;
242        } else {
243            self.irq_counter -= 1;
244        }
245    }
246
247    fn irq_pending(&self) -> bool {
248        self.irq_pending
249    }
250
251    fn current_mirroring(&self) -> Mirroring {
252        self.mirroring
253    }
254
255    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
256        let mut info = crate::mapper::MapperDebugInfo {
257            mapper_id: 67,
258            name: "Sunsoft-3 (67)".into(),
259            mirroring: crate::mapper::mirroring_name(self.mirroring),
260            ..Default::default()
261        };
262        info.prg_banks
263            .push(("PRG".into(), format!("{:#04x}", self.prg_bank)));
264        for (i, b) in self.chr_banks.iter().enumerate() {
265            info.chr_banks.push((format!("C{i}"), format!("{b:#04x}")));
266        }
267        info.irq_state
268            .push(("counter".into(), format!("{:#06x}", self.irq_counter)));
269        info.irq_state
270            .push(("enabled".into(), format!("{}", self.irq_enabled)));
271        info.irq_state
272            .push(("pending".into(), format!("{}", self.irq_pending)));
273        info
274    }
275
276    fn save_state(&self) -> Vec<u8> {
277        let mut out = Vec::with_capacity(
278            16 + self.vram.len() + if self.chr_is_ram { self.chr.len() } else { 0 },
279        );
280        out.push(SAVE_STATE_VERSION);
281        out.push(self.prg_bank);
282        out.extend_from_slice(&self.chr_banks);
283        out.push(self.mirroring as u8);
284        out.extend_from_slice(&self.irq_counter.to_le_bytes());
285        out.push(u8::from(self.irq_enabled));
286        out.push(u8::from(self.irq_pending));
287        out.push(u8::from(self.irq_write_low_next));
288        out.extend_from_slice(&self.vram);
289        if self.chr_is_ram {
290            out.extend_from_slice(&self.chr);
291        }
292        out
293    }
294
295    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
296        let chr_part = if self.chr_is_ram { self.chr.len() } else { 0 };
297        // 1 + 1 + 4 + 1 + 2 + 1 + 1 + 1
298        let scalar_len = 1 + 1 + 4 + 1 + 2 + 1 + 1 + 1;
299        let expected = scalar_len + self.vram.len() + chr_part;
300        if data.len() != expected {
301            return Err(MapperError::WrongLength {
302                expected,
303                got: data.len(),
304            });
305        }
306        if data[0] != SAVE_STATE_VERSION {
307            return Err(MapperError::UnsupportedVersion(data[0]));
308        }
309        let mut c = 1usize;
310        self.prg_bank = data[c];
311        c += 1;
312        self.chr_banks.copy_from_slice(&data[c..c + 4]);
313        c += 4;
314        self.mirroring = match data[c] {
315            0 => Mirroring::Horizontal,
316            1 => Mirroring::Vertical,
317            2 => Mirroring::SingleScreenA,
318            3 => Mirroring::SingleScreenB,
319            4 => Mirroring::FourScreen,
320            5 => Mirroring::MapperControlled,
321            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
322        };
323        c += 1;
324        self.irq_counter = u16::from_le_bytes([data[c], data[c + 1]]);
325        c += 2;
326        self.irq_enabled = data[c] != 0;
327        c += 1;
328        self.irq_pending = data[c] != 0;
329        c += 1;
330        self.irq_write_low_next = data[c] != 0;
331        c += 1;
332        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
333        c += self.vram.len();
334        if self.chr_is_ram {
335            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
336        }
337        Ok(())
338    }
339}
340
341#[cfg(test)]
342#[allow(clippy::cast_possible_truncation)]
343mod tests {
344    use super::*;
345
346    fn synth_prg(banks_16k: usize) -> Box<[u8]> {
347        let mut v = vec![0u8; banks_16k * PRG_BANK_16K];
348        for b in 0..banks_16k {
349            v[b * PRG_BANK_16K] = b as u8;
350        }
351        v.into_boxed_slice()
352    }
353
354    fn synth_chr(banks_2k: usize) -> Box<[u8]> {
355        let mut v = vec![0u8; banks_2k * CHR_BANK_2K];
356        for b in 0..banks_2k {
357            v[b * CHR_BANK_2K] = b as u8;
358        }
359        v.into_boxed_slice()
360    }
361
362    fn fresh() -> Sunsoft3 {
363        Sunsoft3::new(synth_prg(8), synth_chr(16), Mirroring::Vertical).unwrap()
364    }
365
366    #[test]
367    fn prg_bank_select_and_fixed_last() {
368        let mut m = fresh();
369        assert_eq!(m.cpu_read(0x8000), 0);
370        assert_eq!(m.cpu_read(0xC000), 7); // last bank fixed
371        m.cpu_write(0xF800, 5);
372        assert_eq!(m.cpu_read(0x8000), 5);
373        assert_eq!(m.cpu_read(0xC000), 7);
374    }
375
376    #[test]
377    fn chr_four_2k_banks() {
378        let mut m = fresh();
379        m.cpu_write(0x8800, 3); // CHR0
380        m.cpu_write(0xA800, 9); // CHR2
381        assert_eq!(m.ppu_read(0x0000), 3);
382        assert_eq!(m.ppu_read(0x1000), 9);
383    }
384
385    #[test]
386    fn mirroring_select() {
387        let mut m = fresh();
388        m.cpu_write(0xE800, 0);
389        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
390        m.cpu_write(0xE800, 1);
391        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
392        m.cpu_write(0xE800, 2);
393        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
394        m.cpu_write(0xE800, 3);
395        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
396    }
397
398    #[test]
399    fn irq_write_twice_loads_counter_high_then_low() {
400        let mut m = fresh();
401        m.cpu_write(0xD800, 0x00); // reset toggle (write-high next)
402        m.cpu_write(0xC800, 0x12); // high
403        m.cpu_write(0xC800, 0x34); // low
404        assert_eq!(m.irq_counter, 0x1234);
405    }
406
407    #[test]
408    fn irq_counts_down_wraps_and_pauses() {
409        let mut m = fresh();
410        m.cpu_write(0xD800, 0x00); // reset toggle
411        m.cpu_write(0xC800, 0x00); // high
412        m.cpu_write(0xC800, 0x02); // low -> counter = 2
413        m.cpu_write(0xD800, 0x10); // enable
414        m.notify_cpu_cycle(); // 2 -> 1
415        m.notify_cpu_cycle(); // 1 -> 0
416        assert!(!m.irq_pending());
417        m.notify_cpu_cycle(); // 0 -> wrap -> assert + pause
418        assert!(m.irq_pending());
419        assert!(!m.irq_enabled, "wrap pauses the counter");
420        assert_eq!(m.irq_counter, 0xFFFF);
421    }
422
423    #[test]
424    fn d800_does_not_ack_8000_does() {
425        let mut m = fresh();
426        m.irq_pending = true;
427        m.cpu_write(0xD800, 0x00); // must NOT ack
428        assert!(m.irq_pending());
429        m.cpu_write(0x8000, 0x00); // acknowledges
430        assert!(!m.irq_pending());
431    }
432
433    #[test]
434    fn save_state_round_trip() {
435        let mut m = fresh();
436        m.cpu_write(0xF800, 3);
437        m.cpu_write(0x9800, 7);
438        m.cpu_write(0xD800, 0x00);
439        m.cpu_write(0xC800, 0xAB);
440        m.cpu_write(0xC800, 0xCD);
441        m.cpu_write(0xD800, 0x10);
442        m.ppu_write(0x2000, 0x44);
443        let blob = m.save_state();
444        let mut m2 = fresh();
445        m2.load_state(&blob).unwrap();
446        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
447        assert_eq!(m.ppu_read(0x0800), m2.ppu_read(0x0800));
448        assert_eq!(m.irq_counter, m2.irq_counter);
449        assert_eq!(m.ppu_read(0x2000), m2.ppu_read(0x2000));
450    }
451}