Skip to main content

rustynes_mappers/
m228_action52.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Mapper 228: Active Enterprises' *Action 52* and *Cheetahmen II* board
3//! (v2.9.6 "Roster").
4//!
5//! Written from `nesdev_wiki/output/INES_Mapper_228.md`. One register, written
6//! anywhere in `$8000-$FFFF`, is latched from the ADDRESS as well as the data:
7//!
8//! ```text
9//! Address           Data
10//! FEDCBA98 76543210 76543210
11//! 1.MHHPPP PPS.CCCC ......CC
12//!   ||||||  ||| ||||       ++- CHR bank bits 0-1
13//!   ||||||  ||| ++++---------- CHR bank bits 2-5 (8 KiB CHR bank at $0000)
14//!   ||||||  ||+--------------- PRG bank size: 0 = 32 KiB (P&~1 at $8000,
15//!   ||||||  ||                 P|1 at $C000), 1 = the same 16 KiB twice
16//!   |||+++--++---------------- 16 KiB PRG bank within the chip
17//!   |++----------------------- which 512 KiB PRG chip
18//!   +------------------------- mirroring: 0 vertical, 1 horizontal
19//! ```
20//!
21//! *Action 52* has three 512 KiB PRG chips, 0, 1 and 3; chip 2 does not exist
22//! and selecting it reads open bus. Its `.nes` image stores the three chips
23//! back to back (1.5 MiB), so chip 3 is the image's third 512 KiB. Any other
24//! image size is indexed directly and wrapped.
25//!
26//! The games "expect `$00` to be written to `$8000` on powerup/reset", so a
27//! reset clears the register ([`Mapper::reset`]). The rumoured 4x4-bit RAM at
28//! `$4020-$5FFF` "is definitely not present on either cartridge", so that
29//! window floats.
30
31// Bank arithmetic narrows values already reduced modulo a bank count, and the
32// accessors stay non-`const` to match the other mapper modules; both lints
33// are allowed crate-wide in the sibling modules for the same reasons.
34#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)]
35
36use crate::cartridge::Mirroring;
37use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
38use alloc::{boxed::Box, format, vec, vec::Vec};
39
40const PRG_BANK_16K: usize = 0x4000;
41const CHR_BANK_8K: usize = 0x2000;
42const CHIP: usize = 512 * 1024;
43const SAVE_STATE_VERSION: u8 = 1;
44
45/// Mapper 228 (*Action 52* / *Cheetahmen II*).
46pub struct Action52M228 {
47    prg_rom: Box<[u8]>,
48    chr: Box<[u8]>,
49    chr_is_ram: bool,
50    vram: Box<[u8]>,
51    /// The latched address, `$8000-$FFFF`.
52    addr: u16,
53    /// The latched data (CHR bits 0-1).
54    data: u8,
55}
56
57impl Action52M228 {
58    /// Construct the board.
59    ///
60    /// # Errors
61    ///
62    /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 16 KiB
63    /// or CHR is not a multiple of 8 KiB.
64    pub fn new(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result<Self, MapperError> {
65        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
66            return Err(MapperError::Invalid(format!(
67                "mapper 228 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
68                prg_rom.len()
69            )));
70        }
71        if !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
72            return Err(MapperError::Invalid(format!(
73                "mapper 228 CHR-ROM size {} is not a multiple of 8 KiB",
74                chr_rom.len()
75            )));
76        }
77        let chr_is_ram = chr_rom.is_empty();
78        let chr = if chr_is_ram {
79            vec![0u8; CHR_BANK_8K].into_boxed_slice()
80        } else {
81            chr_rom
82        };
83        Ok(Self {
84            prg_rom,
85            chr,
86            chr_is_ram,
87            vram: vec![0u8; 0x800].into_boxed_slice(),
88            addr: 0x8000,
89            data: 0,
90        })
91    }
92
93    fn chip(&self) -> usize {
94        usize::from((self.addr >> 11) & 0x03)
95    }
96
97    /// Chip 2 is absent on the three-chip *Action 52* image.
98    fn chip_absent(&self) -> bool {
99        self.prg_rom.len() == 3 * CHIP && self.chip() == 2
100    }
101
102    /// The byte offset in `prg_rom` for `addr` (`$8000-$FFFF`).
103    fn prg_offset(&self, cpu: u16) -> usize {
104        let page = usize::from((self.addr >> 6) & 0x1F);
105        let page = if self.addr & 0x0020 != 0 {
106            page
107        } else if cpu < 0xC000 {
108            page & !1
109        } else {
110            page | 1
111        };
112        let chip = match (self.prg_rom.len() == 3 * CHIP, self.chip()) {
113            // The image stores chips 0, 1, 3.
114            (true, 3) => 2,
115            (_, c) => c,
116        };
117        let bank = (chip * 32 + page) % (self.prg_rom.len() / PRG_BANK_16K);
118        bank * PRG_BANK_16K + (usize::from(cpu) & 0x3FFF)
119    }
120
121    fn chr_bank(&self) -> usize {
122        let bank = (usize::from(self.addr & 0x0F) << 2) | usize::from(self.data & 0x03);
123        bank % (self.chr.len() / CHR_BANK_8K)
124    }
125
126    fn mirroring(&self) -> Mirroring {
127        if self.addr & 0x2000 != 0 {
128            Mirroring::Horizontal
129        } else {
130            Mirroring::Vertical
131        }
132    }
133
134    fn nt_offset(&self, addr: u16) -> usize {
135        let table = ((addr - 0x2000) / 0x400) & 0x03;
136        self.mirroring().physical_bank(table as u8) * 0x400 + (usize::from(addr) & 0x3FF)
137    }
138}
139
140impl Mapper for Action52M228 {
141    fn caps(&self) -> MapperCaps {
142        MapperCaps::NONE
143    }
144
145    fn reset(&mut self) {
146        self.addr = 0x8000;
147        self.data = 0;
148    }
149
150    fn cpu_read_unmapped(&self, addr: u16) -> bool {
151        match addr {
152            0x4020..=0x7FFF => true,
153            _ => self.chip_absent(),
154        }
155    }
156
157    fn cpu_read(&mut self, addr: u16) -> u8 {
158        if addr >= 0x8000 && !self.chip_absent() {
159            self.prg_rom[self.prg_offset(addr)]
160        } else {
161            0
162        }
163    }
164
165    fn cpu_write(&mut self, addr: u16, value: u8) {
166        if addr >= 0x8000 {
167            self.addr = addr;
168            self.data = value;
169        }
170    }
171
172    fn chr_phys(&self, addr: u16) -> Option<u32> {
173        if self.chr_is_ram {
174            None
175        } else {
176            u32::try_from(self.chr_bank() * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok()
177        }
178    }
179
180    fn ppu_read(&mut self, addr: u16) -> u8 {
181        let addr = addr & 0x3FFF;
182        match addr {
183            0x0000..=0x1FFF => self.chr[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
184            0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
185            _ => 0,
186        }
187    }
188
189    fn ppu_write(&mut self, addr: u16, value: u8) {
190        let addr = addr & 0x3FFF;
191        match addr {
192            0x0000..=0x1FFF if self.chr_is_ram => {
193                let off = self.chr_bank() * CHR_BANK_8K + usize::from(addr);
194                self.chr[off] = value;
195            }
196            0x2000..=0x3EFF => {
197                let off = self.nt_offset(addr);
198                self.vram[off] = value;
199            }
200            _ => {}
201        }
202    }
203
204    fn nametable_address(&self, addr: u16) -> u16 {
205        u16::try_from(self.nt_offset(addr)).unwrap_or(0)
206    }
207
208    fn current_mirroring(&self) -> Mirroring {
209        self.mirroring()
210    }
211
212    fn debug_info(&self) -> MapperDebugInfo {
213        let mut info = MapperDebugInfo {
214            mapper_id: 228,
215            name: "Action 52 (228)".into(),
216            mirroring: crate::mapper::mirroring_name(self.mirroring()),
217            ..Default::default()
218        };
219        info.prg_banks
220            .push(("chip".into(), format!("{}", self.chip())));
221        info.prg_banks
222            .push(("page".into(), format!("{:#04x}", (self.addr >> 6) & 0x1F)));
223        info.chr_banks
224            .push(("bank".into(), format!("{:#04x}", self.chr_bank())));
225        info
226    }
227
228    fn save_state(&self) -> Vec<u8> {
229        let mut out = Vec::with_capacity(4 + self.vram.len() + CHR_BANK_8K);
230        out.push(SAVE_STATE_VERSION);
231        out.extend_from_slice(&self.addr.to_le_bytes());
232        out.push(self.data);
233        out.extend_from_slice(&self.vram);
234        if self.chr_is_ram {
235            out.extend_from_slice(&self.chr);
236        }
237        out
238    }
239
240    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
241        let chr = if self.chr_is_ram { self.chr.len() } else { 0 };
242        let expected = 4 + self.vram.len() + chr;
243        if data.len() != expected {
244            return Err(MapperError::WrongLength {
245                expected,
246                got: data.len(),
247            });
248        }
249        if data[0] != SAVE_STATE_VERSION {
250            return Err(MapperError::UnsupportedVersion(data[0]));
251        }
252        self.addr = u16::from_le_bytes([data[1], data[2]]) | 0x8000;
253        self.data = data[3];
254        self.vram.copy_from_slice(&data[4..4 + self.vram.len()]);
255        if self.chr_is_ram {
256            self.chr.copy_from_slice(&data[4 + self.vram.len()..]);
257        }
258        Ok(())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// Every 16 KiB PRG bank opens with its index; every 8 KiB CHR bank too.
267    fn board(prg_16k: usize, chr_8k: usize) -> Action52M228 {
268        let mut prg = vec![0u8; prg_16k * PRG_BANK_16K];
269        for b in 0..prg_16k {
270            prg[b * PRG_BANK_16K] = b as u8;
271        }
272        let mut chr = vec![0u8; chr_8k * CHR_BANK_8K];
273        for b in 0..chr_8k {
274            chr[b * CHR_BANK_8K] = b as u8;
275        }
276        Action52M228::new(prg.into_boxed_slice(), chr.into_boxed_slice()).unwrap()
277    }
278
279    /// The register address for the mirroring bit, chip, page, size bit and
280    /// CHR high bits.
281    fn reg(horizontal: bool, chip: u16, page: u16, mirrored_16k: bool, chr_hi: u16) -> u16 {
282        0x8000
283            | (u16::from(horizontal) << 13)
284            | (chip << 11)
285            | (page << 6)
286            | (u16::from(mirrored_16k) << 5)
287            | chr_hi
288    }
289
290    #[test]
291    fn action52_chips_0_1_3_and_chip_2_is_open_bus() {
292        let mut m = board(96, 64); // 1.5 MiB PRG, 512 KiB CHR.
293        m.cpu_write(reg(false, 0, 5, true, 0), 0);
294        assert_eq!(m.cpu_read(0x8000), 5);
295        m.cpu_write(reg(false, 1, 5, true, 0), 0);
296        assert_eq!(m.cpu_read(0x8000), 32 + 5);
297        m.cpu_write(reg(false, 3, 5, true, 0), 0);
298        assert_eq!(
299            m.cpu_read(0x8000),
300            64 + 5,
301            "chip 3 is the image's third chip"
302        );
303        m.cpu_write(reg(false, 2, 5, true, 0), 0);
304        assert!(m.cpu_read_unmapped(0x8000), "chip 2 does not exist");
305        assert!(m.cpu_read_unmapped(0xC000));
306    }
307
308    #[test]
309    fn prg_size_bit_selects_32k_or_mirrored_16k() {
310        let mut m = board(96, 64);
311        m.cpu_write(reg(false, 0, 7, false, 0), 0);
312        assert_eq!(m.cpu_read(0x8000), 6, "32 KiB: P & ~1 at $8000");
313        assert_eq!(m.cpu_read(0xC000), 7, "32 KiB: P | 1 at $C000");
314        m.cpu_write(reg(false, 0, 6, true, 0), 0);
315        assert_eq!(m.cpu_read(0x8000), 6);
316        assert_eq!(m.cpu_read(0xC000), 6, "16 KiB mirrored");
317    }
318
319    #[test]
320    fn chr_bank_takes_four_address_bits_and_two_data_bits() {
321        let mut m = board(96, 64);
322        m.cpu_write(reg(false, 0, 0, true, 0x0B), 0x02);
323        assert_eq!(m.ppu_read(0x0000), (0x0B << 2) | 2);
324        m.cpu_write(reg(false, 0, 0, true, 0x0B), 0xFD); // only D1-D0 count
325        assert_eq!(m.ppu_read(0x0000), (0x0B << 2) | 1);
326    }
327
328    #[test]
329    fn mirroring_bit_and_reset() {
330        let mut m = board(96, 64);
331        m.cpu_write(reg(true, 1, 3, true, 1), 3);
332        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
333        m.reset();
334        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
335        assert_eq!(m.cpu_read(0x8000), 0, "reset = a write of $00 to $8000");
336        assert_eq!(m.ppu_read(0x0000), 0);
337    }
338
339    #[test]
340    fn a_single_chip_image_is_indexed_directly() {
341        // Cheetahmen II: 256 KiB PRG. Chip 2 is not special on it.
342        let mut m = board(16, 8);
343        m.cpu_write(reg(false, 2, 3, true, 0), 0);
344        assert!(!m.cpu_read_unmapped(0x8000));
345        assert_eq!(m.cpu_read(0x8000), 3, "(2*32 + 3) wraps to bank 3");
346    }
347
348    #[test]
349    fn state_round_trips() {
350        let mut a = board(96, 64);
351        a.cpu_write(reg(true, 3, 9, false, 5), 1);
352        a.ppu_write(0x2001, 0x44);
353        let blob = a.save_state();
354        let mut b = board(96, 64);
355        b.load_state(&blob).unwrap();
356        assert_eq!(b.cpu_read(0x8000), a.cpu_read(0x8000));
357        assert_eq!(b.ppu_read(0x0000), a.ppu_read(0x0000));
358        assert_eq!(b.ppu_read(0x2001), 0x44);
359        assert_eq!(b.save_state(), blob);
360    }
361}