Skip to main content

rustynes_mappers/
m000_nrom.rs

1//! NROM (iNES mapper 0) implementation.
2//!
3//! NROM is the trivial mapper: no bank registers, no banking, no IRQ. PRG-ROM
4//! is either 16 KiB (mirrored across `$8000-$BFFF` and `$C000-$FFFF`) or
5//! 32 KiB (filling `$8000-$FFFF`). CHR-ROM is a fixed 8 KiB window at
6//! `$0000-$1FFF`; CHR-RAM variants substitute 8 KiB of writable RAM.
7//!
8//! Per `docs/mappers.md` §Mapper coverage matrix, NROM covers ~247 commercial
9//! titles and is the baseline against which the rest of the mapper suite is
10//! validated.
11
12use crate::cartridge::Mirroring;
13use crate::mapper::{Mapper, MapperCaps, MapperError};
14use alloc::{boxed::Box, vec::Vec};
15use alloc::{format, vec};
16
17/// PRG-RAM size for cartridges that include it (Family Basic and a few
18/// homebrews). Standard NROM has no PRG-RAM, but we always allocate the
19/// 8 KiB window so reads/writes to `$6000-$7FFF` don't fall off the edge.
20pub const NROM_PRG_RAM_SIZE: usize = 0x2000;
21
22/// CHR-RAM size for the CHR-RAM variant of NROM. Always 8 KiB.
23pub const NROM_CHR_RAM_SIZE: usize = 0x2000;
24
25const PRG_BANK_16K: usize = 0x4000;
26const PRG_BANK_32K: usize = 0x8000;
27const NAMETABLE_SIZE: usize = 0x0400;
28const NAMETABLE_SIZE_U16: u16 = 0x0400;
29
30const SAVE_STATE_VERSION: u8 = 1;
31
32/// NROM mapper state.
33///
34/// `prg_rom` and `chr_rom` are owned by the mapper rather than borrowed from
35/// `Cartridge`. The cart-owned ROM bytes are passed in by value (boxed slice)
36/// at construction; `Cartridge` retains its own copy as well so the rest of
37/// the system can introspect ROM contents without going through the mapper.
38pub struct Nrom {
39    prg_rom: Box<[u8]>,
40    chr: Box<[u8]>,
41    prg_ram: Box<[u8]>,
42    /// Internal nametable VRAM (2 KiB). The console actually owns this in real
43    /// hardware and the cartridge selects mirroring; we hold it here for now
44    /// so the mapper can self-contain its PPU read/write surface until the
45    /// PPU lands.
46    vram: Box<[u8]>,
47    chr_is_ram: bool,
48    mirroring: Mirroring,
49}
50
51impl Nrom {
52    /// Construct a new NROM mapper.
53    ///
54    /// `prg_rom` must be 16 KiB or 32 KiB. CHR-RAM is selected when `chr_rom`
55    /// is empty; otherwise CHR-ROM must be exactly 8 KiB.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`MapperError::Invalid`] when sizes don't match the NROM
60    /// constraints, since NROM has no banking to compensate.
61    pub fn new(
62        prg_rom: Box<[u8]>,
63        chr_rom: Box<[u8]>,
64        mirroring: Mirroring,
65    ) -> Result<Self, MapperError> {
66        if prg_rom.len() != PRG_BANK_16K && prg_rom.len() != PRG_BANK_32K {
67            return Err(MapperError::Invalid(format!(
68                "NROM expects 16 KiB or 32 KiB PRG-ROM, got {} bytes",
69                prg_rom.len()
70            )));
71        }
72
73        let chr_is_ram = chr_rom.is_empty();
74        let chr: Box<[u8]> = if chr_is_ram {
75            vec![0u8; NROM_CHR_RAM_SIZE].into_boxed_slice()
76        } else if chr_rom.len() == NROM_CHR_RAM_SIZE {
77            chr_rom
78        } else {
79            return Err(MapperError::Invalid(format!(
80                "NROM expects 8 KiB CHR-ROM, got {} bytes",
81                chr_rom.len()
82            )));
83        };
84
85        Ok(Self {
86            prg_rom,
87            chr,
88            prg_ram: vec![0u8; NROM_PRG_RAM_SIZE].into_boxed_slice(),
89            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
90            chr_is_ram,
91            mirroring,
92        })
93    }
94
95    /// Map a PPU address in `$2000-$3EFF` to an offset in the 2 KiB internal
96    /// VRAM, applying the configured mirroring.
97    const fn nametable_offset(&self, addr: u16) -> usize {
98        // $2000-$3EFF mirrors. Internal VRAM is 2 KiB (two physical pages).
99        let table = ((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03;
100        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
101        let physical = match self.mirroring {
102            Mirroring::Horizontal => match table {
103                0 | 1 => 0,
104                _ => 1,
105            },
106            Mirroring::Vertical => match table {
107                0 | 2 => 0,
108                _ => 1,
109            },
110            Mirroring::SingleScreenA => 0,
111            Mirroring::SingleScreenB => 1,
112            // Four-screen and mapper-controlled fall back to vertical layout
113            // here. NROM doesn't legally use either; this avoids panics on
114            // misconfigured headers.
115            Mirroring::FourScreen | Mirroring::MapperControlled => match table {
116                0 | 2 => 0,
117                _ => 1,
118            },
119        };
120        physical * NAMETABLE_SIZE + local
121    }
122}
123
124impl Mapper for Nrom {
125    fn sram(&self) -> &[u8] {
126        &self.prg_ram
127    }
128    fn sram_mut(&mut self) -> &mut [u8] {
129        &mut self.prg_ram
130    }
131    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
132    // skips all four per-CPU-cycle dispatches for this board.
133    fn caps(&self) -> MapperCaps {
134        MapperCaps::NONE
135    }
136
137    fn cpu_read(&mut self, addr: u16) -> u8 {
138        match addr {
139            0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize],
140            0x8000..=0xFFFF => {
141                let idx = (addr - 0x8000) as usize;
142                if self.prg_rom.len() == PRG_BANK_16K {
143                    // Mirror 16 KiB across the full $8000-$FFFF window.
144                    self.prg_rom[idx & (PRG_BANK_16K - 1)]
145                } else {
146                    self.prg_rom[idx]
147                }
148            }
149            // $4020-$5FFF on stock NROM is open bus; report 0 here. The bus
150            // layer will overlay open-bus once it owns the latch.
151            _ => 0,
152        }
153    }
154
155    fn cpu_write(&mut self, addr: u16, value: u8) {
156        if let 0x6000..=0x7FFF = addr {
157            self.prg_ram[(addr - 0x6000) as usize] = value;
158        }
159        // Writes to PRG-ROM ($8000-$FFFF) are silently ignored on NROM.
160    }
161
162    fn chr_phys(&self, addr: u16) -> Option<u32> {
163        // NROM CHR is a single unbanked 8 KiB window, so the absolute offset is
164        // the pattern-space address itself. `None` for the CHR-RAM variant.
165        if self.chr_is_ram {
166            None
167        } else {
168            Some(u32::from(addr & 0x1FFF))
169        }
170    }
171
172    fn ppu_read(&mut self, addr: u16) -> u8 {
173        let addr = addr & 0x3FFF;
174        match addr {
175            0x0000..=0x1FFF => self.chr[addr as usize],
176            0x2000..=0x3EFF => {
177                let off = self.nametable_offset(addr);
178                self.vram[off]
179            }
180            // $3F00-$3FFF is palette RAM; owned by the PPU, not the mapper.
181            // Returning 0 here matches the behavior expected of mappers that
182            // never see palette accesses (PPU short-circuits).
183            _ => 0,
184        }
185    }
186
187    fn ppu_write(&mut self, addr: u16, value: u8) {
188        let addr = addr & 0x3FFF;
189        match addr {
190            0x0000..=0x1FFF => {
191                if self.chr_is_ram {
192                    self.chr[addr as usize] = value;
193                }
194                // CHR-ROM writes are ignored.
195            }
196            0x2000..=0x3EFF => {
197                let off = self.nametable_offset(addr);
198                self.vram[off] = value;
199            }
200            _ => {}
201        }
202    }
203
204    fn current_mirroring(&self) -> Mirroring {
205        self.mirroring
206    }
207
208    // NROM has fixed solder-pad mirroring — a game-DB header correction is valid.
209    fn has_hardwired_mirroring(&self) -> bool {
210        true
211    }
212
213    fn save_state(&self) -> Vec<u8> {
214        let mut out = Vec::with_capacity(1 + self.prg_ram.len() + self.vram.len() + self.chr.len());
215        out.push(SAVE_STATE_VERSION);
216        out.extend_from_slice(&self.prg_ram);
217        out.extend_from_slice(&self.vram);
218        if self.chr_is_ram {
219            out.extend_from_slice(&self.chr);
220        }
221        out
222    }
223
224    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
225        let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 };
226        let expected = 1 + self.prg_ram.len() + self.vram.len() + need_chr;
227        if data.len() != expected {
228            return Err(MapperError::WrongLength {
229                expected,
230                got: data.len(),
231            });
232        }
233        if data[0] != SAVE_STATE_VERSION {
234            return Err(MapperError::UnsupportedVersion(data[0]));
235        }
236        let mut cursor = 1;
237        self.prg_ram
238            .copy_from_slice(&data[cursor..cursor + self.prg_ram.len()]);
239        cursor += self.prg_ram.len();
240        self.vram
241            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
242        cursor += self.vram.len();
243        if self.chr_is_ram {
244            self.chr
245                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
246        }
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252#[allow(clippy::cast_possible_truncation)]
253mod tests {
254    use super::*;
255
256    fn synth_prg_16k() -> Box<[u8]> {
257        // Fill with i % 256 so we can verify the addressing across mirrors.
258        (0..PRG_BANK_16K)
259            .map(|i| (i & 0xFF) as u8)
260            .collect::<Vec<_>>()
261            .into_boxed_slice()
262    }
263
264    fn synth_prg_32k() -> Box<[u8]> {
265        (0..PRG_BANK_32K)
266            .map(|i| ((i >> 4) & 0xFF) as u8)
267            .collect::<Vec<_>>()
268            .into_boxed_slice()
269    }
270
271    fn synth_chr_8k() -> Box<[u8]> {
272        (0..NROM_CHR_RAM_SIZE)
273            .map(|i| ((i ^ 0xA5) & 0xFF) as u8)
274            .collect::<Vec<_>>()
275            .into_boxed_slice()
276    }
277
278    #[test]
279    fn nrom_16k_prg_mirrors_across_full_window() {
280        let mut m = Nrom::new(synth_prg_16k(), synth_chr_8k(), Mirroring::Horizontal).unwrap();
281        // $8000 mirrors $C000.
282        for off in [0u16, 1, 0xFF, 0x1234, 0x3FFF] {
283            let lo = m.cpu_read(0x8000 + off);
284            let hi = m.cpu_read(0xC000 + off);
285            assert_eq!(lo, hi, "mirror differs at offset {off:#06x}");
286            assert_eq!(lo, (off & 0xFF) as u8);
287        }
288    }
289
290    #[test]
291    fn nrom_32k_prg_does_not_mirror() {
292        let mut m = Nrom::new(synth_prg_32k(), synth_chr_8k(), Mirroring::Vertical).unwrap();
293        for off in 0u16..0x8000u16 {
294            let want = ((off as usize >> 4) & 0xFF) as u8;
295            assert_eq!(m.cpu_read(0x8000 + off), want);
296        }
297    }
298
299    #[test]
300    fn nrom_chr_rom_writes_ignored() {
301        let mut m = Nrom::new(synth_prg_16k(), synth_chr_8k(), Mirroring::Horizontal).unwrap();
302        let before = m.ppu_read(0x0123);
303        m.ppu_write(0x0123, before.wrapping_add(1));
304        assert_eq!(m.ppu_read(0x0123), before);
305    }
306
307    #[test]
308    fn nrom_chr_ram_writes_round_trip() {
309        let mut m = Nrom::new(
310            synth_prg_16k(),
311            Vec::new().into_boxed_slice(),
312            Mirroring::Horizontal,
313        )
314        .unwrap();
315        for addr in [0x0000u16, 0x07FF, 0x1234, 0x1FFF] {
316            m.ppu_write(addr, addr as u8);
317        }
318        for addr in [0x0000u16, 0x07FF, 0x1234, 0x1FFF] {
319            assert_eq!(m.ppu_read(addr), addr as u8);
320        }
321    }
322
323    #[test]
324    fn nrom_prg_ram_round_trip() {
325        let mut m = Nrom::new(synth_prg_16k(), synth_chr_8k(), Mirroring::Horizontal).unwrap();
326        m.cpu_write(0x6000, 0xAB);
327        m.cpu_write(0x6FFF, 0xCD);
328        m.cpu_write(0x7FFF, 0xEF);
329        assert_eq!(m.cpu_read(0x6000), 0xAB);
330        assert_eq!(m.cpu_read(0x6FFF), 0xCD);
331        assert_eq!(m.cpu_read(0x7FFF), 0xEF);
332    }
333
334    #[test]
335    fn nrom_horizontal_mirroring() {
336        let mut m = Nrom::new(synth_prg_16k(), synth_chr_8k(), Mirroring::Horizontal).unwrap();
337        // Top row: $2000-$23FF and $2400-$27FF -> physical bank 0.
338        m.ppu_write(0x2000, 0x11);
339        assert_eq!(m.ppu_read(0x2400), 0x11);
340        // Bottom row: $2800-$2BFF and $2C00-$2FFF -> physical bank 1.
341        m.ppu_write(0x2800, 0x22);
342        assert_eq!(m.ppu_read(0x2C00), 0x22);
343        // Top vs bottom must not alias.
344        assert_ne!(m.ppu_read(0x2000), m.ppu_read(0x2800));
345    }
346
347    #[test]
348    fn nrom_vertical_mirroring() {
349        let mut m = Nrom::new(synth_prg_16k(), synth_chr_8k(), Mirroring::Vertical).unwrap();
350        m.ppu_write(0x2000, 0x33);
351        assert_eq!(m.ppu_read(0x2800), 0x33);
352        m.ppu_write(0x2400, 0x44);
353        assert_eq!(m.ppu_read(0x2C00), 0x44);
354        assert_ne!(m.ppu_read(0x2000), m.ppu_read(0x2400));
355    }
356
357    #[test]
358    fn nrom_save_state_round_trip_chr_ram() {
359        let mut m = Nrom::new(
360            synth_prg_16k(),
361            Vec::new().into_boxed_slice(),
362            Mirroring::Horizontal,
363        )
364        .unwrap();
365        m.cpu_write(0x6010, 0xBE);
366        m.ppu_write(0x0010, 0xEF);
367        m.ppu_write(0x2010, 0xCA);
368        let blob = m.save_state();
369
370        let mut m2 = Nrom::new(
371            synth_prg_16k(),
372            Vec::new().into_boxed_slice(),
373            Mirroring::Horizontal,
374        )
375        .unwrap();
376        m2.load_state(&blob).unwrap();
377        assert_eq!(m2.cpu_read(0x6010), 0xBE);
378        assert_eq!(m2.ppu_read(0x0010), 0xEF);
379        assert_eq!(m2.ppu_read(0x2010), 0xCA);
380    }
381
382    #[test]
383    fn nrom_rejects_bad_prg_size() {
384        let prg = vec![0u8; 0x2000].into_boxed_slice();
385        assert!(Nrom::new(prg, synth_chr_8k(), Mirroring::Horizontal).is_err());
386    }
387}