Skip to main content

rustynes_mappers/
m075_vrc1.rs

1//! Konami VRC1 (mapper 75) -- the first and simplest VRC ASIC.
2//!
3//! Three 8 KiB PRG banks plus a fixed last bank, two 4 KiB CHR banks, and
4//! mirroring control. The quirk worth knowing: each CHR bank register is
5//! only four bits wide, and its *fifth* bit lives in the mirroring register
6//! at `$9000` -- so a CHR bank select above 15 requires writing two
7//! different registers.
8//!
9//! Unlike VRC2/VRC4/VRC6/VRC7 there is no IRQ counter and no on-cart audio;
10//! see `m022_vrc2.rs`, `m021_vrc4.rs`, `m073_vrc3.rs`, `m024_vrc6.rs`, `m085_vrc7.rs` for those.
11//!
12//! See `docs/mappers.md` §Mapper coverage matrix.
13
14#![allow(
15    clippy::cast_possible_truncation,
16    clippy::cast_lossless,
17    clippy::missing_const_for_fn,
18    clippy::needless_pass_by_ref_mut,
19    clippy::manual_range_patterns,
20    clippy::match_same_arms,
21    clippy::too_many_arguments
22)]
23
24use crate::cartridge::Mirroring;
25use crate::mapper::{Mapper, MapperCaps, MapperError};
26use alloc::{boxed::Box, vec::Vec};
27use alloc::{format, vec};
28
29const PRG_BANK_8K: usize = 0x2000;
30const CHR_BANK_4K: usize = 0x1000;
31const CHR_BANK_8K: usize = 0x2000;
32const NAMETABLE_SIZE: usize = 0x0400;
33const NAMETABLE_SIZE_U16: u16 = 0x0400;
34
35/// Version byte this board writes in its mapper save-state section. Shared by
36/// mapper 151 (Konami VS), which wraps this core and forwards its section.
37///
38/// **v1** (through v2.9.1) carried the three PRG banks, the CHR bank
39/// registers, the mirroring and the 2 KiB nametable RAM. On a cartridge with
40/// no CHR-ROM the 8 KiB CHR-RAM was left out, and the `.rns` container has no
41/// other section that carries cartridge RAM -- so every save-state load,
42/// rewind step, run-ahead frame and netplay rollback kept the running game's
43/// CHR-RAM instead of the saved one (the v2.9.2 cartridge-RAM sweep; the same
44/// omission core audit AUD-02 found on the other Konami VRC boards). **v2**
45/// appends the CHR-RAM 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 VRC1_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/// VRC1 (Mapper 75).
58pub struct Vrc1 {
59    prg_rom: Box<[u8]>,
60    chr_rom: Box<[u8]>,
61    vram: Box<[u8]>,
62    chr_is_ram: bool,
63    prg_banks: [u8; 3], // $8000, $A000, $C000
64    chr_lo: u8,
65    chr_hi: u8,
66    chr_lo_msb: u8,
67    chr_hi_msb: u8,
68    mirroring: Mirroring,
69}
70
71impl Vrc1 {
72    /// Construct a new VRC1 mapper.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`MapperError::Invalid`] on size mismatch.
77    pub fn new(
78        prg_rom: Box<[u8]>,
79        chr_rom: Box<[u8]>,
80        mirroring: Mirroring,
81    ) -> Result<Self, MapperError> {
82        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
83            return Err(MapperError::Invalid(format!(
84                "VRC1 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
85                prg_rom.len()
86            )));
87        }
88        let chr_is_ram = chr_rom.is_empty();
89        let chr: Box<[u8]> = if chr_is_ram {
90            vec![0u8; CHR_BANK_8K].into_boxed_slice()
91        } else if chr_rom.len().is_multiple_of(CHR_BANK_4K) {
92            chr_rom
93        } else {
94            return Err(MapperError::Invalid(format!(
95                "VRC1 CHR-ROM size {} is not a multiple of 4 KiB",
96                chr_rom.len()
97            )));
98        };
99        Ok(Self {
100            prg_rom,
101            chr_rom: chr,
102            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
103            chr_is_ram,
104            prg_banks: [0, 1, 2],
105            chr_lo: 0,
106            chr_hi: 0,
107            chr_lo_msb: 0,
108            chr_hi_msb: 0,
109            mirroring,
110        })
111    }
112}
113
114impl Mapper for Vrc1 {
115    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
116    // skips all four per-CPU-cycle dispatches for this board.
117    fn caps(&self) -> MapperCaps {
118        MapperCaps::NONE
119    }
120
121    fn cpu_read(&mut self, addr: u16) -> u8 {
122        let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
123        let last = total_8k - 1;
124        let bank = match addr & 0xE000 {
125            0x8000 => (self.prg_banks[0] as usize) % total_8k,
126            0xA000 => (self.prg_banks[1] as usize) % total_8k,
127            0xC000 => (self.prg_banks[2] as usize) % total_8k,
128            0xE000 => last,
129            _ => return 0,
130        };
131        self.prg_rom[(bank * PRG_BANK_8K + (addr as usize & 0x1FFF)) % self.prg_rom.len()]
132    }
133
134    fn cpu_write(&mut self, addr: u16, value: u8) {
135        match addr & 0xF000 {
136            0x8000 => self.prg_banks[0] = value & 0x0F,
137            0x9000 => {
138                // Mirroring (bit 0) + CHR MSB bits.
139                self.mirroring = if value & 1 == 0 {
140                    Mirroring::Vertical
141                } else {
142                    Mirroring::Horizontal
143                };
144                self.chr_lo_msb = (value >> 1) & 1;
145                self.chr_hi_msb = (value >> 2) & 1;
146            }
147            0xA000 => self.prg_banks[1] = value & 0x0F,
148            0xC000 => self.prg_banks[2] = value & 0x0F,
149            0xE000 => self.chr_lo = value & 0x0F,
150            0xF000 => self.chr_hi = value & 0x0F,
151            _ => {}
152        }
153    }
154
155    fn ppu_read(&mut self, addr: u16) -> u8 {
156        let addr = addr & 0x3FFF;
157        match addr {
158            0x0000..=0x0FFF => {
159                let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
160                let bank = (((self.chr_lo_msb as usize) << 4) | (self.chr_lo as usize)) % total_4k;
161                self.chr_rom[(bank * CHR_BANK_4K + addr as usize) % self.chr_rom.len()]
162            }
163            0x1000..=0x1FFF => {
164                let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
165                let bank = (((self.chr_hi_msb as usize) << 4) | (self.chr_hi as usize)) % total_4k;
166                self.chr_rom[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr_rom.len()]
167            }
168            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
169            _ => 0,
170        }
171    }
172
173    fn ppu_write(&mut self, addr: u16, value: u8) {
174        let addr = addr & 0x3FFF;
175        match addr {
176            0x0000..=0x1FFF => {
177                if self.chr_is_ram {
178                    let len = self.chr_rom.len();
179                    self.chr_rom[addr as usize % len] = value;
180                }
181            }
182            0x2000..=0x3EFF => {
183                let off = nametable_offset(addr, self.mirroring) % self.vram.len();
184                self.vram[off] = value;
185            }
186            _ => {}
187        }
188    }
189
190    fn current_mirroring(&self) -> Mirroring {
191        self.mirroring
192    }
193
194    fn save_state(&self) -> Vec<u8> {
195        let mut out = Vec::with_capacity(16 + self.vram.len() + self.ram_block_len());
196        out.push(VRC1_SECTION_VERSION);
197        out.extend_from_slice(&self.prg_banks);
198        out.push(self.chr_lo);
199        out.push(self.chr_hi);
200        out.push(self.chr_lo_msb);
201        out.push(self.chr_hi_msb);
202        out.push(self.mirroring as u8);
203        out.extend_from_slice(&self.vram);
204        // --- v2 tail: the CHR-RAM, if any (see `VRC1_SECTION_VERSION`) ---
205        if self.chr_is_ram {
206            out.extend_from_slice(&self.chr_rom);
207        }
208        out
209    }
210
211    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
212        let version = data.first().copied().unwrap_or(0);
213        // Only the current layout is read (v2.9.8, ADR 0042). A v1 blob, which
214        // stopped before the RAM block, is refused rather than loaded with the
215        // RAM left as it was.
216        if version != VRC1_SECTION_VERSION {
217            return Err(MapperError::UnsupportedVersion(version));
218        }
219        let ram_len = self.ram_block_len();
220        // The whole length is validated before the first field is written.
221        let core_len = 9 + self.vram.len();
222        let expected = core_len + ram_len;
223        if data.len() != expected {
224            return Err(MapperError::WrongLength {
225                expected,
226                got: data.len(),
227            });
228        }
229        self.prg_banks.copy_from_slice(&data[1..4]);
230        self.chr_lo = data[4];
231        self.chr_hi = data[5];
232        self.chr_lo_msb = data[6];
233        self.chr_hi_msb = data[7];
234        self.mirroring = match data[8] {
235            0 => Mirroring::Horizontal,
236            1 => Mirroring::Vertical,
237            2 => Mirroring::SingleScreenA,
238            3 => Mirroring::SingleScreenB,
239            4 => Mirroring::FourScreen,
240            5 => Mirroring::MapperControlled,
241            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
242        };
243        self.vram.copy_from_slice(&data[9..core_len]);
244        if self.chr_is_ram {
245            self.chr_rom.copy_from_slice(&data[core_len..]);
246        }
247        Ok(())
248    }
249}
250
251impl Vrc1 {
252    /// Bytes the v2 tail adds: the 8 KiB CHR-RAM when the cartridge has no
253    /// CHR-ROM, else nothing. Derived from the loaded ROM, so a save and its
254    /// load (same ROM, checked by the `.rns` hash tag) agree.
255    fn ram_block_len(&self) -> usize {
256        if self.chr_is_ram {
257            self.chr_rom.len()
258        } else {
259            0
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn synth(banks_8k: usize) -> Box<[u8]> {
269        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
270        for b in 0..banks_8k {
271            v[b * PRG_BANK_8K] = b as u8;
272        }
273        v.into_boxed_slice()
274    }
275
276    fn synth_chr_4k(banks: usize) -> Box<[u8]> {
277        let mut v = vec![0u8; banks * CHR_BANK_4K];
278        for b in 0..banks {
279            v[b * CHR_BANK_4K] = b as u8;
280        }
281        v.into_boxed_slice()
282    }
283
284    #[test]
285    fn vrc1_basic_banking() {
286        let mut m = Vrc1::new(synth(8), synth_chr_4k(2), Mirroring::Vertical).unwrap();
287        m.cpu_write(0x8000, 3);
288        assert_eq!(m.cpu_read(0x8000), 3);
289        // $E000 is fixed last bank.
290        assert_eq!(m.cpu_read(0xE000), 7);
291    }
292
293    /// v2.9.2 cartridge-RAM sweep: the section carries the 8 KiB CHR-RAM of
294    /// a board with no CHR-ROM. The whole-machine pin is
295    /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
296    #[test]
297    fn vrc1_save_state_carries_chr_ram() {
298        let mut m = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
299        m.chr_rom[0x0000] = 0x11;
300        m.chr_rom[0x1FFF] = 0x22;
301        let blob = m.save_state();
302        let mut m2 = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
303        m2.load_state(&blob).expect("round-trip");
304        assert_eq!(m2.chr_rom[0x0000], 0x11);
305        assert_eq!(m2.chr_rom[0x1FFF], 0x22);
306    }
307
308    /// v2.9.8 (ADR 0042): a v1 blob (no RAM tail, written through v2.9.1)
309    /// is refused. Until then it loaded and left the RAM as it was.
310    #[test]
311    fn vrc1_v1_blob_is_refused() {
312        let mut m = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
313        m.cpu_write(0x8000, 3);
314        let core_len = 9 + m.vram.len();
315        let mut v1 = m.save_state()[..core_len].to_vec();
316        v1[0] = 1;
317        let mut m2 = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
318        assert!(matches!(
319            m2.load_state(&v1),
320            Err(MapperError::UnsupportedVersion(1))
321        ));
322    }
323
324    /// A v2 blob one byte short (inside the CHR-RAM tail) is rejected.
325    #[test]
326    fn vrc1_truncated_chr_ram_tail_is_rejected() {
327        let m = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
328        let blob = m.save_state();
329        let mut m2 = Vrc1::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
330        let err = m2
331            .load_state(&blob[..blob.len() - 1])
332            .expect_err("a truncated v2 blob must be rejected");
333        assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
334    }
335}