Skip to main content

rustynes_mappers/
m078_irem_jaleco78.rs

1//! Irem/Jaleco mapper 78 (Holy Diver / Uchuusen - Cosmo Carrier).
2//!
3//! A UxROM + CNROM hybrid (discrete logic): a single bank-select register at
4//! `$8000-$FFFF` switches a 16 KiB PRG bank, an 8 KiB CHR bank, and a single
5//! mirroring bit. The two games wire that mirroring bit differently, so it is
6//! **submapper-selected** (`nesdev_wiki/INES_Mapper_078.xhtml`):
7//!
8//! - Submapper 3 (Holy Diver): bit 3 toggles Horizontal / Vertical.
9//! - Submapper 1 (Cosmo Carrier): bit 3 toggles single-screen A / B (the bit
10//!   is wired directly to CIRAM A10 like `AxROM`).
11//!
12//! The two modes are mutually incompatible — running one game's ROM under the
13//! other's wiring glitches. iNES 1.0 images often set the "alternative
14//! nametables" header flag for Holy Diver and clear it for Cosmo Carrier; NES
15//! 2.0 uses the submapper byte. The dispatcher in `lib.rs` follows exactly
16//! that: a named submapper wins, and without one the flag picks the wiring
17//! (set = Holy Diver, clear = Cosmo Carrier, the common emulator default).
18//!
19//! # Bank Select (`$8000-$FFFF`)
20//!
21//! ```text
22//! 7  bit  0
23//! CCCC MPPP
24//! |||| |+++- 16 KiB PRG bank for $8000-$BFFF
25//! |||| +---- Mirroring (Holy Diver: 0=H,1=V; Cosmo Carrier: 0=1scA,1=1scB)
26//! ++++------ 8 KiB CHR bank for $0000-$1FFF
27//! ```
28//!
29//! `$C000-$FFFF` is fixed to the last 16 KiB PRG bank. No PRG-RAM, no IRQ.
30//! Subject to bus conflicts (the value written must match the ROM byte) — we
31//! do not model bus conflicts here (none of the two games rely on a conflict
32//! that differs from a plain write).
33
34#![allow(
35    clippy::cast_possible_truncation,
36    clippy::cast_lossless,
37    clippy::missing_const_for_fn,
38    clippy::doc_markdown
39)]
40
41use crate::cartridge::Mirroring;
42use crate::mapper::{Mapper, MapperCaps, MapperError};
43use alloc::{boxed::Box, vec::Vec};
44use alloc::{format, vec};
45
46const PRG_BANK_16K: usize = 0x4000;
47const CHR_BANK_8K: usize = 0x2000;
48const NAMETABLE_SIZE: usize = 0x0400;
49const NAMETABLE_SIZE_U16: u16 = 0x0400;
50
51const SAVE_STATE_VERSION: u8 = 1;
52
53/// Mirroring wiring variant, selected by NES 2.0 submapper.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum M78Variant {
56    /// Submapper 3 — Holy Diver: bit 3 = Horizontal (0) / Vertical (1).
57    HolyDiver,
58    /// Submapper 1 — Cosmo Carrier: bit 3 = single-screen A (0) / B (1).
59    CosmoCarrier,
60}
61
62/// Mapper 78 (Holy Diver / Cosmo Carrier).
63pub struct M78 {
64    prg_rom: Box<[u8]>,
65    chr: Box<[u8]>,
66    vram: Box<[u8]>,
67    chr_is_ram: bool,
68
69    prg_bank: u8,
70    chr_bank: u8,
71    mirroring: Mirroring,
72    variant: M78Variant,
73}
74
75impl M78 {
76    /// Construct a new mapper-78 instance.
77    ///
78    /// `prg_rom` must be a non-zero multiple of 16 KiB; CHR-ROM (when present)
79    /// must be a multiple of 8 KiB. CHR-RAM (8 KiB) is allocated when no
80    /// CHR-ROM is supplied. `variant` selects the mirroring wiring.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`MapperError::Invalid`] on size mismatch.
85    pub fn new(
86        prg_rom: Box<[u8]>,
87        chr_rom: Box<[u8]>,
88        variant: M78Variant,
89    ) -> Result<Self, MapperError> {
90        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
91            return Err(MapperError::Invalid(format!(
92                "Mapper-78 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
93                prg_rom.len()
94            )));
95        }
96        let chr_is_ram = chr_rom.is_empty();
97        let chr: Box<[u8]> = if chr_is_ram {
98            vec![0u8; CHR_BANK_8K].into_boxed_slice()
99        } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
100            chr_rom
101        } else {
102            return Err(MapperError::Invalid(format!(
103                "Mapper-78 CHR-ROM size {} is not a multiple of 8 KiB",
104                chr_rom.len()
105            )));
106        };
107        // Default mirroring at power-on: bank-select bit 3 = 0.
108        let mirroring = match variant {
109            M78Variant::HolyDiver => Mirroring::Horizontal,
110            M78Variant::CosmoCarrier => Mirroring::SingleScreenA,
111        };
112        Ok(Self {
113            prg_rom,
114            chr,
115            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
116            chr_is_ram,
117            prg_bank: 0,
118            chr_bank: 0,
119            mirroring,
120            variant,
121        })
122    }
123
124    fn prg_offset(&self, addr: u16) -> usize {
125        let total = (self.prg_rom.len() / PRG_BANK_16K).max(1);
126        let last = total - 1;
127        let bank = match addr {
128            0x8000..=0xBFFF => (self.prg_bank as usize) % total,
129            _ => last,
130        };
131        bank * PRG_BANK_16K + (addr as usize & 0x3FFF)
132    }
133
134    fn chr_offset(&self, addr: u16) -> usize {
135        let addr = (addr & 0x1FFF) as usize;
136        let total_8k = (self.chr.len() / CHR_BANK_8K).max(1);
137        let bank = (self.chr_bank as usize) % total_8k;
138        bank * CHR_BANK_8K + addr
139    }
140
141    fn nametable_offset(&self, addr: u16) -> usize {
142        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
143        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
144        let physical = self.mirroring.physical_bank(table);
145        physical * NAMETABLE_SIZE + local
146    }
147}
148
149impl Mapper for M78 {
150    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
151    // skips all four per-CPU-cycle dispatches for this board.
152    fn caps(&self) -> MapperCaps {
153        MapperCaps::NONE
154    }
155
156    fn cpu_read(&mut self, addr: u16) -> u8 {
157        match addr {
158            0x8000..=0xFFFF => {
159                let off = self.prg_offset(addr);
160                self.prg_rom[off % self.prg_rom.len()]
161            }
162            _ => 0,
163        }
164    }
165
166    fn cpu_write(&mut self, addr: u16, value: u8) {
167        if let 0x8000..=0xFFFF = addr {
168            self.prg_bank = value & 0x07;
169            self.chr_bank = (value >> 4) & 0x0F;
170            let mbit = (value & 0x08) != 0;
171            self.mirroring = match self.variant {
172                M78Variant::HolyDiver => {
173                    if mbit {
174                        Mirroring::Vertical
175                    } else {
176                        Mirroring::Horizontal
177                    }
178                }
179                M78Variant::CosmoCarrier => {
180                    if mbit {
181                        Mirroring::SingleScreenB
182                    } else {
183                        Mirroring::SingleScreenA
184                    }
185                }
186            };
187        }
188    }
189
190    fn ppu_read(&mut self, addr: u16) -> u8 {
191        let addr = addr & 0x3FFF;
192        match addr {
193            0x0000..=0x1FFF => {
194                let off = self.chr_offset(addr);
195                self.chr[off % self.chr.len()]
196            }
197            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
198            _ => 0,
199        }
200    }
201
202    fn ppu_write(&mut self, addr: u16, value: u8) {
203        let addr = addr & 0x3FFF;
204        match addr {
205            0x0000..=0x1FFF => {
206                if self.chr_is_ram {
207                    let off = self.chr_offset(addr);
208                    let len = self.chr.len();
209                    self.chr[off % len] = value;
210                }
211            }
212            0x2000..=0x3EFF => {
213                let off = self.nametable_offset(addr) % self.vram.len();
214                self.vram[off] = value;
215            }
216            _ => {}
217        }
218    }
219
220    fn nametable_address(&self, addr: u16) -> u16 {
221        let off = self.nametable_offset(addr);
222        u16::try_from(off & 0x07FF).unwrap_or(0)
223    }
224
225    fn current_mirroring(&self) -> Mirroring {
226        self.mirroring
227    }
228
229    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
230        let mut info = crate::mapper::MapperDebugInfo {
231            mapper_id: 78,
232            name: match self.variant {
233                M78Variant::HolyDiver => "Mapper 78 (Holy Diver)".into(),
234                M78Variant::CosmoCarrier => "Mapper 78 (Cosmo Carrier)".into(),
235            },
236            mirroring: crate::mapper::mirroring_name(self.mirroring),
237            ..Default::default()
238        };
239        info.prg_banks
240            .push(("PRG".into(), format!("{:#04x}", self.prg_bank)));
241        info.chr_banks
242            .push(("CHR".into(), format!("{:#04x}", self.chr_bank)));
243        info
244    }
245
246    fn save_state(&self) -> Vec<u8> {
247        let mut out = Vec::with_capacity(
248            8 + self.vram.len() + if self.chr_is_ram { self.chr.len() } else { 0 },
249        );
250        out.push(SAVE_STATE_VERSION);
251        out.push(self.prg_bank);
252        out.push(self.chr_bank);
253        out.push(self.mirroring as u8);
254        out.push(match self.variant {
255            M78Variant::HolyDiver => 0,
256            M78Variant::CosmoCarrier => 1,
257        });
258        out.extend_from_slice(&self.vram);
259        if self.chr_is_ram {
260            out.extend_from_slice(&self.chr);
261        }
262        out
263    }
264
265    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
266        let chr_part = if self.chr_is_ram { self.chr.len() } else { 0 };
267        // 1 + 1 + 1 + 1 + 1
268        let scalar_len = 5;
269        let expected = scalar_len + self.vram.len() + chr_part;
270        if data.len() != expected {
271            return Err(MapperError::WrongLength {
272                expected,
273                got: data.len(),
274            });
275        }
276        if data[0] != SAVE_STATE_VERSION {
277            return Err(MapperError::UnsupportedVersion(data[0]));
278        }
279        self.prg_bank = data[1];
280        self.chr_bank = data[2];
281        self.mirroring = match data[3] {
282            0 => Mirroring::Horizontal,
283            1 => Mirroring::Vertical,
284            2 => Mirroring::SingleScreenA,
285            3 => Mirroring::SingleScreenB,
286            4 => Mirroring::FourScreen,
287            5 => Mirroring::MapperControlled,
288            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
289        };
290        self.variant = match data[4] {
291            0 => M78Variant::HolyDiver,
292            1 => M78Variant::CosmoCarrier,
293            other => return Err(MapperError::Invalid(format!("variant {other}"))),
294        };
295        let mut c = 5usize;
296        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
297        c += self.vram.len();
298        if self.chr_is_ram {
299            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
300        }
301        Ok(())
302    }
303}
304
305#[cfg(test)]
306#[allow(clippy::cast_possible_truncation)]
307mod tests {
308    use super::*;
309
310    fn synth_prg(banks_16k: usize) -> Box<[u8]> {
311        let mut v = vec![0u8; banks_16k * PRG_BANK_16K];
312        for b in 0..banks_16k {
313            v[b * PRG_BANK_16K] = b as u8;
314        }
315        v.into_boxed_slice()
316    }
317
318    fn synth_chr(banks_8k: usize) -> Box<[u8]> {
319        let mut v = vec![0u8; banks_8k * CHR_BANK_8K];
320        for b in 0..banks_8k {
321            v[b * CHR_BANK_8K] = b as u8;
322        }
323        v.into_boxed_slice()
324    }
325
326    fn fresh_hd() -> M78 {
327        M78::new(synth_prg(8), synth_chr(16), M78Variant::HolyDiver).unwrap()
328    }
329
330    fn fresh_cc() -> M78 {
331        M78::new(synth_prg(8), synth_chr(16), M78Variant::CosmoCarrier).unwrap()
332    }
333
334    #[test]
335    fn prg_low_bank_switch_high_fixed() {
336        let mut m = fresh_hd();
337        assert_eq!(m.cpu_read(0x8000), 0);
338        assert_eq!(m.cpu_read(0xC000), 7); // last fixed
339        m.cpu_write(0x8000, 0x05); // PRG bits 0-2 = 5
340        assert_eq!(m.cpu_read(0x8000), 5);
341        assert_eq!(m.cpu_read(0xC000), 7);
342    }
343
344    #[test]
345    fn chr_bank_switch_high_nibble() {
346        let mut m = fresh_hd();
347        m.cpu_write(0x8000, 0x30); // CHR bits 4-7 = 3
348        assert_eq!(m.ppu_read(0x0000), 3);
349    }
350
351    #[test]
352    fn holy_diver_mirroring_h_v() {
353        let mut m = fresh_hd();
354        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
355        m.cpu_write(0x8000, 0x08); // bit 3 set -> Vertical
356        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
357        m.cpu_write(0x8000, 0x00);
358        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
359    }
360
361    #[test]
362    fn cosmo_carrier_mirroring_single_screen() {
363        let mut m = fresh_cc();
364        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
365        m.cpu_write(0x8000, 0x08); // bit 3 set -> 1scB
366        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
367        m.cpu_write(0x8000, 0x00);
368        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
369    }
370
371    #[test]
372    fn no_irq() {
373        let mut m = fresh_hd();
374        for _ in 0..1000 {
375            m.notify_cpu_cycle();
376            m.notify_a12(true);
377            m.notify_a12(false);
378        }
379        assert!(!m.irq_pending());
380    }
381
382    #[test]
383    fn save_state_round_trip() {
384        let mut m = fresh_cc();
385        m.cpu_write(0x8000, 0x3D); // CHR=3, mirroring bit set, PRG=5
386        m.ppu_write(0x2000, 0x66);
387        let blob = m.save_state();
388        let mut m2 = fresh_cc();
389        m2.load_state(&blob).unwrap();
390        assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
391        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
392        assert_eq!(m.current_mirroring(), m2.current_mirroring());
393        assert_eq!(m.ppu_read(0x2000), m2.ppu_read(0x2000));
394    }
395}