Skip to main content

rustynes_mappers/
m094_un1rom.rs

1//! Nintendo `UN1ROM` (mapper 94) -- Senjou no Ookami.
2//!
3//! A `UxROM` (`m002_uxrom.rs`) whose PRG bank field sits in data bits 4-2 rather
4//! than the low bits, so the same 16 KiB-switchable / 16 KiB-fixed layout is
5//! driven by a shifted register value. CHR is RAM and there is no IRQ.
6//!
7//! A best-effort (Tier-2) board: register-decode correctness verified against
8//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
9//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
10//! every bank select wraps with `% count`, so a register write can never index
11//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
12//! afford a panic on a register access.
13//!
14//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
15//! and `docs/mappers.md` §Mapper coverage matrix.
16
17#![allow(
18    clippy::bool_to_int_with_if,
19    clippy::cast_lossless,
20    clippy::cast_possible_truncation,
21    clippy::doc_markdown,
22    clippy::match_same_arms,
23    clippy::missing_const_for_fn,
24    clippy::similar_names,
25    clippy::struct_excessive_bools,
26    clippy::too_many_lines,
27    clippy::unreadable_literal
28)]
29
30use crate::cartridge::Mirroring;
31use crate::mapper::{Mapper, MapperCaps, MapperError};
32use alloc::{boxed::Box, vec::Vec};
33use alloc::{format, vec};
34
35const PRG_BANK_16K: usize = 0x4000;
36const CHR_BANK_8K: usize = 0x2000;
37const NAMETABLE_SIZE: usize = 0x0400;
38const NAMETABLE_SIZE_U16: u16 = 0x0400;
39
40const SAVE_STATE_VERSION: u8 = 1;
41
42const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
43    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
44    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
45    let physical = mirroring.physical_bank(table);
46    physical * NAMETABLE_SIZE + local
47}
48
49/// Mapper 94 (`UN1ROM`).
50pub struct Un1rom94 {
51    prg_rom: Box<[u8]>,
52    chr_ram: Box<[u8]>,
53    vram: Box<[u8]>,
54    prg_bank: u8,
55    mirroring: Mirroring,
56}
57
58impl Un1rom94 {
59    /// Construct a new mapper 94 board.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`MapperError::Invalid`] when PRG is not a non-zero multiple of
64    /// 16 KiB.
65    pub fn new(
66        prg_rom: Box<[u8]>,
67        _chr_rom: &[u8],
68        mirroring: Mirroring,
69    ) -> Result<Self, MapperError> {
70        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
71            return Err(MapperError::Invalid(format!(
72                "mapper 94 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
73                prg_rom.len()
74            )));
75        }
76        Ok(Self {
77            prg_rom,
78            chr_ram: vec![0u8; CHR_BANK_8K].into_boxed_slice(),
79            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
80            prg_bank: 0,
81            mirroring,
82        })
83    }
84
85    fn read_prg(&self, bank: usize, addr: u16) -> u8 {
86        let count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
87        let bank = bank % count;
88        self.prg_rom[bank * PRG_BANK_16K + (addr as usize & 0x3FFF)]
89    }
90}
91
92impl Mapper for Un1rom94 {
93    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/INES_Mapper_094: "Solder pads select vertical or horizontal mirroring"`, so a
94    /// per-game database correction of a wrong header bit is safe here.
95    fn has_hardwired_mirroring(&self) -> bool {
96        true
97    }
98
99    fn caps(&self) -> MapperCaps {
100        MapperCaps::NONE
101    }
102
103    fn cpu_read(&mut self, addr: u16) -> u8 {
104        match addr {
105            0x8000..=0xBFFF => self.read_prg(self.prg_bank as usize, addr),
106            0xC000..=0xFFFF => {
107                let last = (self.prg_rom.len() / PRG_BANK_16K).max(1) - 1;
108                self.read_prg(last, addr)
109            }
110            _ => 0,
111        }
112    }
113
114    fn cpu_write(&mut self, addr: u16, value: u8) {
115        if (0x8000..=0xFFFF).contains(&addr) {
116            // Bus conflict: AND with the byte the CPU actually reads at this
117            // address, using the SAME window mapping as `cpu_read` — the
118            // switchable bank for $8000-$BFFF, the fixed last bank for
119            // $C000-$FFFF (a register write can land in either half).
120            let conflict = self.cpu_read(addr);
121            let effective = value & conflict;
122            // UN1ROM (Senjou no Ookami) selects the 16 KiB bank from data
123            // bits 4..2 (a 3-bit field, 8 banks).
124            self.prg_bank = (effective >> 2) & 0x07;
125        }
126    }
127
128    fn ppu_read(&mut self, addr: u16) -> u8 {
129        let addr = addr & 0x3FFF;
130        match addr {
131            0x0000..=0x1FFF => self.chr_ram[addr as usize],
132            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
133            _ => 0,
134        }
135    }
136
137    fn ppu_write(&mut self, addr: u16, value: u8) {
138        let addr = addr & 0x3FFF;
139        match addr {
140            0x0000..=0x1FFF => self.chr_ram[addr as usize] = value,
141            0x2000..=0x3EFF => {
142                let off = nametable_offset(addr, self.mirroring);
143                self.vram[off] = value;
144            }
145            _ => {}
146        }
147    }
148
149    fn current_mirroring(&self) -> Mirroring {
150        self.mirroring
151    }
152
153    fn save_state(&self) -> Vec<u8> {
154        let mut out = Vec::with_capacity(2 + self.vram.len() + self.chr_ram.len());
155        out.push(SAVE_STATE_VERSION);
156        out.push(self.prg_bank);
157        out.extend_from_slice(&self.vram);
158        out.extend_from_slice(&self.chr_ram);
159        out
160    }
161
162    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
163        let expected = 2 + self.vram.len() + self.chr_ram.len();
164        if data.len() != expected {
165            return Err(MapperError::WrongLength {
166                expected,
167                got: data.len(),
168            });
169        }
170        if data[0] != SAVE_STATE_VERSION {
171            return Err(MapperError::UnsupportedVersion(data[0]));
172        }
173        self.prg_bank = data[1];
174        let mut cursor = 2;
175        self.vram
176            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
177        cursor += self.vram.len();
178        self.chr_ram
179            .copy_from_slice(&data[cursor..cursor + self.chr_ram.len()]);
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn synth_prg_16k(banks: usize) -> Box<[u8]> {
189        let mut v = vec![0xFFu8; banks * PRG_BANK_16K];
190        for b in 0..banks {
191            v[b * PRG_BANK_16K] = b as u8;
192        }
193        v.into_boxed_slice()
194    }
195
196    #[test]
197    fn m94_prg_bank_from_data_bits() {
198        let mut m = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
199        // value (data>>2)&0x0F = 3 -> write 0b0000_1100 (12). Offset !=0 has
200        // no bus conflict (0xFF), so the value sticks.
201        m.cpu_write(0x8001, 0b0000_1100);
202        assert_eq!(m.cpu_read(0x8000), 3);
203        // $C000 is fixed to the last 16 KiB bank (7).
204        assert_eq!(m.cpu_read(0xC000), 7);
205    }
206
207    #[test]
208    fn m94_save_state_round_trip() {
209        let mut m = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
210        m.cpu_write(0x8001, 0b0001_0000); // (16>>2)&0xF = 4
211        m.ppu_write(0x0002, 0x9A);
212        let blob = m.save_state();
213        let mut m2 = Un1rom94::new(synth_prg_16k(8), &[], Mirroring::Vertical).unwrap();
214        m2.load_state(&blob).unwrap();
215        assert_eq!(m2.cpu_read(0x8000), 4);
216        assert_eq!(m2.ppu_read(0x0002), 0x9A);
217    }
218}