Skip to main content

rustynes_mappers/
m068_sunsoft4.rs

1//! Sunsoft-4 (iNES mapper 68) implementation.
2//!
3//! Used by After Burner (US), Maharaja (J), Nantettatte!! Baseball (J). Its
4//! distinguishing feature is the ability to map **CHR ROM into the nametable
5//! address space** (`$2000-$2FFF`): two 1 KiB nametable-bank registers select
6//! CHR-ROM pages to back the nametables, gated by an enable bit.
7//!
8//! # Banking (`nesdev_wiki/INES_Mapper_068.xhtml`)
9//!
10//! - `$6000-$7FFF`: 8 KiB PRG-RAM (gated by `$F000` bit 4).
11//! - `$8000-$BFFF`: 16 KiB switchable PRG bank (`$F000` bits 0-3).
12//! - `$C000-$FFFF`: 16 KiB PRG bank fixed to the last internal bank.
13//! - PPU `$0000`/`$0800`/`$1000`/`$1800`: four 2 KiB CHR banks
14//!   (`$8000`/`$9000`/`$A000`/`$B000`).
15//!
16//! # Registers (each occupies a `$1000`-aligned range)
17//!
18//! | Addr    | Purpose                                                       |
19//! |---------|---------------------------------------------------------------|
20//! | `$8000` | CHR pattern bank 0 (2 KiB @ `$0000`)                          |
21//! | `$9000` | CHR pattern bank 1 (2 KiB @ `$0800`)                          |
22//! | `$A000` | CHR pattern bank 2 (2 KiB @ `$1000`)                          |
23//! | `$B000` | CHR pattern bank 3 (2 KiB @ `$1800`)                          |
24//! | `$C000` | CHR nametable bank 0 (1 KiB; D7 ignored, treated as 1)        |
25//! | `$D000` | CHR nametable bank 1 (1 KiB; D7 ignored, treated as 1)        |
26//! | `$E000` | nametable control: bits 0-1 mirroring, bit 4 CIRAM/CHR-ROM    |
27//! | `$F000` | PRG bank (bits 0-3) + bit 4 = enable PRG-RAM                  |
28//!
29//! # CHR-ROM nametables
30//!
31//! When `$E000` bit 4 is set, nametable fetches in `$2000-$2FFF` come from
32//! CHR ROM instead of CIRAM: the lower logical nametable uses the `$C000`
33//! bank, the upper uses the `$D000` bank, with the lower/upper assignment
34//! following the `$E000` mirroring mode (the same H/V/1scA/1scB table used for
35//! CIRAM). Only D6-D0 of `$C000`/`$D000` are used; D7 is forced to 1, so
36//! nametable banks live in the last 128 KiB of CHR ROM.
37//!
38//! This is the canonical use of the [`Mapper::nametable_fetch`] hook (the PPU
39//! consults it before reading CIRAM); CIRAM mode falls back to
40//! [`Mapper::nametable_address`].
41
42#![allow(
43    clippy::cast_possible_truncation,
44    clippy::cast_lossless,
45    clippy::missing_const_for_fn,
46    clippy::struct_excessive_bools,
47    clippy::doc_markdown
48)]
49
50use crate::cartridge::Mirroring;
51use crate::mapper::{Mapper, MapperCaps, MapperError};
52use alloc::{boxed::Box, vec::Vec};
53use alloc::{format, vec};
54
55const PRG_BANK_16K: usize = 0x4000;
56const CHR_BANK_2K: usize = 0x0800;
57const CHR_BANK_1K: usize = 0x0400;
58const NAMETABLE_SIZE: usize = 0x0400;
59const NAMETABLE_SIZE_U16: u16 = 0x0400;
60
61const SAVE_STATE_VERSION: u8 = 1;
62
63/// Sunsoft-4 mapper (iNES mapper 68).
64pub struct Sunsoft4 {
65    prg_rom: Box<[u8]>,
66    chr: Box<[u8]>,
67    prg_ram: Box<[u8]>,
68    vram: Box<[u8]>,
69    chr_is_ram: bool,
70
71    prg_bank: u8,
72    prg_ram_enabled: bool,
73    chr_banks: [u8; 4], // 2 KiB pattern banks
74    nt_banks: [u8; 2],  // 1 KiB CHR-ROM nametable banks
75    mirroring: Mirroring,
76    nt_rom_mode: bool, // $E000 bit 4
77}
78
79impl Sunsoft4 {
80    /// Construct a new Sunsoft-4 mapper.
81    ///
82    /// `prg_rom` must be a non-zero multiple of 16 KiB; CHR-ROM (when present)
83    /// must be a multiple of 1 KiB. CHR-RAM (8 KiB) is allocated when no
84    /// CHR-ROM is supplied (the CHR-ROM nametable feature then has no backing
85    /// ROM and behaves as a 1 KiB-banked RAM read).
86    ///
87    /// # Errors
88    ///
89    /// Returns [`MapperError::Invalid`] on size mismatch.
90    pub fn new(
91        prg_rom: Box<[u8]>,
92        chr_rom: Box<[u8]>,
93        mirroring: Mirroring,
94    ) -> Result<Self, MapperError> {
95        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
96            return Err(MapperError::Invalid(format!(
97                "Sunsoft-4 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
98                prg_rom.len()
99            )));
100        }
101        let chr_is_ram = chr_rom.is_empty();
102        let chr: Box<[u8]> = if chr_is_ram {
103            vec![0u8; 4 * CHR_BANK_2K].into_boxed_slice()
104        } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
105            chr_rom
106        } else {
107            return Err(MapperError::Invalid(format!(
108                "Sunsoft-4 CHR-ROM size {} is not a multiple of 1 KiB",
109                chr_rom.len()
110            )));
111        };
112        Ok(Self {
113            prg_rom,
114            chr,
115            prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
116            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
117            chr_is_ram,
118            prg_bank: 0,
119            prg_ram_enabled: false,
120            chr_banks: [0; 4],
121            nt_banks: [0; 2],
122            mirroring,
123            nt_rom_mode: false,
124        })
125    }
126
127    fn prg_offset(&self, addr: u16) -> usize {
128        let total = (self.prg_rom.len() / PRG_BANK_16K).max(1);
129        let last = total - 1;
130        let bank = match addr {
131            0x8000..=0xBFFF => (self.prg_bank as usize) % total,
132            _ => last,
133        };
134        bank * PRG_BANK_16K + (addr as usize & 0x3FFF)
135    }
136
137    fn chr_offset(&self, addr: u16) -> usize {
138        let addr = (addr & 0x1FFF) as usize;
139        let total_2k = (self.chr.len() / CHR_BANK_2K).max(1);
140        let slot = addr / CHR_BANK_2K;
141        let bank = (self.chr_banks[slot] as usize) % total_2k;
142        bank * CHR_BANK_2K + (addr & (CHR_BANK_2K - 1))
143    }
144
145    /// Map a logical nametable index (0..=3) to the physical lower/upper bank
146    /// (0 or 1) under the current `$E000` mirroring mode.
147    fn nt_physical(&self, table: u8) -> usize {
148        self.mirroring.physical_bank(table)
149    }
150
151    fn nametable_offset(&self, addr: u16) -> usize {
152        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
153        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
154        self.nt_physical(table) * NAMETABLE_SIZE + local
155    }
156
157    /// Read a CHR-ROM nametable byte for `addr` in `$2000-$2FFF` using the
158    /// 1 KiB `$C000`/`$D000` bank registers (D7 forced to 1).
159    fn chr_nt_byte(&self, addr: u16) -> u8 {
160        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
161        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
162        // Lower physical nametable -> $C000 bank; upper -> $D000 bank.
163        let reg = self.nt_banks[self.nt_physical(table)] as usize;
164        // D6-D0 used; D7 forced to 1.
165        let bank = (reg & 0x7F) | 0x80;
166        let total_1k = (self.chr.len() / CHR_BANK_1K).max(1);
167        let off = (bank % total_1k) * CHR_BANK_1K + local;
168        self.chr[off % self.chr.len()]
169    }
170}
171
172impl Mapper for Sunsoft4 {
173    fn sram(&self) -> &[u8] {
174        &self.prg_ram
175    }
176    fn sram_mut(&mut self) -> &mut [u8] {
177        &mut self.prg_ram
178    }
179    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
180    // skips all four per-CPU-cycle dispatches for this board.
181    fn caps(&self) -> MapperCaps {
182        MapperCaps::NONE
183    }
184
185    fn cpu_read(&mut self, addr: u16) -> u8 {
186        match addr {
187            0x6000..=0x7FFF => {
188                if self.prg_ram_enabled {
189                    self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()]
190                } else {
191                    0
192                }
193            }
194            0x8000..=0xFFFF => {
195                let off = self.prg_offset(addr);
196                self.prg_rom[off % self.prg_rom.len()]
197            }
198            _ => 0,
199        }
200    }
201
202    fn cpu_write(&mut self, addr: u16, value: u8) {
203        match addr {
204            0x6000..=0x7FFF => {
205                if self.prg_ram_enabled {
206                    let off = (addr - 0x6000) as usize % self.prg_ram.len();
207                    self.prg_ram[off] = value;
208                }
209            }
210            // Each register occupies a $1000-aligned window.
211            0x8000..=0x8FFF => self.chr_banks[0] = value,
212            0x9000..=0x9FFF => self.chr_banks[1] = value,
213            0xA000..=0xAFFF => self.chr_banks[2] = value,
214            0xB000..=0xBFFF => self.chr_banks[3] = value,
215            0xC000..=0xCFFF => self.nt_banks[0] = value,
216            0xD000..=0xDFFF => self.nt_banks[1] = value,
217            0xE000..=0xEFFF => {
218                self.mirroring = match value & 0x03 {
219                    0 => Mirroring::Vertical,
220                    1 => Mirroring::Horizontal,
221                    2 => Mirroring::SingleScreenA,
222                    _ => Mirroring::SingleScreenB,
223                };
224                self.nt_rom_mode = (value & 0x10) != 0;
225            }
226            0xF000..=0xFFFF => {
227                self.prg_bank = value & 0x0F;
228                self.prg_ram_enabled = (value & 0x10) != 0;
229            }
230            _ => {}
231        }
232    }
233
234    fn ppu_read(&mut self, addr: u16) -> u8 {
235        let addr = addr & 0x3FFF;
236        match addr {
237            0x0000..=0x1FFF => {
238                let off = self.chr_offset(addr);
239                self.chr[off % self.chr.len()]
240            }
241            0x2000..=0x3EFF => {
242                // CHR-ROM nametable mode is handled by `nametable_fetch`
243                // (the PPU consults it first). This fallback path serves
244                // CIRAM for the non-ROM-nametable case.
245                if self.nt_rom_mode {
246                    self.chr_nt_byte(addr | 0x2000)
247                } else {
248                    self.vram[self.nametable_offset(addr) % self.vram.len()]
249                }
250            }
251            _ => 0,
252        }
253    }
254
255    fn ppu_write(&mut self, addr: u16, value: u8) {
256        let addr = addr & 0x3FFF;
257        match addr {
258            0x0000..=0x1FFF => {
259                if self.chr_is_ram {
260                    let off = self.chr_offset(addr);
261                    let len = self.chr.len();
262                    self.chr[off % len] = value;
263                }
264            }
265            // In CHR-ROM nametable mode writes are dropped (ROM-backed).
266            0x2000..=0x3EFF if !self.nt_rom_mode => {
267                let off = self.nametable_offset(addr) % self.vram.len();
268                self.vram[off] = value;
269            }
270            _ => {}
271        }
272    }
273
274    fn nametable_fetch(&mut self, addr: u16) -> Option<u8> {
275        if self.nt_rom_mode && (0x2000..=0x2FFF).contains(&(addr & 0x3FFF)) {
276            Some(self.chr_nt_byte(addr & 0x2FFF))
277        } else {
278            None
279        }
280    }
281
282    fn nametable_write(&mut self, _addr: u16, _value: u8) -> bool {
283        // In CHR-ROM nametable mode the PPU's CIRAM write is suppressed (the
284        // nametables are ROM-backed). In CIRAM mode return false so the PPU
285        // performs its normal CIRAM write via `nametable_address`.
286        self.nt_rom_mode
287    }
288
289    fn nametable_address(&self, addr: u16) -> u16 {
290        let off = self.nametable_offset(addr);
291        u16::try_from(off & 0x07FF).unwrap_or(0)
292    }
293
294    fn current_mirroring(&self) -> Mirroring {
295        self.mirroring
296    }
297
298    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
299        let mut info = crate::mapper::MapperDebugInfo {
300            mapper_id: 68,
301            name: "Sunsoft-4 (68)".into(),
302            mirroring: crate::mapper::mirroring_name(self.mirroring),
303            ..Default::default()
304        };
305        info.prg_banks
306            .push(("PRG".into(), format!("{:#04x}", self.prg_bank)));
307        info.prg_banks
308            .push(("ram_en".into(), format!("{}", self.prg_ram_enabled)));
309        for (i, b) in self.chr_banks.iter().enumerate() {
310            info.chr_banks.push((format!("C{i}"), format!("{b:#04x}")));
311        }
312        info.extra
313            .push(("ntROM".into(), format!("{}", self.nt_rom_mode)));
314        info.extra
315            .push(("NT0".into(), format!("{:#04x}", self.nt_banks[0])));
316        info.extra
317            .push(("NT1".into(), format!("{:#04x}", self.nt_banks[1])));
318        info
319    }
320
321    fn save_state(&self) -> Vec<u8> {
322        let mut out = Vec::with_capacity(
323            16 + self.prg_ram.len()
324                + self.vram.len()
325                + if self.chr_is_ram { self.chr.len() } else { 0 },
326        );
327        out.push(SAVE_STATE_VERSION);
328        out.push(self.prg_bank);
329        out.push(u8::from(self.prg_ram_enabled));
330        out.extend_from_slice(&self.chr_banks);
331        out.extend_from_slice(&self.nt_banks);
332        out.push(self.mirroring as u8);
333        out.push(u8::from(self.nt_rom_mode));
334        out.extend_from_slice(&self.prg_ram);
335        out.extend_from_slice(&self.vram);
336        if self.chr_is_ram {
337            out.extend_from_slice(&self.chr);
338        }
339        out
340    }
341
342    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
343        let chr_part = if self.chr_is_ram { self.chr.len() } else { 0 };
344        // 1 + 1 + 1 + 4 + 2 + 1 + 1
345        let scalar_len = 1 + 1 + 1 + 4 + 2 + 1 + 1;
346        let expected = scalar_len + self.prg_ram.len() + self.vram.len() + chr_part;
347        if data.len() != expected {
348            return Err(MapperError::WrongLength {
349                expected,
350                got: data.len(),
351            });
352        }
353        if data[0] != SAVE_STATE_VERSION {
354            return Err(MapperError::UnsupportedVersion(data[0]));
355        }
356        let mut c = 1usize;
357        self.prg_bank = data[c];
358        c += 1;
359        self.prg_ram_enabled = data[c] != 0;
360        c += 1;
361        self.chr_banks.copy_from_slice(&data[c..c + 4]);
362        c += 4;
363        self.nt_banks.copy_from_slice(&data[c..c + 2]);
364        c += 2;
365        self.mirroring = match data[c] {
366            0 => Mirroring::Horizontal,
367            1 => Mirroring::Vertical,
368            2 => Mirroring::SingleScreenA,
369            3 => Mirroring::SingleScreenB,
370            4 => Mirroring::FourScreen,
371            5 => Mirroring::MapperControlled,
372            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
373        };
374        c += 1;
375        self.nt_rom_mode = data[c] != 0;
376        c += 1;
377        self.prg_ram
378            .copy_from_slice(&data[c..c + self.prg_ram.len()]);
379        c += self.prg_ram.len();
380        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
381        c += self.vram.len();
382        if self.chr_is_ram {
383            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
384        }
385        Ok(())
386    }
387}
388
389#[cfg(test)]
390#[allow(clippy::cast_possible_truncation)]
391mod tests {
392    use super::*;
393
394    fn synth_prg(banks_16k: usize) -> Box<[u8]> {
395        let mut v = vec![0u8; banks_16k * PRG_BANK_16K];
396        for b in 0..banks_16k {
397            v[b * PRG_BANK_16K] = b as u8;
398        }
399        v.into_boxed_slice()
400    }
401
402    /// 256 KiB CHR = 256 1 KiB banks, so D7-forced nametable banks ($80+) are
403    /// reachable. Each 1 KiB bank's first byte = bank index (wrapped to u8).
404    fn synth_chr(banks_1k: usize) -> Box<[u8]> {
405        let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
406        for b in 0..banks_1k {
407            v[b * CHR_BANK_1K] = b as u8;
408        }
409        v.into_boxed_slice()
410    }
411
412    fn fresh() -> Sunsoft4 {
413        Sunsoft4::new(synth_prg(8), synth_chr(256), Mirroring::Vertical).unwrap()
414    }
415
416    #[test]
417    fn prg_bank_select_and_fixed_last() {
418        let mut m = fresh();
419        assert_eq!(m.cpu_read(0x8000), 0);
420        assert_eq!(m.cpu_read(0xC000), 7);
421        m.cpu_write(0xF000, 5);
422        assert_eq!(m.cpu_read(0x8000), 5);
423        assert_eq!(m.cpu_read(0xC000), 7);
424    }
425
426    #[test]
427    fn chr_four_2k_pattern_banks() {
428        let mut m = fresh();
429        // 2 KiB bank = 2 of the 1 KiB synth banks; first byte = bank*2.
430        m.cpu_write(0x8000, 3); // pattern bank 0 -> 2 KiB bank 3 -> 1k bank 6
431        assert_eq!(m.ppu_read(0x0000), 6);
432        m.cpu_write(0xA000, 9); // pattern bank 2 @ $1000 -> 1k bank 18
433        assert_eq!(m.ppu_read(0x1000), 18);
434    }
435
436    #[test]
437    fn prg_ram_gated_by_f000_bit4() {
438        let mut m = fresh();
439        // Disabled by default -> writes ignored, reads 0.
440        m.cpu_write(0x6000, 0xAB);
441        assert_eq!(m.cpu_read(0x6000), 0);
442        // Enable.
443        m.cpu_write(0xF000, 0x10);
444        m.cpu_write(0x6000, 0xCD);
445        assert_eq!(m.cpu_read(0x6000), 0xCD);
446    }
447
448    #[test]
449    fn nametable_chr_rom_mode_serves_chr_bytes() {
450        let mut m = fresh();
451        // Vertical mirroring: $2000 -> table 0 -> lower -> NT0 bank ($C000).
452        m.cpu_write(0xC000, 0x05); // NT0 bank -> (0x05 & 0x7F)|0x80 = 0x85 = 133
453        m.cpu_write(0xD000, 0x06); // NT1 bank -> 0x86 = 134
454        // Enable ROM nametable mode (bit 4), keep vertical (bits 0-1 = 0).
455        m.cpu_write(0xE000, 0x10);
456        // $2000 (table 0, vertical -> physical 0 -> NT0 = 133).
457        assert_eq!(m.nametable_fetch(0x2000), Some(133));
458        // $2400 (table 1, vertical -> physical 1 -> NT1 = 134).
459        assert_eq!(m.nametable_fetch(0x2400), Some(134));
460    }
461
462    #[test]
463    fn nametable_ciram_mode_returns_none_from_fetch() {
464        let mut m = fresh();
465        // ROM nametable mode OFF -> nametable_fetch returns None (PPU uses
466        // CIRAM via nametable_address).
467        m.cpu_write(0xE000, 0x00);
468        assert_eq!(m.nametable_fetch(0x2000), None);
469        assert!(!m.nametable_write(0x2000, 0));
470    }
471
472    #[test]
473    fn rom_nametable_mode_suppresses_ciram_write() {
474        let mut m = fresh();
475        m.cpu_write(0xE000, 0x10); // ROM nametable mode
476        assert!(m.nametable_write(0x2000, 0x42));
477    }
478
479    #[test]
480    fn save_state_round_trip() {
481        let mut m = fresh();
482        m.cpu_write(0xF000, 0x10 | 3);
483        m.cpu_write(0x9000, 7);
484        m.cpu_write(0xC000, 0x12);
485        m.cpu_write(0xE000, 0x11);
486        m.cpu_write(0x6000, 0x99);
487        let blob = m.save_state();
488        let mut m2 = fresh();
489        m2.load_state(&blob).unwrap();
490        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
491        assert_eq!(m.cpu_read(0x6000), m2.cpu_read(0x6000));
492        assert_eq!(m.nametable_fetch(0x2000), m2.nametable_fetch(0x2000));
493        assert_eq!(m.current_mirroring(), m2.current_mirroring());
494    }
495}