Skip to main content

rustynes_mappers/
m010_mmc4.rs

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