Skip to main content

rustynes_mappers/
m180_nichibutsu180.rs

1//! Nichibutsu / Hokutosha (mapper 180) -- Crazy Climber.
2//!
3//! An inverted `UxROM` board: the *low* 16 KiB is fixed and the *high* 16 KiB
4//! switchable, the opposite of stock `UxROM`. That inversion is not a design
5//! flourish -- Crazy Climber ships with a special controller, and the board is
6//! wired so the fixed half holds the code that must always be reachable.
7//! Writes are subject to a bus conflict, as on any ungated discrete board.
8//!
9//! A best-effort (Tier-2) board: register-decode correctness verified against
10//! the `GeraNES` reference emulator (cross-referenced, not copied)
11//! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math
12//! is direct slice indexing and every bank select wraps with `% count`, so a
13//! register write can never index out of bounds -- required for the `#![no_std]`
14//! chip stack, which cannot afford a panic on a register access.
15//!
16//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
17//! and `docs/mappers.md` §Mapper coverage matrix.
18
19use crate::cartridge::Mirroring;
20use crate::mapper::{Mapper, MapperCaps, MapperError};
21use alloc::{boxed::Box, vec::Vec};
22use alloc::{format, vec};
23
24const PRG_BANK_16K: usize = 0x4000;
25const CHR_BANK_8K: usize = 0x2000;
26const NAMETABLE_SIZE: usize = 0x0400;
27const NAMETABLE_SIZE_U16: u16 = 0x0400;
28
29const SAVE_STATE_VERSION: u8 = 1;
30
31// ---------------------------------------------------------------------------
32// Shared nametable helper (mirrors the one in the other simple-mapper modules).
33// ---------------------------------------------------------------------------
34
35const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
36    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
37    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
38    let physical = mirroring.physical_bank(table);
39    physical * NAMETABLE_SIZE + local
40}
41
42/// Mapper 180 (Nichibutsu `UNROM`-inverted, Crazy Climber).
43pub struct Nichibutsu180 {
44    prg_rom: Box<[u8]>,
45    chr: Box<[u8]>,
46    vram: Box<[u8]>,
47    chr_is_ram: bool,
48    prg_bank: u8,
49    mirroring: Mirroring,
50}
51
52impl Nichibutsu180 {
53    /// Construct a new mapper 180 board.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
58    /// 16 KiB, or CHR-ROM (when present) is not 8 KiB.
59    pub fn new(
60        prg_rom: Box<[u8]>,
61        chr_rom: Box<[u8]>,
62        mirroring: Mirroring,
63    ) -> Result<Self, MapperError> {
64        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
65            return Err(MapperError::Invalid(format!(
66                "mapper 180 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
67                prg_rom.len()
68            )));
69        }
70        let chr_is_ram = chr_rom.is_empty();
71        let chr: Box<[u8]> = if chr_is_ram {
72            vec![0u8; CHR_BANK_8K].into_boxed_slice()
73        } else if chr_rom.len() == CHR_BANK_8K {
74            chr_rom
75        } else {
76            return Err(MapperError::Invalid(format!(
77                "mapper 180 expects 8 KiB CHR (RAM or ROM); got {} bytes",
78                chr_rom.len()
79            )));
80        };
81        Ok(Self {
82            prg_rom,
83            chr,
84            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
85            chr_is_ram,
86            prg_bank: 0,
87            mirroring,
88        })
89    }
90
91    fn read_prg(&self, bank: usize, offset_in_bank: usize) -> u8 {
92        let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
93        let bank = bank % count;
94        self.prg_rom[bank * PRG_BANK_16K + offset_in_bank]
95    }
96}
97
98impl Mapper for Nichibutsu180 {
99    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_180: "Fixed H or V, controlled by solder pads"`, so a
100    /// per-game database correction of a wrong header bit is safe here.
101    fn has_hardwired_mirroring(&self) -> bool {
102        true
103    }
104
105    fn caps(&self) -> MapperCaps {
106        MapperCaps::NONE
107    }
108
109    fn cpu_read(&mut self, addr: u16) -> u8 {
110        match addr {
111            0x8000..=0xBFFF => self.read_prg(0, addr as usize - 0x8000),
112            0xC000..=0xFFFF => self.read_prg(self.prg_bank as usize, addr as usize - 0xC000),
113            _ => 0,
114        }
115    }
116
117    fn cpu_write(&mut self, addr: u16, value: u8) {
118        if (0x8000..=0xFFFF).contains(&addr) {
119            // Bus conflict: AND with the byte currently visible at addr.
120            let prg_byte = self.cpu_read_at_for_conflict(addr);
121            let effective = value & prg_byte;
122            self.prg_bank = effective & 0x07;
123        }
124    }
125
126    fn ppu_read(&mut self, addr: u16) -> u8 {
127        let addr = addr & 0x3FFF;
128        match addr {
129            0x0000..=0x1FFF => self.chr[addr as usize],
130            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
131            _ => 0,
132        }
133    }
134
135    fn ppu_write(&mut self, addr: u16, value: u8) {
136        let addr = addr & 0x3FFF;
137        match addr {
138            0x0000..=0x1FFF => {
139                if self.chr_is_ram {
140                    self.chr[addr as usize] = value;
141                }
142            }
143            0x2000..=0x3EFF => {
144                let off = nametable_offset(addr, self.mirroring);
145                self.vram[off] = value;
146            }
147            _ => {}
148        }
149    }
150
151    fn current_mirroring(&self) -> Mirroring {
152        self.mirroring
153    }
154
155    fn save_state(&self) -> Vec<u8> {
156        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
157        let mut out = Vec::with_capacity(2 + self.vram.len() + chr_extra);
158        out.push(SAVE_STATE_VERSION);
159        out.push(self.prg_bank);
160        out.extend_from_slice(&self.vram);
161        if self.chr_is_ram {
162            out.extend_from_slice(&self.chr);
163        }
164        out
165    }
166
167    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
168        let chr_extra = if self.chr_is_ram { self.chr.len() } else { 0 };
169        let expected = 2 + self.vram.len() + chr_extra;
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.prg_bank = data[1];
180        let mut cursor = 2;
181        self.vram
182            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
183        cursor += self.vram.len();
184        if self.chr_is_ram {
185            self.chr
186                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
187        }
188        Ok(())
189    }
190}
191
192impl Nichibutsu180 {
193    /// The byte currently visible at `addr` in the $8000-$FFFF window, used for
194    /// bus-conflict masking (mirrors the active `cpu_read` banking).
195    fn cpu_read_at_for_conflict(&self, addr: u16) -> u8 {
196        match addr {
197            0x8000..=0xBFFF => self.read_prg(0, addr as usize - 0x8000),
198            _ => self.read_prg(self.prg_bank as usize, addr as usize - 0xC000),
199        }
200    }
201}
202
203#[cfg(test)]
204#[allow(clippy::cast_possible_truncation)]
205mod tests {
206    use super::*;
207
208    fn synth_prg_16k(banks: usize) -> Box<[u8]> {
209        let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
210        for b in 0..banks {
211            v[b * PRG_BANK_16K] = b as u8;
212        }
213        v.into_boxed_slice()
214    }
215
216    #[test]
217    fn m180_fixes_low_switches_high() {
218        let mut m =
219            Nichibutsu180::new(synth_prg_16k(8), Box::new([]), Mirroring::Vertical).unwrap();
220        // $8000-$BFFF is fixed to bank 0.
221        assert_eq!(m.cpu_read(0x8000), 0);
222        // Write at $C001 (PRG byte 0xFF -> no masking) selects $C000 bank 3.
223        m.cpu_write(0xC001, 3);
224        assert_eq!(m.cpu_read(0xC000), 3);
225        // $8000 still fixed.
226        assert_eq!(m.cpu_read(0x8000), 0);
227    }
228
229    #[test]
230    fn m180_bus_conflict() {
231        // $C000 bank 0 offset 0 holds the bank index (0). Writing 3 there ANDs
232        // with 0 -> bank 0.
233        let mut m =
234            Nichibutsu180::new(synth_prg_16k(8), Box::new([]), Mirroring::Vertical).unwrap();
235        m.cpu_write(0xC000, 3);
236        assert_eq!(m.cpu_read(0xC000), 0);
237    }
238}