Skip to main content

rustynes_mappers/
m011_color_dreams.rs

1//! Color Dreams (mapper 11) -- unlicensed discrete-logic board.
2//!
3//! A single write-anywhere register in `$8000-$FFFF`: the low two bits select
4//! a 32 KiB PRG bank, the high four bits an 8 KiB CHR bank. Because the board
5//! is discrete logic with no write-enable gating, writes are subject to *bus
6//! conflicts* -- the value written is `AND`ed with the byte the PRG ROM is
7//! simultaneously driving onto the data bus at that address.
8//!
9//! See `docs/mappers.md` §Mapper coverage matrix.
10
11#![allow(
12    clippy::cast_possible_truncation,
13    clippy::cast_lossless,
14    clippy::missing_const_for_fn,
15    clippy::needless_pass_by_ref_mut,
16    clippy::manual_range_patterns,
17    clippy::match_same_arms,
18    clippy::too_many_arguments
19)]
20
21use crate::cartridge::Mirroring;
22use crate::mapper::{Mapper, MapperCaps, MapperError};
23use alloc::{boxed::Box, vec::Vec};
24use alloc::{format, vec};
25
26const CHR_BANK_8K: usize = 0x2000;
27const NAMETABLE_SIZE: usize = 0x0400;
28const NAMETABLE_SIZE_U16: u16 = 0x0400;
29
30/// Version byte this board writes in its mapper save-state section.
31///
32/// **v1** (through v2.9.1) carried the PRG and CHR bank registers and the
33/// 2 KiB nametable RAM. On a cartridge with no CHR-ROM the 8 KiB CHR-RAM was
34/// left out, and the `.rns` container has no other section that carries
35/// cartridge RAM -- so every save-state load, rewind step, run-ahead frame and
36/// netplay rollback kept the running game's CHR-RAM instead of the saved one
37/// (the v2.9.2 cartridge-RAM sweep; the same omission core audit AUD-02 found
38/// on the Konami VRC boards). **v2** appends the CHR-RAM when present.
39/// Since v2.9.8 (ADR 0042)
40/// `load_state` reads v2 only and refuses a v1 blob, which it used to load
41/// with the RAM left untouched.
42const COLOR_DREAMS_SECTION_VERSION: u8 = 2;
43
44fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
45    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
46    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
47    let physical = mirroring.physical_bank(table);
48    physical * NAMETABLE_SIZE + local
49}
50
51/// Color Dreams (Mapper 11).
52pub struct ColorDreams {
53    prg_rom: Box<[u8]>,
54    chr_rom: Box<[u8]>,
55    vram: Box<[u8]>,
56    chr_is_ram: bool,
57    prg_bank: u8,
58    chr_bank: u8,
59    mirroring: Mirroring,
60}
61
62impl ColorDreams {
63    /// Construct a new Color Dreams mapper.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`MapperError::Invalid`] on size mismatch.
68    pub fn new(
69        prg_rom: Box<[u8]>,
70        chr_rom: Box<[u8]>,
71        mirroring: Mirroring,
72    ) -> Result<Self, MapperError> {
73        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(32 * 1024) {
74            return Err(MapperError::Invalid(format!(
75                "Color Dreams PRG-ROM size {} is not a non-zero multiple of 32 KiB",
76                prg_rom.len()
77            )));
78        }
79        let chr_is_ram = chr_rom.is_empty();
80        let chr: Box<[u8]> = if chr_is_ram {
81            vec![0u8; CHR_BANK_8K].into_boxed_slice()
82        } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
83            chr_rom
84        } else {
85            return Err(MapperError::Invalid(format!(
86                "Color Dreams CHR-ROM size {} is not a multiple of 8 KiB",
87                chr_rom.len()
88            )));
89        };
90        Ok(Self {
91            prg_rom,
92            chr_rom: chr,
93            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
94            chr_is_ram,
95            prg_bank: 0,
96            chr_bank: 0,
97            mirroring,
98        })
99    }
100}
101
102impl Mapper for ColorDreams {
103    /// Fixed mirroring (v2.7.2, core audit §5.6): `nesdev_wiki/Color_Dreams: "Fixed H or V, controlled by solder pads"`, so a
104    /// per-game database correction of a wrong header bit is safe here.
105    fn has_hardwired_mirroring(&self) -> bool {
106        true
107    }
108
109    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
110    // skips all four per-CPU-cycle dispatches for this board.
111    fn caps(&self) -> MapperCaps {
112        MapperCaps::NONE
113    }
114
115    fn cpu_read(&mut self, addr: u16) -> u8 {
116        if addr < 0x8000 {
117            return 0;
118        }
119        let total_32k = (self.prg_rom.len() / (32 * 1024)).max(1);
120        let bank = (self.prg_bank as usize) % total_32k;
121        let off = bank * 32 * 1024 + (addr as usize - 0x8000);
122        self.prg_rom[off % self.prg_rom.len()]
123    }
124
125    fn cpu_write(&mut self, addr: u16, value: u8) {
126        if addr >= 0x8000 {
127            // Bus conflict: AND with the current PRG byte at this address.
128            let conflict = self.cpu_read(addr);
129            let v = value & conflict;
130            self.prg_bank = v & 0x03;
131            self.chr_bank = (v >> 4) & 0x0F;
132        }
133    }
134
135    fn ppu_read(&mut self, addr: u16) -> u8 {
136        let addr = addr & 0x3FFF;
137        match addr {
138            0x0000..=0x1FFF => {
139                let total_8k = (self.chr_rom.len() / CHR_BANK_8K).max(1);
140                let bank = (self.chr_bank as usize) % total_8k;
141                self.chr_rom[(bank * CHR_BANK_8K + addr as usize) % self.chr_rom.len()]
142            }
143            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
144            _ => 0,
145        }
146    }
147
148    fn ppu_write(&mut self, addr: u16, value: u8) {
149        let addr = addr & 0x3FFF;
150        if let 0x2000..=0x3EFF = addr {
151            let off = nametable_offset(addr, self.mirroring) % self.vram.len();
152            self.vram[off] = value;
153        } else if (0x0000..=0x1FFF).contains(&addr) && self.chr_is_ram {
154            let total_8k = (self.chr_rom.len() / CHR_BANK_8K).max(1);
155            let bank = (self.chr_bank as usize) % total_8k;
156            let off = (bank * CHR_BANK_8K + addr as usize) % self.chr_rom.len();
157            self.chr_rom[off] = value;
158        }
159    }
160
161    fn current_mirroring(&self) -> Mirroring {
162        self.mirroring
163    }
164
165    fn save_state(&self) -> Vec<u8> {
166        let mut out = Vec::with_capacity(4 + self.vram.len() + self.ram_block_len());
167        out.push(COLOR_DREAMS_SECTION_VERSION);
168        out.push(self.prg_bank);
169        out.push(self.chr_bank);
170        out.extend_from_slice(&self.vram);
171        // --- v2 tail: the CHR-RAM, if any (see `COLOR_DREAMS_SECTION_VERSION`) ---
172        if self.chr_is_ram {
173            out.extend_from_slice(&self.chr_rom);
174        }
175        out
176    }
177
178    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
179        let version = data.first().copied().unwrap_or(0);
180        // Only the current layout is read (v2.9.8, ADR 0042). A v1 blob, which
181        // stopped before the RAM block, is refused rather than loaded with the
182        // RAM left as it was.
183        if version != COLOR_DREAMS_SECTION_VERSION {
184            return Err(MapperError::UnsupportedVersion(version));
185        }
186        let ram_len = self.ram_block_len();
187        // The whole length is validated before the first field is written.
188        let core_len = 3 + self.vram.len();
189        let expected = core_len + ram_len;
190        if data.len() != expected {
191            return Err(MapperError::WrongLength {
192                expected,
193                got: data.len(),
194            });
195        }
196        self.prg_bank = data[1];
197        self.chr_bank = data[2];
198        self.vram.copy_from_slice(&data[3..core_len]);
199        if self.chr_is_ram {
200            self.chr_rom.copy_from_slice(&data[core_len..]);
201        }
202        Ok(())
203    }
204}
205
206impl ColorDreams {
207    /// Bytes the v2 tail adds: the 8 KiB CHR-RAM when the cartridge has no
208    /// CHR-ROM, else nothing. Derived from the loaded ROM, so a save and its
209    /// load (same ROM, checked by the `.rns` hash tag) agree.
210    fn ram_block_len(&self) -> usize {
211        if self.chr_is_ram {
212            self.chr_rom.len()
213        } else {
214            0
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn color_dreams_bus_conflict() {
225        let mut prg = vec![0u8; 32 * 1024];
226        // Make ROM byte at $8000 = 0x55 -> AND with 0xFF gives 0x55.
227        prg[0] = 0x55;
228        let m_prg: Box<[u8]> = prg.into_boxed_slice();
229        let chr = vec![0u8; 8 * 1024].into_boxed_slice();
230        let mut m = ColorDreams::new(m_prg, chr, Mirroring::Vertical).unwrap();
231        m.cpu_write(0x8000, 0xFF);
232        // Effective value = 0xFF & 0x55 = 0x55. PRG bank = 0x55 & 0x03 = 1.
233        assert_eq!(m.prg_bank, 1);
234    }
235
236    /// v2.9.2 cartridge-RAM sweep: the section carries the 8 KiB CHR-RAM of
237    /// a board with no CHR-ROM. The whole-machine pin is
238    /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
239    #[test]
240    fn color_dreams_save_state_carries_chr_ram() {
241        let mut m = ColorDreams::new(
242            vec![0u8; 32 * 1024].into_boxed_slice(),
243            Box::new([]),
244            Mirroring::Vertical,
245        )
246        .unwrap();
247        m.chr_rom[0x0000] = 0x11;
248        m.chr_rom[0x1FFF] = 0x22;
249        let blob = m.save_state();
250        let mut m2 = ColorDreams::new(
251            vec![0u8; 32 * 1024].into_boxed_slice(),
252            Box::new([]),
253            Mirroring::Vertical,
254        )
255        .unwrap();
256        m2.load_state(&blob).expect("round-trip");
257        assert_eq!(m2.chr_rom[0x0000], 0x11);
258        assert_eq!(m2.chr_rom[0x1FFF], 0x22);
259    }
260
261    /// v2.9.8 (ADR 0042): a v1 blob (no RAM tail, written through v2.9.1)
262    /// is refused. Until then it loaded and left the RAM as it was.
263    #[test]
264    fn color_dreams_v1_blob_is_refused() {
265        let mut m = ColorDreams::new(
266            vec![0u8; 32 * 1024].into_boxed_slice(),
267            Box::new([]),
268            Mirroring::Vertical,
269        )
270        .unwrap();
271        m.chr_bank = 5;
272        let core_len = 3 + m.vram.len();
273        let mut v1 = m.save_state()[..core_len].to_vec();
274        v1[0] = 1;
275        let mut m2 = ColorDreams::new(
276            vec![0u8; 32 * 1024].into_boxed_slice(),
277            Box::new([]),
278            Mirroring::Vertical,
279        )
280        .unwrap();
281        assert!(matches!(
282            m2.load_state(&v1),
283            Err(MapperError::UnsupportedVersion(1))
284        ));
285    }
286
287    /// A v2 blob one byte short (inside the CHR-RAM tail) is rejected.
288    #[test]
289    fn color_dreams_truncated_chr_ram_tail_is_rejected() {
290        let m = ColorDreams::new(
291            vec![0u8; 32 * 1024].into_boxed_slice(),
292            Box::new([]),
293            Mirroring::Vertical,
294        )
295        .unwrap();
296        let blob = m.save_state();
297        let mut m2 = ColorDreams::new(
298            vec![0u8; 32 * 1024].into_boxed_slice(),
299            Box::new([]),
300            Mirroring::Vertical,
301        )
302        .unwrap();
303        let err = m2
304            .load_state(&blob[..blob.len() - 1])
305            .expect_err("a truncated v2 blob must be rejected");
306        assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
307    }
308}