Skip to main content

rustynes_mappers/
m268_bmc_coolboy.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the CoolBoy MMC3-variant banking is derived from Mesen2 (GPL-3.0-or-later), `Mmc3Variants/MMC3_Coolboy.h`, and the FCEUX banking transforms (GPL-2.0-or-later). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! `COOLBOY` / `MINDKIDS` (mapper 268).
6//!
7//! Another MMC3-core-plus-outer-registers pirate ASIC, closely related to the
8//! `FK23C` in `m176_bmc_fk23c.rs` but with its outer registers in the
9//! `$6000-$7FFF` PRG-RAM window and a different mode encoding. The two are
10//! kept separate rather than merged because their outer decode is where all
11//! the per-board behaviour lives, and conflating them would obscure exactly
12//! the part that differs.
13//!
14//! A best-effort (Tier-2) board: register-decode correctness verified against
15//! the reference emulators (`Mesen2`, `GeraNES`) and the nesdev wiki, with no
16//! commercial-oracle ROM in the tree. Banking math is direct slice indexing and
17//! every bank select wraps with `% count`, so a register write can never index
18//! out of bounds -- required for the `#![no_std]` chip stack, which cannot
19//! afford a panic on a register access.
20//!
21//! See `tier.rs` (`MapperTier::BestEffort`), `docs/adr/0011-mapper-tiering.md`,
22//! and `docs/mappers.md` §Mapper coverage matrix.
23
24#![allow(
25    clippy::cast_possible_truncation,
26    clippy::cast_lossless,
27    clippy::match_same_arms,
28    clippy::doc_markdown,
29    clippy::similar_names,
30    clippy::too_many_lines,
31    clippy::missing_const_for_fn,
32    clippy::struct_excessive_bools,
33    clippy::bool_to_int_with_if,
34    clippy::unreadable_literal
35)]
36
37use crate::a12_filter::A12RiseFilter;
38use crate::cartridge::Mirroring;
39use crate::mapper::{Mapper, MapperCaps, MapperError};
40use alloc::{boxed::Box, format, vec, vec::Vec};
41
42const PRG_BANK_8K: usize = 0x2000;
43const CHR_BANK_1K: usize = 0x0400;
44const NAMETABLE_SIZE: usize = 0x0400;
45const NAMETABLE_SIZE_U16: u16 = 0x0400;
46
47const SAVE_STATE_VERSION: u8 = 2;
48
49// ---------------------------------------------------------------------------
50// Shared nametable + mirroring helpers (mirror the other simple-mapper modules).
51// ---------------------------------------------------------------------------
52
53const fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
54    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
55    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
56    let physical = mirroring.physical_bank(table);
57    physical * NAMETABLE_SIZE + local
58}
59
60const fn mirroring_to_byte(m: Mirroring) -> u8 {
61    match m {
62        Mirroring::Horizontal => 0,
63        Mirroring::Vertical => 1,
64        Mirroring::SingleScreenA => 2,
65        Mirroring::SingleScreenB => 3,
66        Mirroring::FourScreen => 4,
67        Mirroring::MapperControlled => 5,
68    }
69}
70
71const fn byte_to_mirroring(b: u8, fallback: Mirroring) -> Mirroring {
72    match b {
73        0 => Mirroring::Horizontal,
74        1 => Mirroring::Vertical,
75        2 => Mirroring::SingleScreenA,
76        3 => Mirroring::SingleScreenB,
77        4 => Mirroring::FourScreen,
78        5 => Mirroring::MapperControlled,
79        _ => fallback,
80    }
81}
82
83/// Validate a PRG-ROM image is a non-zero multiple of 8 KiB.
84fn check_prg(prg: &[u8], id: u16) -> Result<(), MapperError> {
85    if prg.is_empty() || !prg.len().is_multiple_of(PRG_BANK_8K) {
86        return Err(MapperError::Invalid(format!(
87            "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
88            prg.len()
89        )));
90    }
91    Ok(())
92}
93
94/// COOLBOY / MINDKIDS MMC3-clone multicart (mapper 268).
95pub struct Coolboy {
96    prg_rom: Box<[u8]>,
97    chr: Box<[u8]>,
98    chr_is_ram: bool,
99    vram: Box<[u8]>,
100    mirroring: Mirroring,
101    prg_count_8k: usize,
102    chr_count_1k: usize,
103    regs: [u8; 8],
104    bank_select: u8,
105    prg_mode: bool,
106    chr_mode: bool,
107    irq_counter: u8,
108    irq_latch: u8,
109    irq_reload: bool,
110    irq_enabled: bool,
111    irq_pending: bool,
112    /// v2.9.7 — MMC3's A12 rise filter (see `a12_filter`): the counter
113    /// clocks once per scanline on the eight-pulse stream the PPU reports.
114    a12: A12RiseFilter,
115    ex_regs: [u8; 4],
116}
117
118impl Coolboy {
119    const SAVE_LEN: usize = 8 + 9 + 4 + 1;
120
121    fn new(
122        prg_rom: Box<[u8]>,
123        chr_rom: Box<[u8]>,
124        mirroring: Mirroring,
125    ) -> Result<Self, MapperError> {
126        check_prg(&prg_rom, 268)?;
127        let chr_is_ram = chr_rom.is_empty();
128        let chr: Box<[u8]> = if chr_is_ram {
129            vec![0u8; 0x40000].into_boxed_slice()
130        } else {
131            if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
132                return Err(MapperError::Invalid(format!(
133                    "mapper 268 CHR-ROM size {} is not a multiple of 1 KiB",
134                    chr_rom.len()
135                )));
136            }
137            chr_rom
138        };
139        let prg_count_8k = prg_rom.len() / PRG_BANK_8K;
140        let chr_count_1k = (chr.len() / CHR_BANK_1K).max(1);
141        Ok(Self {
142            prg_rom,
143            chr,
144            chr_is_ram,
145            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
146            mirroring,
147            prg_count_8k,
148            chr_count_1k,
149            regs: [0; 8],
150            bank_select: 0,
151            prg_mode: false,
152            chr_mode: false,
153            irq_counter: 0,
154            irq_latch: 0,
155            irq_reload: false,
156            irq_enabled: false,
157            irq_pending: false,
158            a12: A12RiseFilter::new(),
159            ex_regs: [0; 4],
160        })
161    }
162
163    fn prg_bank_mmc3(&self, slot: usize) -> usize {
164        let last = self.prg_count_8k - 1;
165        let second_last = last.saturating_sub(1);
166        let r6 = self.regs[6] as usize;
167        let r7 = self.regs[7] as usize;
168        match (slot, self.prg_mode) {
169            (0, false) => r6,
170            (0, true) => second_last,
171            (1, _) => r7,
172            (2, false) => second_last,
173            (2, true) => r6,
174            (3, _) => last,
175            _ => 0,
176        }
177    }
178
179    fn resolve_prg(&self, slot: usize) -> usize {
180        let page = self.prg_bank_mmc3(slot);
181        let e0 = self.ex_regs[0] as usize;
182        let e1 = self.ex_regs[1] as usize;
183        let e3 = self.ex_regs[3] as usize;
184        let mut mask =
185            ((0x3F | (e1 & 0x40) | ((e1 & 0x20) << 2)) ^ ((e0 & 0x40) >> 2)) ^ ((e1 & 0x80) >> 2);
186        let base = (e0 & 0x07) | ((e1 & 0x10) >> 1) | ((e1 & 0x0C) << 2) | ((e0 & 0x30) << 2);
187        let bank = if e3 & 0x10 == 0 {
188            ((base << 4) & !mask) | (page & mask)
189        } else {
190            mask &= 0xF0;
191            let emask = if e1 & 0x02 != 0 {
192                (e3 & 0x0C) | (slot & 0x01)
193            } else {
194                e3 & 0x0E
195            };
196            ((base << 4) & !mask) | (page & mask) | emask | (slot & 0x01)
197        };
198        bank % self.prg_count_8k
199    }
200
201    fn chr_bank_mmc3(&self, slot: usize) -> usize {
202        let banks: [usize; 8] = if self.chr_mode {
203            [
204                self.regs[2] as usize,
205                self.regs[3] as usize,
206                self.regs[4] as usize,
207                self.regs[5] as usize,
208                self.regs[0] as usize & !1,
209                (self.regs[0] as usize & !1) | 1,
210                self.regs[1] as usize & !1,
211                (self.regs[1] as usize & !1) | 1,
212            ]
213        } else {
214            [
215                self.regs[0] as usize & !1,
216                (self.regs[0] as usize & !1) | 1,
217                self.regs[1] as usize & !1,
218                (self.regs[1] as usize & !1) | 1,
219                self.regs[2] as usize,
220                self.regs[3] as usize,
221                self.regs[4] as usize,
222                self.regs[5] as usize,
223            ]
224        };
225        banks[slot & 0x07]
226    }
227
228    fn resolve_chr(&self, slot: usize) -> usize {
229        let page = self.chr_bank_mmc3(slot);
230        let e0 = self.ex_regs[0] as usize;
231        let e2 = self.ex_regs[2] as usize;
232        let e3 = self.ex_regs[3] as usize;
233        let mask = 0xFF ^ (e0 & 0x80);
234        let bank = if e3 & 0x10 != 0 {
235            (page & 0x80 & mask) | (((e0 & 0x08) << 4) & !mask) | ((e2 & 0x0F) << 3) | slot
236        } else {
237            (page & mask) | (((e0 & 0x08) << 4) & !mask)
238        };
239        bank % self.chr_count_1k
240    }
241
242    fn write_mmc3(&mut self, addr: u16, value: u8) {
243        match addr & 0xE001 {
244            0x8000 => {
245                self.bank_select = value & 0x07;
246                self.prg_mode = value & 0x40 != 0;
247                self.chr_mode = value & 0x80 != 0;
248            }
249            0x8001 => {
250                let idx = (self.bank_select & 0x07) as usize;
251                self.regs[idx] = value;
252            }
253            0xA000 => {
254                self.mirroring = if value & 0x01 == 0 {
255                    Mirroring::Vertical
256                } else {
257                    Mirroring::Horizontal
258                };
259            }
260            0xC000 => self.irq_latch = value,
261            0xC001 => {
262                self.irq_counter = 0;
263                self.irq_reload = true;
264            }
265            0xE000 => {
266                self.irq_enabled = false;
267                self.irq_pending = false;
268            }
269            0xE001 => self.irq_enabled = true,
270            _ => {}
271        }
272    }
273}
274
275impl Mapper for Coolboy {
276    fn caps(&self) -> MapperCaps {
277        MapperCaps {
278            // v2.9.7: the A12 filter's clock (`notify_cpu_cycle`); the bus
279            // calls it only on boards that declare this.
280            cpu_cycle_hook: true,
281            audio: false,
282            frame_event_hook: false,
283            irq_source: true,
284        }
285    }
286
287    fn cpu_read(&mut self, addr: u16) -> u8 {
288        match addr {
289            0x8000..=0x9FFF => {
290                let b = self.resolve_prg(0);
291                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
292            }
293            0xA000..=0xBFFF => {
294                let b = self.resolve_prg(1);
295                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
296            }
297            0xC000..=0xDFFF => {
298                let b = self.resolve_prg(2);
299                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
300            }
301            0xE000..=0xFFFF => {
302                let b = self.resolve_prg(3);
303                self.prg_rom[b * PRG_BANK_8K + (addr as usize & 0x1FFF)]
304            }
305            _ => 0,
306        }
307    }
308
309    fn cpu_write(&mut self, addr: u16, value: u8) {
310        match addr {
311            0x6000..=0x7FFF => {
312                // Outer-bank registers, latched while $E000-bit-7 mode allows.
313                if (self.ex_regs[3] & 0x90) != 0x80 {
314                    self.ex_regs[(addr & 0x03) as usize] = value;
315                }
316            }
317            0x8000..=0xFFFF => self.write_mmc3(addr, value),
318            _ => {}
319        }
320    }
321
322    fn ppu_read(&mut self, addr: u16) -> u8 {
323        let addr = addr & 0x3FFF;
324        match addr {
325            0x0000..=0x1FFF => {
326                if self.chr_is_ram {
327                    return self.chr[addr as usize & (self.chr.len() - 1)];
328                }
329                let slot = (addr as usize) / CHR_BANK_1K;
330                let b = self.resolve_chr(slot);
331                self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)]
332            }
333            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
334            _ => 0,
335        }
336    }
337
338    fn ppu_write(&mut self, addr: u16, value: u8) {
339        let addr = addr & 0x3FFF;
340        match addr {
341            0x0000..=0x1FFF if self.chr_is_ram => {
342                self.chr[addr as usize & (self.chr.len() - 1)] = value;
343            }
344            0x2000..=0x3EFF => {
345                let off = nametable_offset(addr, self.mirroring);
346                self.vram[off] = value;
347            }
348            _ => {}
349        }
350    }
351
352    fn notify_cpu_cycle(&mut self) {
353        self.a12.tick();
354    }
355
356    fn notify_a12(&mut self, level: bool) {
357        // v2.9.7 — MMC3's filter; see `a12_filter` for why a bare
358        // rising-edge test is no longer enough.
359        if !self.a12.edge(level) {
360            return;
361        }
362        if self.irq_counter == 0 || self.irq_reload {
363            self.irq_counter = self.irq_latch;
364            self.irq_reload = false;
365        } else {
366            self.irq_counter = self.irq_counter.wrapping_sub(1);
367        }
368        if self.irq_counter == 0 && self.irq_enabled {
369            self.irq_pending = true;
370        }
371    }
372
373    fn irq_pending(&self) -> bool {
374        self.irq_pending
375    }
376
377    fn irq_acknowledge(&mut self) {
378        self.irq_pending = false;
379    }
380
381    fn current_mirroring(&self) -> Mirroring {
382        self.mirroring
383    }
384
385    fn save_state(&self) -> Vec<u8> {
386        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
387        let mut out = Vec::with_capacity(1 + Self::SAVE_LEN + self.vram.len() + chr_ram);
388        out.push(SAVE_STATE_VERSION);
389        out.extend_from_slice(&self.regs);
390        out.push(self.bank_select);
391        out.push(u8::from(self.prg_mode));
392        out.push(u8::from(self.chr_mode));
393        out.push(self.irq_counter);
394        out.push(self.irq_latch);
395        out.push(u8::from(self.irq_reload));
396        out.push(u8::from(self.irq_enabled));
397        out.push(u8::from(self.irq_pending));
398        out.push(self.a12.to_byte());
399        out.extend_from_slice(&self.ex_regs);
400        out.push(mirroring_to_byte(self.mirroring));
401        out.extend_from_slice(&self.vram);
402        if self.chr_is_ram {
403            out.extend_from_slice(&self.chr);
404        }
405        out
406    }
407
408    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
409        let chr_ram = if self.chr_is_ram { self.chr.len() } else { 0 };
410        let expected = 1 + Self::SAVE_LEN + self.vram.len() + chr_ram;
411        if data.len() != expected {
412            return Err(MapperError::WrongLength {
413                expected,
414                got: data.len(),
415            });
416        }
417        // v2 (v2.9.7) packs the A12 filter into the old `last_a12` byte. A v1
418        // state, whose byte is the bare level, is refused since v2.9.8 (ADR
419        // 0042); it used to load as such.
420        let version = data[0];
421        if version != SAVE_STATE_VERSION {
422            return Err(MapperError::UnsupportedVersion(version));
423        }
424        let mut c = 1;
425        self.regs.copy_from_slice(&data[c..c + 8]);
426        c += 8;
427        self.bank_select = data[c];
428        self.prg_mode = data[c + 1] != 0;
429        self.chr_mode = data[c + 2] != 0;
430        self.irq_counter = data[c + 3];
431        self.irq_latch = data[c + 4];
432        self.irq_reload = data[c + 5] != 0;
433        self.irq_enabled = data[c + 6] != 0;
434        self.irq_pending = data[c + 7] != 0;
435        self.a12 = A12RiseFilter::from_byte(data[c + 8]);
436        c += 9;
437        self.ex_regs.copy_from_slice(&data[c..c + 4]);
438        c += 4;
439        self.mirroring = byte_to_mirroring(data[c], self.mirroring);
440        c += 1;
441        self.vram.copy_from_slice(&data[c..c + self.vram.len()]);
442        c += self.vram.len();
443        if self.chr_is_ram {
444            self.chr.copy_from_slice(&data[c..c + self.chr.len()]);
445        }
446        Ok(())
447    }
448}
449
450/// Mapper 268 (COOLBOY / MINDKIDS MMC3-clone multicart).
451///
452/// # Errors
453/// [`MapperError::Invalid`] on a bad PRG/CHR size.
454pub fn new_m268(
455    prg_rom: Box<[u8]>,
456    chr_rom: Box<[u8]>,
457    mirroring: Mirroring,
458) -> Result<Coolboy, MapperError> {
459    Coolboy::new(prg_rom, chr_rom, mirroring)
460}
461
462// ===========================================================================
463// Sachen9602 (mapper 513) — Sachen 9602 MMC3-clone.
464//
465// A plain MMC3 core with a PRG-A19/A20 outer bank from the high two bits of
466// $8001 (captured when the selected register is < 6), forced into the top of
467// the address space. CHR is RAM. Register map per the NESdev wiki CoolBoy /
468// mapper-268 documentation; the banking implementation is derived from Mesen2's
469// `Mmc3Variants/MMC3_Coolboy.h` (GPL-3.0-or-later) and the FCEUX transforms
470// (GPL-2.0-or-later). See NOTICE + docs/originality-and-provenance.md §1.
471// ===========================================================================
472
473#[cfg(test)]
474#[allow(clippy::cast_possible_truncation)]
475mod tests {
476
477    /// v2.9.7 — the IRQ counter clocks once per scanline on the A12 stream the
478    /// PPU now reports (eight pulses per line), through MMC3's filter. Before,
479    /// it clocked on every rise and depended on the PPU delivering only one.
480    #[test]
481    fn irq_counts_scanlines_not_raw_a12_pulses() {
482        let mut m = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Vertical).unwrap();
483        assert_eq!(crate::a12_filter::irqs_over_scanlines(&mut m, 16), 2);
484    }
485
486    use super::*;
487
488    fn synth_prg_8k(banks: usize) -> Box<[u8]> {
489        let mut v = vec![0xFFu8; banks * PRG_BANK_8K];
490        for b in 0..banks {
491            v[b * PRG_BANK_8K] = b as u8;
492        }
493        v.into_boxed_slice()
494    }
495
496    fn synth_chr_1k(banks: usize) -> Box<[u8]> {
497        let mut v = vec![0u8; banks * CHR_BANK_1K];
498        for b in 0..banks {
499            v[b * CHR_BANK_1K] = b as u8;
500        }
501        v.into_boxed_slice()
502    }
503
504    #[test]
505    fn coolboy_outer_regs_and_irq() {
506        let mut m = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Vertical).unwrap();
507        m.cpu_write(0x6000, 0x01); // ex_reg0
508        m.cpu_write(0x8000, 0x06); // R6
509        m.cpu_write(0x8001, 3);
510        let v = m.cpu_read(0x8000);
511        assert!((v as usize) < 64); // in range, no panic.
512
513        m.cpu_write(0xC000, 1);
514        m.cpu_write(0xC001, 0);
515        m.cpu_write(0xE001, 0);
516        m.notify_a12(false);
517        // v2.9.7: three CPU cycles low, as MMC3's A12 filter requires.
518        for _ in 0..3 {
519            m.notify_cpu_cycle();
520        }
521        m.notify_a12(true);
522        m.notify_a12(false);
523        // v2.9.7: three CPU cycles low, as MMC3's A12 filter requires.
524        for _ in 0..3 {
525            m.notify_cpu_cycle();
526        }
527        m.notify_a12(true);
528        assert!(m.irq_pending());
529    }
530
531    #[test]
532    fn coolboy_save_state_round_trip() {
533        let mut m = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Horizontal).unwrap();
534        m.cpu_write(0x6000, 0x05);
535        m.cpu_write(0x6001, 0x02);
536        m.cpu_write(0x8000, 0x06);
537        m.cpu_write(0x8001, 4);
538        m.ppu_write(0x2005, 0x3C);
539        let blob = m.save_state();
540        let mut m2 = new_m268(synth_prg_8k(64), synth_chr_1k(128), Mirroring::Horizontal).unwrap();
541        m2.load_state(&blob).unwrap();
542        assert_eq!(m2.cpu_read(0x8000), m.cpu_read(0x8000));
543        assert_eq!(m2.ppu_read(0x2005), 0x3C);
544    }
545}