Skip to main content

rustynes_mappers/
m087_jaleco87.rs

1//! Jaleco/Konami JF-05..JF-19 CNROM-style board (iNES mapper 87).
2//!
3//! A simple discrete-logic CHR-bank-switch board (Argus, Choplifter, The
4//! Goonies, City Connection, Field Combat, etc.). PRG-ROM is fixed
5//! (16 KiB mirrored or 32 KiB, NROM-style). The single register lives in the
6//! `$6000-$7FFF` window (it is wired to the cartridge's PRG-RAM enable area,
7//! not the `$8000-$FFFF` ROM space), and selects the 8 KiB CHR bank using a
8//! **bit-swapped** 2-bit field:
9//!
10//! ```text
11//!   $6000-$7FFF [.... ..LH]  CHR bank = (H << 0) | (L << 1)
12//!                            i.e. ((v >> 1) & 1) | ((v << 1) & 2)
13//! ```
14//!
15//! There is **no IRQ**. Mirroring is fixed from the iNES header.
16//!
17//! See `docs/mappers.md` §Mapper coverage matrix.
18
19#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
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_16K: usize = 0x4000;
27const PRG_BANK_32K: usize = 0x8000;
28const CHR_BANK_8K: usize = 0x2000;
29const NAMETABLE_SIZE: usize = 0x0400;
30const NAMETABLE_SIZE_U16: u16 = 0x0400;
31
32const SAVE_STATE_VERSION: u8 = 1;
33
34/// Jaleco/Konami mapper 87.
35pub struct Jaleco87 {
36    prg_rom: Box<[u8]>,
37    chr_rom: Box<[u8]>,
38    vram: Box<[u8]>,
39    chr_bank: u8,
40    mirroring: Mirroring,
41}
42
43impl Jaleco87 {
44    /// Construct a new mapper-87 board.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`MapperError::Invalid`] when the PRG-ROM is not 16/32 KiB or
49    /// CHR-ROM is empty / not a multiple of 8 KiB.
50    pub fn new(
51        prg_rom: Box<[u8]>,
52        chr_rom: Box<[u8]>,
53        mirroring: Mirroring,
54    ) -> Result<Self, MapperError> {
55        if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
56            return Err(MapperError::Invalid(format!(
57                "Mapper-87 expects 16 or 32 KiB PRG, got {} bytes",
58                prg_rom.len()
59            )));
60        }
61        if chr_rom.is_empty() || !chr_rom.len().is_multiple_of(CHR_BANK_8K) {
62            return Err(MapperError::Invalid(format!(
63                "Mapper-87 expects non-empty CHR-ROM in 8 KiB units, got {} bytes",
64                chr_rom.len()
65            )));
66        }
67        Ok(Self {
68            prg_rom,
69            chr_rom,
70            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
71            chr_bank: 0,
72            mirroring,
73        })
74    }
75
76    const fn nametable_offset(&self, addr: u16) -> usize {
77        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
78        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
79        let physical = self.mirroring.physical_bank(table);
80        physical * NAMETABLE_SIZE + local
81    }
82
83    fn read_prg(&self, addr: u16) -> u8 {
84        let off = (addr - 0x8000) as usize;
85        if self.prg_rom.len() == PRG_BANK_16K {
86            self.prg_rom[off & (PRG_BANK_16K - 1)]
87        } else {
88            self.prg_rom[off]
89        }
90    }
91}
92
93impl Mapper for Jaleco87 {
94    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_087: "Fixed H or V, controlled by solder pads"`, so a
95    /// per-game database correction of a wrong header bit is safe here.
96    fn has_hardwired_mirroring(&self) -> bool {
97        true
98    }
99
100    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
101    // skips all four per-CPU-cycle dispatches for this board.
102    fn caps(&self) -> MapperCaps {
103        MapperCaps::NONE
104    }
105
106    fn cpu_read(&mut self, addr: u16) -> u8 {
107        if (0x8000..=0xFFFF).contains(&addr) {
108            self.read_prg(addr)
109        } else {
110            0
111        }
112    }
113
114    fn cpu_write(&mut self, addr: u16, value: u8) {
115        if (0x6000..=0x7FFF).contains(&addr) {
116            // The 2-bit CHR-select field is bit-swapped: D0->bank bit 1,
117            // D1->bank bit 0.
118            self.chr_bank = ((value >> 1) & 0x01) | ((value << 1) & 0x02);
119        }
120    }
121
122    fn ppu_read(&mut self, addr: u16) -> u8 {
123        let addr = addr & 0x3FFF;
124        match addr {
125            0x0000..=0x1FFF => {
126                let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
127                let bank = (self.chr_bank as usize) % bank_count;
128                self.chr_rom[bank * CHR_BANK_8K + (addr as usize)]
129            }
130            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
131            _ => 0,
132        }
133    }
134
135    fn ppu_write(&mut self, addr: u16, value: u8) {
136        let addr = addr & 0x3FFF;
137        if let 0x2000..=0x3EFF = addr {
138            let off = self.nametable_offset(addr);
139            self.vram[off] = value;
140        }
141        // CHR-ROM writes ignored.
142    }
143
144    fn current_mirroring(&self) -> Mirroring {
145        self.mirroring
146    }
147
148    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
149        let mut info = crate::mapper::MapperDebugInfo {
150            mapper_id: 87,
151            name: "Jaleco/Konami (87)".into(),
152            mirroring: crate::mapper::mirroring_name(self.mirroring),
153            ..Default::default()
154        };
155        info.chr_banks
156            .push(("CHR8k".into(), format!("{:#04x}", self.chr_bank)));
157        info
158    }
159
160    fn save_state(&self) -> Vec<u8> {
161        let mut out = Vec::with_capacity(2 + self.vram.len());
162        out.push(SAVE_STATE_VERSION);
163        out.push(self.chr_bank);
164        out.extend_from_slice(&self.vram);
165        out
166    }
167
168    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
169        let expected = 2 + self.vram.len();
170        if data.len() != expected {
171            return Err(MapperError::WrongLength {
172                expected,
173                got: data.len(),
174            });
175        }
176        if data[0] != SAVE_STATE_VERSION {
177            return Err(MapperError::UnsupportedVersion(data[0]));
178        }
179        self.chr_bank = data[1];
180        self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
181        Ok(())
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::cast_possible_truncation)]
187mod tests {
188    use super::*;
189
190    fn synth_prg(bytes: usize) -> Box<[u8]> {
191        let mut v = vec![0u8; bytes];
192        for (i, b) in v.iter_mut().enumerate() {
193            *b = (i & 0xFF) as u8;
194        }
195        v.into_boxed_slice()
196    }
197
198    fn synth_chr(banks: usize) -> Box<[u8]> {
199        let mut v = vec![0u8; banks * CHR_BANK_8K];
200        for b in 0..banks {
201            v[b * CHR_BANK_8K] = b as u8;
202        }
203        v.into_boxed_slice()
204    }
205
206    #[test]
207    fn bit_swapped_chr_select() {
208        let mut m =
209            Jaleco87::new(synth_prg(PRG_BANK_32K), synth_chr(4), Mirroring::Vertical).unwrap();
210        // value 0b00 -> bank 0
211        m.cpu_write(0x6000, 0b00);
212        assert_eq!(m.ppu_read(0x0000), 0);
213        // value 0b01 (D0 set) -> bank bit 1 set = bank 2
214        m.cpu_write(0x6000, 0b01);
215        assert_eq!(m.ppu_read(0x0000), 2);
216        // value 0b10 (D1 set) -> bank bit 0 set = bank 1
217        m.cpu_write(0x6000, 0b10);
218        assert_eq!(m.ppu_read(0x0000), 1);
219        // value 0b11 -> bank 3
220        m.cpu_write(0x6000, 0b11);
221        assert_eq!(m.ppu_read(0x0000), 3);
222    }
223
224    #[test]
225    fn write_outside_6000_7fff_ignored() {
226        let mut m =
227            Jaleco87::new(synth_prg(PRG_BANK_32K), synth_chr(4), Mirroring::Vertical).unwrap();
228        m.cpu_write(0x6000, 0b01); // bank 2
229        assert_eq!(m.ppu_read(0x0000), 2);
230        m.cpu_write(0x8000, 0b11); // ROM write must NOT change the bank
231        assert_eq!(m.ppu_read(0x0000), 2);
232    }
233
234    #[test]
235    fn prg_16k_mirrors() {
236        let mut m =
237            Jaleco87::new(synth_prg(PRG_BANK_16K), synth_chr(2), Mirroring::Vertical).unwrap();
238        assert_eq!(m.cpu_read(0x8000), m.cpu_read(0xC000));
239    }
240
241    #[test]
242    fn save_state_round_trip() {
243        let mut m =
244            Jaleco87::new(synth_prg(PRG_BANK_32K), synth_chr(4), Mirroring::Vertical).unwrap();
245        m.cpu_write(0x6000, 0b11);
246        let blob = m.save_state();
247        let mut m2 =
248            Jaleco87::new(synth_prg(PRG_BANK_32K), synth_chr(4), Mirroring::Vertical).unwrap();
249        m2.load_state(&blob).unwrap();
250        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
251    }
252}