Skip to main content

rustynes_mappers/
m096_bandai96.rs

1//! Bandai Oeka Kids (mapper 96).
2//!
3//! The unusual one in this batch: the low CHR bank bits are not written by the
4//! CPU at all -- they are latched from the *PPU address bus* during nametable
5//! fetches. The board watches for a fetch in `$2000-$2FFF` and captures two
6//! address bits, so the CHR bank in use tracks which nametable quadrant the
7//! PPU is currently reading. That is how the drawing-tablet software swaps
8//! character data per screen region without CPU involvement, and it is why
9//! this board needs a PPU-read hook where its peers need none.
10//!
11//! A best-effort (Tier-2) board: register-decode correctness verified against
12//! the `GeraNES` reference emulator (cross-referenced, not copied)
13//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
14//! is direct slice indexing and every bank select wraps with `% count`, so a
15//! register write can never index out of bounds -- required for the `#![no_std]`
16//! chip stack, which cannot afford a panic on a register access.
17//!
18//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
19//! and `docs/mappers.md` §Mapper coverage matrix.
20
21use crate::cartridge::Mirroring;
22use crate::mapper::{Mapper, MapperCaps, MapperError};
23use alloc::{boxed::Box, vec::Vec};
24use alloc::{format, vec};
25
26const PRG_BANK_32K: usize = 0x8000;
27const CHR_BANK_4K: usize = 0x1000;
28const NAMETABLE_SIZE: usize = 0x0400;
29const NAMETABLE_SIZE_U16: u16 = 0x0400;
30
31const SAVE_STATE_VERSION: u8 = 1;
32
33// ---------------------------------------------------------------------------
34// Shared nametable helper (mirrors the one in the other simple-mapper modules).
35// ---------------------------------------------------------------------------
36
37const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
38    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
39    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
40    let physical = mirroring.physical_bank(table);
41    physical * NAMETABLE_SIZE + local
42}
43
44/// Mapper 96 (Bandai Oeka Kids).
45pub struct Bandai96 {
46    prg_rom: Box<[u8]>,
47    chr: Box<[u8]>,
48    vram: Box<[u8]>,
49    chr_is_ram: bool,
50    prg_bank: u8,
51    outer_chr: u8,
52    inner_chr: u8,
53    last_ppu_addr: u16,
54    mirroring: Mirroring,
55}
56
57impl Bandai96 {
58    /// Construct a new mapper 96 board.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
63    /// 32 KiB, or CHR-ROM (when present) is not a multiple of 4 KiB.
64    pub fn new(
65        prg_rom: Box<[u8]>,
66        chr_rom: Box<[u8]>,
67        mirroring: Mirroring,
68    ) -> Result<Self, MapperError> {
69        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
70            return Err(MapperError::Invalid(format!(
71                "mapper 96 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
72                prg_rom.len()
73            )));
74        }
75        let chr_is_ram = chr_rom.is_empty();
76        let chr: Box<[u8]> = if chr_is_ram {
77            // Two 4 KiB CHR-RAM banks (the Oeka Kids drawing buffer).
78            vec![0u8; 2 * CHR_BANK_4K].into_boxed_slice()
79        } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
80            chr_rom
81        } else {
82            return Err(MapperError::Invalid(format!(
83                "mapper 96 CHR-ROM size {} is not a multiple of 4 KiB",
84                chr_rom.len()
85            )));
86        };
87        Ok(Self {
88            prg_rom,
89            chr,
90            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
91            chr_is_ram,
92            prg_bank: 0,
93            outer_chr: 0,
94            inner_chr: 0,
95            last_ppu_addr: 0,
96            mirroring,
97        })
98    }
99
100    fn chr_count_4k(&self) -> usize {
101        (self.chr.len() / CHR_BANK_4K).max(1)
102    }
103
104    fn chr_offset(&self, addr: u16) -> usize {
105        // $0000 slot uses outer|inner; $1000 slot uses outer|0x03.
106        let slot = (addr >> 12) & 0x01;
107        let bank = if slot == 0 {
108            self.outer_chr | self.inner_chr
109        } else {
110            self.outer_chr | 0x03
111        };
112        let bank = (bank as usize) % self.chr_count_4k();
113        bank * CHR_BANK_4K + (addr as usize & (CHR_BANK_4K - 1))
114    }
115}
116
117impl Mapper for Bandai96 {
118    /// v3.1.0: not pure -- its inner CHR bank follows the last PPU address read, so the PPU's display-only
119    /// "disable sprite limit" reads are not made on this board.
120    fn chr_reads_are_pure(&self) -> bool {
121        false
122    }
123
124    fn caps(&self) -> MapperCaps {
125        MapperCaps::NONE
126    }
127
128    fn cpu_read(&mut self, addr: u16) -> u8 {
129        if (0x8000..=0xFFFF).contains(&addr) {
130            let count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
131            let bank = (self.prg_bank as usize) % count;
132            self.prg_rom[bank * PRG_BANK_32K + (addr as usize - 0x8000)]
133        } else {
134            0
135        }
136    }
137
138    fn cpu_write(&mut self, addr: u16, value: u8) {
139        if (0x8000..=0xFFFF).contains(&addr) {
140            self.prg_bank = value & 0x03;
141            self.outer_chr = value & 0x04;
142        }
143    }
144
145    fn ppu_read(&mut self, addr: u16) -> u8 {
146        let masked = addr & 0x3FFF;
147        // Sniff the PPU address bus: rising edge into a nametable fetch latches
148        // the CHR inner bank from address bits 9-8.
149        if (self.last_ppu_addr & 0x3000) != 0x2000 && (masked & 0x3000) == 0x2000 {
150            self.inner_chr = ((masked >> 8) & 0x03) as u8;
151        }
152        self.last_ppu_addr = masked;
153        match masked {
154            0x0000..=0x1FFF => self.chr[self.chr_offset(masked)],
155            0x2000..=0x3EFF => self.vram[nametable_offset(masked, self.mirroring)],
156            _ => 0,
157        }
158    }
159
160    fn ppu_write(&mut self, addr: u16, value: u8) {
161        let masked = addr & 0x3FFF;
162        if (self.last_ppu_addr & 0x3000) != 0x2000 && (masked & 0x3000) == 0x2000 {
163            self.inner_chr = ((masked >> 8) & 0x03) as u8;
164        }
165        self.last_ppu_addr = masked;
166        match masked {
167            0x0000..=0x1FFF => {
168                if self.chr_is_ram {
169                    let off = self.chr_offset(masked);
170                    self.chr[off] = value;
171                }
172            }
173            0x2000..=0x3EFF => {
174                let off = nametable_offset(masked, self.mirroring);
175                self.vram[off] = value;
176            }
177            _ => {}
178        }
179    }
180
181    fn current_mirroring(&self) -> Mirroring {
182        self.mirroring
183    }
184
185    fn save_state(&self) -> Vec<u8> {
186        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
187        let mut out = Vec::with_capacity(6 + self.vram.len() + chr_extra);
188        out.push(SAVE_STATE_VERSION);
189        out.push(self.prg_bank);
190        out.push(self.outer_chr);
191        out.push(self.inner_chr);
192        out.push((self.last_ppu_addr & 0xFF) as u8);
193        out.push((self.last_ppu_addr >> 8) as u8);
194        out.extend_from_slice(&self.vram);
195        if self.chr_is_ram {
196            out.extend_from_slice(&self.chr);
197        }
198        out
199    }
200
201    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
202        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
203        let expected = 6 + self.vram.len() + chr_extra;
204        if data.len() != expected {
205            return Err(MapperError::WrongLength {
206                expected,
207                got: data.len(),
208            });
209        }
210        if data[0] != SAVE_STATE_VERSION {
211            return Err(MapperError::UnsupportedVersion(data[0]));
212        }
213        self.prg_bank = data[1];
214        self.outer_chr = data[2];
215        self.inner_chr = data[3];
216        self.last_ppu_addr = u16::from(data[4]) | (u16::from(data[5]) << 8);
217        let mut cursor = 6;
218        self.vram
219            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
220        cursor += self.vram.len();
221        if self.chr_is_ram {
222            self.chr
223                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
224        }
225        Ok(())
226    }
227}
228
229#[cfg(test)]
230#[allow(clippy::cast_possible_truncation)]
231mod tests {
232    use super::*;
233
234    fn synth_prg_32k(banks: usize) -> Box<[u8]> {
235        let mut v = vec![0xFFu8; banks * PRG_BANK_32K];
236        for b in 0..banks {
237            v[b * PRG_BANK_32K] = b as u8;
238        }
239        v.into_boxed_slice()
240    }
241
242    fn synth_chr_4k(banks: usize) -> Box<[u8]> {
243        let mut v = vec![0u8; banks * CHR_BANK_4K];
244        for b in 0..banks {
245            v[b * CHR_BANK_4K] = b as u8;
246        }
247        v.into_boxed_slice()
248    }
249
250    #[test]
251    fn m96_prg_and_outer_chr() {
252        let mut m =
253            Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
254        // PRG = bits 0-1, outer CHR = bit 2.
255        m.cpu_write(0x8000, 0b0000_0011); // PRG 3, outer 0
256        assert_eq!(m.cpu_read(0x8000), 3);
257        // $1000 slot = outer|0x03 = 3.
258        assert_eq!(m.ppu_read(0x1000), 3);
259    }
260
261    #[test]
262    fn m96_inner_chr_latched_from_ppu_bus() {
263        let mut m =
264            Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
265        // outer = 0 (PRG write bit2 clear).
266        m.cpu_write(0x8000, 0);
267        // Approach a nametable fetch from a non-$2xxx address (e.g. a pattern
268        // fetch at $0000), then fetch $2100 -> inner = (0x2100>>8)&3 = 1.
269        let _ = m.ppu_read(0x0000);
270        let _ = m.ppu_read(0x2100);
271        // $0000 slot bank = outer|inner = 0|1 = 1.
272        assert_eq!(m.ppu_read(0x0000), 1);
273        // Fetch $2300 -> inner = 3. (Must re-approach from outside $2xxx.)
274        let _ = m.ppu_read(0x0000);
275        let _ = m.ppu_read(0x2300);
276        assert_eq!(m.ppu_read(0x0000), 3);
277    }
278
279    #[test]
280    fn m96_save_state_round_trips_ppu_bus_latch() {
281        let mut b =
282            Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
283        b.cpu_write(0x8000, 0);
284        let _ = b.ppu_read(0x0000);
285        let _ = b.ppu_read(0x2200);
286        let blob = b.save_state();
287        let mut b2 =
288            Bandai96::new(synth_prg_32k(4), synth_chr_4k(8), Mirroring::Horizontal).unwrap();
289        b2.load_state(&blob).unwrap();
290        assert_eq!(b2.ppu_read(0x0000), b.ppu_read(0x0000));
291    }
292}