Skip to main content

rustynes_mappers/
m009_mmc2.rs

1//! Nintendo MMC2 (`PxROM`, mapper 9) -- Punch-Out!!
2//!
3//! The defining feature is a *tile-fetch CHR latch*: the PPU pattern-table
4//! address the cartridge sees during rendering selects which of two banked CHR
5//! windows stays mapped. Fetching tile `$FD` or `$FE` from a pattern half
6//! latches that half to one of two banks, so the mapper switches CHR banks
7//! mid-scanline with no CPU involvement -- the trick Punch-Out!! uses to draw a
8//! large animated opponent out of a small CHR ROM. That requires a hook on PPU
9//! pattern fetches, unlike every other board in this size class.
10//!
11//! PRG is 8 KiB switchable at `$8000` plus three fixed banks. The closely
12//! related MMC4 is in `m010_mmc4.rs` -- same CHR latch, different PRG
13//! granularity.
14//!
15//! See `docs/mappers.md` §Mapper coverage matrix.
16
17#![allow(
18    clippy::cast_possible_truncation,
19    clippy::cast_lossless,
20    clippy::missing_const_for_fn,
21    clippy::needless_pass_by_ref_mut,
22    clippy::manual_range_patterns,
23    clippy::match_same_arms,
24    clippy::too_many_arguments
25)]
26
27use crate::cartridge::Mirroring;
28use crate::mapper::{Mapper, MapperCaps, MapperError};
29use alloc::{boxed::Box, vec::Vec};
30use alloc::{format, vec};
31
32const PRG_BANK_8K: usize = 0x2000;
33const CHR_BANK_4K: usize = 0x1000;
34const CHR_BANK_8K: usize = 0x2000;
35const NAMETABLE_SIZE: usize = 0x0400;
36const NAMETABLE_SIZE_U16: u16 = 0x0400;
37
38/// Version byte this board writes in its mapper save-state section.
39///
40/// **v1** (through v2.9.1) carried the PRG bank, the four CHR bank registers,
41/// both latches, the mirroring and the 2 KiB nametable RAM. On a cartridge
42/// with no CHR-ROM the 8 KiB CHR-RAM was left out, and the `.rns` container
43/// has no other section that carries cartridge RAM -- so every save-state
44/// load, rewind step, run-ahead frame and netplay rollback kept the running
45/// game's CHR-RAM instead of the saved one (the v2.9.2 cartridge-RAM sweep;
46/// the same omission core audit AUD-02 found on the Konami VRC boards). No
47/// licensed MMC2 cartridge ships CHR-RAM, but the board builds one for an
48/// image without CHR-ROM, so its section must carry it. **v2** appends the
49/// CHR-RAM when present. Since v2.9.8 (ADR 0042)
50/// `load_state` reads v2 only and refuses a v1 blob, which it used to load
51/// with the RAM left untouched.
52const MMC2_SECTION_VERSION: u8 = 2;
53
54fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
55    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
56    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
57    let physical = mirroring.physical_bank(table);
58    physical * NAMETABLE_SIZE + local
59}
60
61/// MMC2 (Mapper 9).
62pub struct Mmc2 {
63    prg_rom: Box<[u8]>,
64    chr_rom: Box<[u8]>,
65    vram: Box<[u8]>,
66    chr_is_ram: bool,
67    prg_bank: u8,
68    chr_lo_fd: u8,
69    chr_lo_fe: u8,
70    chr_hi_fd: u8,
71    chr_hi_fe: u8,
72    /// `false` -> use the FD bank for window 0 (`$0000-$0FFF`).
73    latch_lo_fe: bool,
74    /// `false` -> use the FD bank for window 1 (`$1000-$1FFF`).
75    latch_hi_fe: bool,
76    mirroring: Mirroring,
77}
78
79impl Mmc2 {
80    /// Construct a new MMC2 mapper.
81    ///
82    /// PRG must be a non-zero multiple of 8 KiB; CHR-ROM is mandatory and
83    /// must be a multiple of 4 KiB.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`MapperError::Invalid`] on size mismatch.
88    pub fn new(
89        prg_rom: Box<[u8]>,
90        chr_rom: Box<[u8]>,
91        mirroring: Mirroring,
92    ) -> Result<Self, MapperError> {
93        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
94            return Err(MapperError::Invalid(format!(
95                "MMC2 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
96                prg_rom.len()
97            )));
98        }
99        let chr_is_ram = chr_rom.is_empty();
100        let chr: Box<[u8]> = if chr_is_ram {
101            vec![0u8; CHR_BANK_8K].into_boxed_slice()
102        } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
103            chr_rom
104        } else {
105            return Err(MapperError::Invalid(format!(
106                "MMC2 CHR-ROM size {} is not a multiple of 4 KiB",
107                chr_rom.len()
108            )));
109        };
110        Ok(Self {
111            prg_rom,
112            chr_rom: chr,
113            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
114            chr_is_ram,
115            prg_bank: 0,
116            chr_lo_fd: 0,
117            chr_lo_fe: 0,
118            chr_hi_fd: 0,
119            chr_hi_fe: 0,
120            latch_lo_fe: false,
121            latch_hi_fe: false,
122            mirroring,
123        })
124    }
125
126    fn prg_offset(&self, addr: u16) -> usize {
127        let total_8k = self.prg_rom.len() / PRG_BANK_8K;
128        let last3 = total_8k.saturating_sub(3);
129        let last2 = total_8k.saturating_sub(2);
130        let last1 = total_8k.saturating_sub(1);
131        let bank = match addr & 0xE000 {
132            0x8000 => (self.prg_bank as usize) % total_8k.max(1),
133            0xA000 => last3,
134            0xC000 => last2,
135            _ => last1, // $E000 + the implicit fallback
136        };
137        bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF)
138    }
139
140    fn chr_offset(&mut self, addr: u16) -> usize {
141        let addr = (addr & 0x1FFF) as usize;
142        let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
143        let bank = if addr < CHR_BANK_4K {
144            let b = if self.latch_lo_fe {
145                self.chr_lo_fe
146            } else {
147                self.chr_lo_fd
148            };
149            (b as usize) % total_4k
150        } else {
151            let b = if self.latch_hi_fe {
152                self.chr_hi_fe
153            } else {
154                self.chr_hi_fd
155            };
156            (b as usize) % total_4k
157        };
158        bank * CHR_BANK_4K + (addr & (CHR_BANK_4K - 1))
159    }
160
161    /// Update the CHR latch based on the fetched pattern address.
162    /// $0FD8-$0FDF -> window 0 latch FD; $0FE8-$0FEF -> window 0 latch FE;
163    /// similarly $1FD8-$1FDF / $1FE8-$1FEF for window 1.  Per nesdev wiki.
164    fn update_latch(&mut self, addr: u16) {
165        match addr & 0x3FF8 {
166            0x0FD8 => self.latch_lo_fe = false,
167            0x0FE8 => self.latch_lo_fe = true,
168            0x1FD8 => self.latch_hi_fe = false,
169            0x1FE8 => self.latch_hi_fe = true,
170            _ => {}
171        }
172    }
173}
174
175impl Mapper for Mmc2 {
176    /// v3.1.0: not pure -- a read of tile $FD/$FE switches its CHR latch, so the PPU's display-only
177    /// "disable sprite limit" reads are not made on this board.
178    fn chr_reads_are_pure(&self) -> bool {
179        false
180    }
181
182    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
183    // skips all four per-CPU-cycle dispatches for this board.
184    fn caps(&self) -> MapperCaps {
185        MapperCaps::NONE
186    }
187
188    fn cpu_read(&mut self, addr: u16) -> u8 {
189        match addr {
190            0x8000..=0xFFFF => {
191                let off = self.prg_offset(addr);
192                self.prg_rom[off % self.prg_rom.len()]
193            }
194            _ => 0,
195        }
196    }
197
198    fn cpu_write(&mut self, addr: u16, value: u8) {
199        match addr & 0xF000 {
200            0xA000 => self.prg_bank = value & 0x0F,
201            0xB000 => self.chr_lo_fd = value & 0x1F,
202            0xC000 => self.chr_lo_fe = value & 0x1F,
203            0xD000 => self.chr_hi_fd = value & 0x1F,
204            0xE000 => self.chr_hi_fe = value & 0x1F,
205            0xF000 => {
206                self.mirroring = if value & 1 == 0 {
207                    Mirroring::Vertical
208                } else {
209                    Mirroring::Horizontal
210                };
211            }
212            _ => {}
213        }
214    }
215
216    fn ppu_read(&mut self, addr: u16) -> u8 {
217        let addr = addr & 0x3FFF;
218        match addr {
219            0x0000..=0x1FFF => {
220                let off = self.chr_offset(addr);
221                let v = self.chr_rom[off % self.chr_rom.len()];
222                self.update_latch(addr);
223                v
224            }
225            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
226            _ => 0,
227        }
228    }
229
230    fn ppu_write(&mut self, addr: u16, value: u8) {
231        let addr = addr & 0x3FFF;
232        match addr {
233            0x0000..=0x1FFF => {
234                if self.chr_is_ram {
235                    let off = self.chr_offset(addr);
236                    let len = self.chr_rom.len();
237                    self.chr_rom[off % len] = value;
238                }
239            }
240            0x2000..=0x3EFF => {
241                let off = nametable_offset(addr, self.mirroring) % self.vram.len();
242                self.vram[off] = value;
243            }
244            _ => {}
245        }
246    }
247
248    fn current_mirroring(&self) -> Mirroring {
249        self.mirroring
250    }
251
252    fn save_state(&self) -> Vec<u8> {
253        let mut out = Vec::with_capacity(16 + self.vram.len() + self.ram_block_len());
254        out.push(MMC2_SECTION_VERSION);
255        out.push(self.prg_bank);
256        out.push(self.chr_lo_fd);
257        out.push(self.chr_lo_fe);
258        out.push(self.chr_hi_fd);
259        out.push(self.chr_hi_fe);
260        out.push(u8::from(self.latch_lo_fe));
261        out.push(u8::from(self.latch_hi_fe));
262        out.push(self.mirroring as u8);
263        out.extend_from_slice(&self.vram);
264        // --- v2 tail: the on-cart RAM (see `MMC2_SECTION_VERSION`) ---
265        if self.chr_is_ram {
266            out.extend_from_slice(&self.chr_rom);
267        }
268        out
269    }
270
271    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
272        let version = data.first().copied().unwrap_or(0);
273        // Only the current layout is read (v2.9.8, ADR 0042). A v1 blob, which
274        // stopped before the RAM block, is refused rather than loaded with the
275        // RAM left as it was.
276        if version != MMC2_SECTION_VERSION {
277            return Err(MapperError::UnsupportedVersion(version));
278        }
279        let ram_len = self.ram_block_len();
280        // The whole length is validated before the first field is written.
281        let core_len = 9 + self.vram.len();
282        let expected = core_len + ram_len;
283        if data.len() != expected {
284            return Err(MapperError::WrongLength {
285                expected,
286                got: data.len(),
287            });
288        }
289        self.prg_bank = data[1];
290        self.chr_lo_fd = data[2];
291        self.chr_lo_fe = data[3];
292        self.chr_hi_fd = data[4];
293        self.chr_hi_fe = data[5];
294        self.latch_lo_fe = data[6] != 0;
295        self.latch_hi_fe = data[7] != 0;
296        self.mirroring = match data[8] {
297            0 => Mirroring::Horizontal,
298            1 => Mirroring::Vertical,
299            2 => Mirroring::SingleScreenA,
300            3 => Mirroring::SingleScreenB,
301            4 => Mirroring::FourScreen,
302            5 => Mirroring::MapperControlled,
303            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
304        };
305        self.vram.copy_from_slice(&data[9..core_len]);
306        if self.chr_is_ram {
307            self.chr_rom.copy_from_slice(&data[core_len..]);
308        }
309        Ok(())
310    }
311}
312
313impl Mmc2 {
314    /// Bytes the v2 tail adds: the 8 KiB CHR-RAM when the cartridge has no
315    /// CHR-ROM, else nothing. Derived from the loaded ROM, so a save and its
316    /// load (same ROM, checked by the `.rns` hash tag) agree.
317    fn ram_block_len(&self) -> usize {
318        if self.chr_is_ram {
319            self.chr_rom.len()
320        } else {
321            0
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn synth(banks_8k: usize) -> Box<[u8]> {
331        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
332        for b in 0..banks_8k {
333            v[b * PRG_BANK_8K] = b as u8;
334        }
335        v.into_boxed_slice()
336    }
337
338    fn synth_chr_4k(banks: usize) -> Box<[u8]> {
339        let mut v = vec![0u8; banks * CHR_BANK_4K];
340        for b in 0..banks {
341            v[b * CHR_BANK_4K] = b as u8;
342        }
343        v.into_boxed_slice()
344    }
345
346    #[test]
347    fn mmc2_swap_window_via_latch() {
348        let mut m = Mmc2::new(synth(8), synth_chr_4k(4), Mirroring::Vertical).unwrap();
349        m.chr_lo_fd = 0;
350        m.chr_lo_fe = 1;
351        // Default latch is FD -> bank 0 byte 0 = 0.
352        assert_eq!(m.ppu_read(0x0000), 0);
353        // Reading the FE sentinel switches to FE bank.
354        let _ = m.ppu_read(0x0FE8);
355        assert_eq!(m.ppu_read(0x0000), 1);
356    }
357
358    /// v2.9.2 cartridge-RAM sweep: the section carries the 8 KiB CHR-RAM of
359    /// a board with no CHR-ROM. The whole-machine pin is
360    /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
361    #[test]
362    fn mmc2_save_state_carries_chr_ram() {
363        let mut m = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
364        m.chr_rom[0x0000] = 0x11;
365        m.chr_rom[0x1FFF] = 0x22;
366        let blob = m.save_state();
367        let mut m2 = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
368        m2.load_state(&blob).expect("round-trip");
369        assert_eq!(m2.chr_rom[0x0000], 0x11);
370        assert_eq!(m2.chr_rom[0x1FFF], 0x22);
371    }
372
373    /// v2.9.8 (ADR 0042): a v1 blob (no RAM tail, written through v2.9.1)
374    /// is refused. Until then it loaded and left the RAM as it was.
375    #[test]
376    fn mmc2_v1_blob_is_refused() {
377        let mut m = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
378        m.cpu_write(0xA000, 3);
379        let core_len = 9 + m.vram.len();
380        let mut v1 = m.save_state()[..core_len].to_vec();
381        v1[0] = 1;
382        let mut m2 = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
383        assert!(matches!(
384            m2.load_state(&v1),
385            Err(MapperError::UnsupportedVersion(1))
386        ));
387    }
388
389    /// A v2 blob one byte short (inside the CHR-RAM tail) is rejected.
390    #[test]
391    fn mmc2_truncated_chr_ram_tail_is_rejected() {
392        let m = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
393        let blob = m.save_state();
394        let mut m2 = Mmc2::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
395        let err = m2
396            .load_state(&blob[..blob.len() - 1])
397            .expect_err("a truncated v2 blob must be rejected");
398        assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
399    }
400}