Skip to main content

rustynes_mappers/
m022_vrc2.rs

1//! Konami VRC2 (mappers 22, and sub-variants of 23 / 25).
2//!
3//! The VRC2 and VRC4 share a register map that is *identical in decode* but
4//! *rewired at the pins*: each PCB revision ties the two low register-select
5//! address lines to a different pair of CPU address pins, so the same write
6//! reaches a different register depending on the board. That rewiring is the
7//! only real difference between the mapper numbers, and it is isolated in
8//! [`vrc_a_bits`] (duplicated in `m021_vrc4.rs`, as the crate duplicates its
9//! other small shared helpers rather than coupling board modules).
10//!
11//! VRC2 exposes a one-byte CHR latch and, unlike VRC4, has **no IRQ counter**
12//! and no on-cart audio. Its siblings: `m021_vrc4.rs`, `m073_vrc3.rs`,
13//! `m024_vrc6.rs`, `m085_vrc7.rs`, `m075_vrc1.rs`.
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::struct_excessive_bools,
25    clippy::doc_markdown,
26    clippy::range_plus_one,
27    clippy::single_match_else,
28    clippy::bool_to_int_with_if,
29    clippy::unnested_or_patterns,
30    clippy::single_match,
31    clippy::doc_lazy_continuation,
32    clippy::too_long_first_doc_paragraph
33)]
34
35use crate::cartridge::Mirroring;
36use crate::mapper::{Mapper, MapperCaps, MapperError};
37use alloc::{boxed::Box, vec::Vec};
38use alloc::{format, vec};
39
40const PRG_BANK_8K: usize = 0x2000;
41const CHR_BANK_1K: usize = 0x0400;
42const CHR_BANK_8K: usize = 0x2000;
43const NAMETABLE_SIZE: usize = 0x0400;
44const NAMETABLE_SIZE_U16: u16 = 0x0400;
45
46/// Version byte this board writes in its mapper save-state section.
47///
48/// **v1** (through v2.9.1) carried the banking and mirroring registers and
49/// the 2 KiB nametable RAM -- and nothing else. The 8 KiB PRG-RAM at
50/// `$6000-$7FFF` and, on a CHR-RAM cartridge, the 8 KiB CHR-RAM were left
51/// out, and the `.rns` container has no other section that carries
52/// cartridge RAM, so every save-state load, rewind step, run-ahead frame and
53/// netplay rollback kept whatever RAM the running game held instead of the
54/// saved one (core audit v2.9.2 AUD-02). **v2** appends the PRG-RAM, then the
55/// CHR-RAM when present. Since v2.9.8 (ADR 0042)
56/// `load_state` reads v2 only and refuses a v1 blob, which it used to load
57/// with the RAM left untouched.
58const VRC2_SECTION_VERSION: u8 = 2;
59
60fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
61    let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
62    let local = (addr as usize) & (NAMETABLE_SIZE - 1);
63    let physical = mirroring.physical_bank(table);
64    physical * NAMETABLE_SIZE + local
65}
66
67/// Map a VRC2/4 register address to its (a0, a1) register-select pin pair.
68///
69/// Per the nesdev "VRC2 and VRC4" wiki, the iNES mapper number selects
70/// which CPU address lines are wired to the chip's A0/A1 register-select
71/// pins.  On real Konami boards the two candidate lines for each pin are
72/// physically tied together, so a write to *either* one drives the pin —
73/// the hardware ORs them.  Modelling that OR (rather than picking a single
74/// bit) is what makes submapper-0 iNES-1.0 ROMs decode correctly: e.g.
75/// mapper 23 games write CHR registers at both `$x002/$x003` (A1/A0) and
76/// `$x008/$x00C` (A3/A2), and a single-bit decoder collapses the latter
77/// set onto register 0.
78///
79/// Here `a0` is the chip's *high-nibble* select (register address +1) and
80/// `a1` is the *next-register* select (register address +2), matching how
81/// the callers consume the pair: `slot = a1 ? base+1 : base` and
82/// `low = !a0`.  Mapped to CPU address lines per mapper:
83///
84/// | Mapper | a0 (high) driven by | a1 (reg-sel) driven by |
85/// |--------|---------------------|------------------------|
86/// | 21     | A1, A6              | A2, A7                 |  (VRC4a/c)
87/// | 22     | A1                  | A0                     |  (VRC2a — A0/A1 SWAPPED)
88/// | 23     | A0, A2              | A1, A3                 |  (VRC4e/f, VRC2b)
89/// | 25     | A1, A3              | A0, A2                 |  (VRC4b/d, VRC2c — swapped)
90///
91/// VRC2a (mapper 22) and VRC2c (mapper 25) both wire the chip's A0 register
92/// pin to CPU A1 and A1 to CPU A0 (the swap); VRC2b (mapper 23) is straight.
93/// The v2.4.0 fix swapped 25 but left 22 straight, leaving TwinBee 3's BG
94/// tiles scrambled (the sprite slots happened to land right); v2.4.1 swaps 22.
95///
96/// Verified against the per-game register-write traces (Crisis Force /
97/// Akumajou = mapper 23 use offsets $0/$4/$8/$C; Wai Wai World 2 = mapper
98/// 21 use $0/$2/$4/$6; TwinBee 3 = mapper 22 and Goemon Gaiden = mapper 25
99/// use $0/$1/$2/$3).  NES 2.0 submappers, when present, pin a single line;
100/// OR-ing the candidate lines is a superset that decodes those correctly
101/// because a given ROM only toggles one of the board-tied lines.
102fn vrc_a_bits(mapper_id: u16, _submapper: u8, addr: u16) -> (bool, bool) {
103    let bit = |n: u16| (addr >> n) & 1 != 0;
104    match mapper_id {
105        21 => (bit(1) | bit(6), bit(2) | bit(7)),
106        22 => (bit(1), bit(0)), // VRC2a: A0/A1 SWAPPED (chip A0<-CPU A1)
107        25 => (bit(1) | bit(3), bit(0) | bit(2)), // VRC2c/VRC4b/d: swapped
108        // Mapper 23 (and any other VRC2/4 fallback).
109        _ => (bit(0) | bit(2), bit(1) | bit(3)),
110    }
111}
112
113/// VRC2 (Mapper 22 + sub-variants of 23/25).
114pub struct Vrc2 {
115    prg_rom: Box<[u8]>,
116    chr_rom: Box<[u8]>,
117    vram: Box<[u8]>,
118    chr_is_ram: bool,
119    prg_lo: u8,
120    prg_mid: u8,
121    chr: [u8; 8],
122    mirroring: Mirroring,
123    mapper_id: u16,
124    submapper: u8,
125    /// 8 KiB WRAM at $6000-$7FFF (battery-backed on most Konami carts).
126    /// T-60-003b (2026-05-17).
127    prg_ram: Box<[u8]>,
128}
129
130impl Vrc2 {
131    /// Construct a new VRC2 mapper.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`MapperError::Invalid`] on size mismatch.
136    pub fn new(
137        prg_rom: Box<[u8]>,
138        chr_rom: Box<[u8]>,
139        mapper_id: u16,
140        submapper: u8,
141        mirroring: Mirroring,
142    ) -> Result<Self, MapperError> {
143        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
144            return Err(MapperError::Invalid(format!(
145                "VRC2 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
146                prg_rom.len()
147            )));
148        }
149        let chr_is_ram = chr_rom.is_empty();
150        let chr: Box<[u8]> = if chr_is_ram {
151            vec![0u8; CHR_BANK_8K].into_boxed_slice()
152        } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
153            chr_rom
154        } else {
155            return Err(MapperError::Invalid(format!(
156                "VRC2 CHR-ROM size {} is not a multiple of 1 KiB",
157                chr_rom.len()
158            )));
159        };
160        Ok(Self {
161            prg_rom,
162            chr_rom: chr,
163            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
164            chr_is_ram,
165            prg_lo: 0,
166            prg_mid: 1,
167            chr: [0; 8],
168            mirroring,
169            mapper_id,
170            submapper,
171            // 8 KiB WRAM at $6000-$7FFF (T-60-003b).
172            prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
173        })
174    }
175
176    fn prg_offset(&self, addr: u16) -> usize {
177        let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
178        let last1 = total_8k - 1;
179        let last2 = total_8k.saturating_sub(2);
180        let bank = match addr & 0xE000 {
181            0x8000 => (self.prg_lo as usize) % total_8k,
182            0xA000 => (self.prg_mid as usize) % total_8k,
183            0xC000 => last2,
184            0xE000 => last1,
185            _ => 0,
186        };
187        bank * PRG_BANK_8K + (addr as usize & 0x1FFF)
188    }
189
190    fn chr_offset(&self, addr: u16) -> usize {
191        let addr = (addr & 0x1FFF) as usize;
192        let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
193        let slot = addr / CHR_BANK_1K;
194        // VRC2a (mapper 22) does not connect the low bit of the CHR bank
195        // value: the effective 1 KiB bank is `register >> 1`.  Real ROMs
196        // rely on this — e.g. TwinBee 3 writes bank $A8 (168) to a slot of
197        // a 128 KiB (128-bank) CHR-ROM, which is only in range as $54 (84)
198        // after the shift.  CHR-RAM carts (chr_is_ram) address linearly,
199        // and only mapper 22 has the dropped-low-bit wiring (mappers 23/25
200        // are routed to the Vrc4 type, but guard on the id regardless).
201        let raw = if self.mapper_id == 22 && !self.chr_is_ram {
202            self.chr[slot] as usize >> 1
203        } else {
204            self.chr[slot] as usize
205        };
206        let bank = raw % total_1k;
207        bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
208    }
209
210    fn write_chr_reg(&mut self, slot: usize, low: bool, value: u8) {
211        let cur = self.chr[slot];
212        let v = if low {
213            (cur & 0xF0) | (value & 0x0F)
214        } else {
215            (cur & 0x0F) | ((value & 0x1F) << 4)
216        };
217        self.chr[slot] = v;
218    }
219}
220
221impl Mapper for Vrc2 {
222    fn sram(&self) -> &[u8] {
223        &self.prg_ram
224    }
225    fn sram_mut(&mut self) -> &mut [u8] {
226        &mut self.prg_ram
227    }
228    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
229    // skips all four per-CPU-cycle dispatches for this board.
230    fn caps(&self) -> MapperCaps {
231        MapperCaps::NONE
232    }
233
234    fn cpu_read(&mut self, addr: u16) -> u8 {
235        match addr {
236            // T-60-003b (2026-05-17): Konami's VRC2 carts include 8KB
237            // battery-backed WRAM at $6000-$7FFF (e.g., Ganbare Goemon 2
238            // reads its save magic from $7E14 area at boot). Pre-fix
239            // returned 0 here; the games' save-validation paths got
240            // stuck-at-uniform-gray as a result. Now reads the
241            // allocated `prg_ram` byte.
242            0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()],
243            0x8000..=0xFFFF => {
244                let off = self.prg_offset(addr);
245                self.prg_rom[off % self.prg_rom.len()]
246            }
247            _ => 0,
248        }
249    }
250
251    fn cpu_write(&mut self, addr: u16, value: u8) {
252        // T-60-003b (2026-05-17): WRAM at $6000-$7FFF (paired with the
253        // read fix above). Without the write path, save data written by
254        // the game is silently dropped on the floor.
255        if (0x6000..=0x7FFF).contains(&addr) {
256            let len = self.prg_ram.len();
257            self.prg_ram[(addr - 0x6000) as usize % len] = value;
258            return;
259        }
260        let (a0, a1) = vrc_a_bits(self.mapper_id, self.submapper, addr);
261        match addr & 0xF000 {
262            0x8000 => self.prg_lo = value & 0x1F,
263            0x9000 => {
264                self.mirroring = match value & 0x03 {
265                    0 => Mirroring::Vertical,
266                    1 => Mirroring::Horizontal,
267                    2 => Mirroring::SingleScreenA,
268                    _ => Mirroring::SingleScreenB,
269                };
270            }
271            0xA000 => self.prg_mid = value & 0x1F,
272            0xB000 => {
273                let slot = if a1 { 1 } else { 0 };
274                self.write_chr_reg(slot, !a0, value);
275            }
276            0xC000 => {
277                let slot = if a1 { 3 } else { 2 };
278                self.write_chr_reg(slot, !a0, value);
279            }
280            0xD000 => {
281                let slot = if a1 { 5 } else { 4 };
282                self.write_chr_reg(slot, !a0, value);
283            }
284            0xE000 => {
285                let slot = if a1 { 7 } else { 6 };
286                self.write_chr_reg(slot, !a0, value);
287            }
288            _ => {}
289        }
290    }
291
292    fn ppu_read(&mut self, addr: u16) -> u8 {
293        let addr = addr & 0x3FFF;
294        match addr {
295            0x0000..=0x1FFF => {
296                let off = self.chr_offset(addr);
297                self.chr_rom[off % self.chr_rom.len()]
298            }
299            0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
300            _ => 0,
301        }
302    }
303
304    fn ppu_write(&mut self, addr: u16, value: u8) {
305        let addr = addr & 0x3FFF;
306        match addr {
307            0x0000..=0x1FFF => {
308                if self.chr_is_ram {
309                    let len = self.chr_rom.len();
310                    self.chr_rom[addr as usize % len] = value;
311                }
312            }
313            0x2000..=0x3EFF => {
314                let off = nametable_offset(addr, self.mirroring) % self.vram.len();
315                self.vram[off] = value;
316            }
317            _ => {}
318        }
319    }
320
321    fn current_mirroring(&self) -> Mirroring {
322        self.mirroring
323    }
324
325    fn save_state(&self) -> Vec<u8> {
326        let mut out = Vec::with_capacity(20 + self.vram.len() + self.ram_block_len());
327        out.push(VRC2_SECTION_VERSION);
328        out.push(self.prg_lo);
329        out.push(self.prg_mid);
330        out.extend_from_slice(&self.chr);
331        out.push(self.mirroring as u8);
332        out.extend_from_slice(&self.vram);
333        // --- v2 tail: the on-cart RAM (see `VRC2_SECTION_VERSION`) ---
334        out.extend_from_slice(&self.prg_ram);
335        if self.chr_is_ram {
336            out.extend_from_slice(&self.chr_rom);
337        }
338        out
339    }
340
341    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
342        let version = data.first().copied().unwrap_or(0);
343        // Only the current layout is read (v2.9.8, ADR 0042). A v1 blob, which
344        // stopped before the RAM block, is refused rather than loaded with the
345        // RAM left as it was.
346        if version != VRC2_SECTION_VERSION {
347            return Err(MapperError::UnsupportedVersion(version));
348        }
349        let ram_len = self.ram_block_len();
350        // The whole length is validated before the first field is written.
351        let core_len = 12 + self.vram.len();
352        let expected = core_len + ram_len;
353        if data.len() != expected {
354            return Err(MapperError::WrongLength {
355                expected,
356                got: data.len(),
357            });
358        }
359        self.prg_lo = data[1];
360        self.prg_mid = data[2];
361        self.chr.copy_from_slice(&data[3..11]);
362        self.mirroring = match data[11] {
363            0 => Mirroring::Horizontal,
364            1 => Mirroring::Vertical,
365            2 => Mirroring::SingleScreenA,
366            3 => Mirroring::SingleScreenB,
367            4 => Mirroring::FourScreen,
368            5 => Mirroring::MapperControlled,
369            other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
370        };
371        self.vram.copy_from_slice(&data[12..core_len]);
372        let (prg, chr) = data[core_len..].split_at(self.prg_ram.len());
373        self.prg_ram.copy_from_slice(prg);
374        if self.chr_is_ram {
375            self.chr_rom.copy_from_slice(chr);
376        }
377        Ok(())
378    }
379}
380
381impl Vrc2 {
382    /// Bytes the v2 tail adds: the 8 KiB PRG-RAM, plus the 8 KiB CHR-RAM
383    /// when the cartridge has no CHR-ROM. Derived from the loaded ROM, so a
384    /// save and its load (same ROM, checked by the `.rns` hash tag) agree.
385    fn ram_block_len(&self) -> usize {
386        self.prg_ram.len()
387            + if self.chr_is_ram {
388                self.chr_rom.len()
389            } else {
390                0
391            }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn synth(banks_8k: usize) -> Box<[u8]> {
400        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
401        for b in 0..banks_8k {
402            v[b * PRG_BANK_8K] = b as u8;
403        }
404        v.into_boxed_slice()
405    }
406
407    fn synth_chr(banks_1k: usize) -> Box<[u8]> {
408        let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
409        for b in 0..banks_1k {
410            v[b * CHR_BANK_1K] = b as u8;
411        }
412        v.into_boxed_slice()
413    }
414
415    #[test]
416    fn vrc24_a_bits_per_board_pin_rewiring() {
417        // The a0 (high-nibble) and a1 (register-select) pins are wired to
418        // different CPU address lines per mapper number. On real Konami
419        // boards the two candidate lines for each pin are tied together, so
420        // the decode ORs them. Confirmed against per-game register-write
421        // traces (see vrc_a_bits doc comment). Base $8000; only the low
422        // decode bits matter. `(a0, a1)`.
423        //
424        // Mapper 21: a0 = A1|A6, a1 = A2|A7.
425        assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 1)), (true, false));
426        assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 6)), (true, false));
427        assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 2)), (false, true));
428        assert_eq!(vrc_a_bits(21, 0, 0x8000 | (1 << 7)), (false, true));
429        // Mapper 22 (VRC2a): a0 = A1, a1 = A0 (SWAPPED, like VRC2c/m25).
430        assert_eq!(vrc_a_bits(22, 0, 0x8000 | (1 << 1)), (true, false));
431        assert_eq!(vrc_a_bits(22, 0, 0x8000 | (1 << 0)), (false, true));
432        // Mapper 23: a0 = A0|A2, a1 = A1|A3 (Crisis Force uses A2/A3).
433        assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 0)), (true, false));
434        assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 2)), (true, false));
435        assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 1)), (false, true));
436        assert_eq!(vrc_a_bits(23, 0, 0x8000 | (1 << 3)), (false, true));
437        // Mapper 25: a0 = A1|A3, a1 = A0|A2 (swapped).
438        assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 1)), (true, false));
439        assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 3)), (true, false));
440        assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 0)), (false, true));
441        assert_eq!(vrc_a_bits(25, 0, 0x8000 | (1 << 2)), (false, true));
442    }
443
444    #[test]
445    fn vrc2_prg_bank_registers_and_fixed_banks() {
446        // 8 PRG banks (each tagged with its index byte at the bank base).
447        let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
448        // $8000 selects the $8000-$9FFF bank (prg_lo); $A000 selects the
449        // $A000-$BFFF bank (prg_mid). $C000/$E000 are fixed to last-2/last-1.
450        m.cpu_write(0x8000, 3);
451        m.cpu_write(0xA000, 5);
452        assert_eq!(m.cpu_read(0x8000), 3, "prg_lo -> bank 3");
453        assert_eq!(m.cpu_read(0xA000), 5, "prg_mid -> bank 5");
454        assert_eq!(m.cpu_read(0xC000), 6, "fixed -> last-2 (bank 6 of 8)");
455        assert_eq!(m.cpu_read(0xE000), 7, "fixed -> last-1 (bank 7 of 8)");
456        // The 5-bit bank field masks high bits.
457        m.cpu_write(0x8000, 0xE0 | 2);
458        assert_eq!(m.cpu_read(0x8000), 2, "high bits above 5-bit field ignored");
459    }
460
461    #[test]
462    fn vrc2_mirroring_control_register() {
463        let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
464        m.cpu_write(0x9000, 0);
465        assert_eq!(m.mirroring, Mirroring::Vertical);
466        m.cpu_write(0x9000, 1);
467        assert_eq!(m.mirroring, Mirroring::Horizontal);
468        m.cpu_write(0x9000, 2);
469        assert_eq!(m.mirroring, Mirroring::SingleScreenA);
470        m.cpu_write(0x9000, 3);
471        assert_eq!(m.mirroring, Mirroring::SingleScreenB);
472    }
473
474    #[test]
475    fn vrc2_chr_bank_low_high_nibble_split() {
476        // CHR registers are written as low/high nibbles selected by a0, with
477        // the bank slot pair selected by a1. Using VRC2b default wiring
478        // (a0=bit0, a1=bit1), $B000 writes CHR slot 0 (a1=0): low nibble at
479        // a0=0, high nibble at a0=1. Assemble bank 0x12 into slot 0 and read
480        // CHR byte 0 (each CHR bank base is tagged with its index byte).
481        let mut m = Vrc2::new(synth(8), synth_chr(0x20), 23, 3, Mirroring::Vertical).unwrap();
482        // $B000 (a0=0): low nibble = 0x2.
483        m.cpu_write(0xB000, 0x2);
484        // $B001 (a0=1): high nibble = 0x1 -> bank = 0x12.
485        m.cpu_write(0xB001, 0x1);
486        assert_eq!(m.ppu_read(0x0000), 0x12, "CHR slot 0 -> bank 0x12");
487    }
488
489    /// Core audit v2.9.2 AUD-02: the section carries the 8 KiB PRG-RAM and,
490    /// on a CHR-RAM board, the 8 KiB CHR-RAM. The core-level pin is
491    /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
492    #[test]
493    fn vrc2_save_state_carries_prg_ram_and_chr_ram() {
494        let mut m = Vrc2::new(synth(8), Box::new([]), 22, 0, Mirroring::Vertical).unwrap();
495        m.cpu_write(0x6000, 0x5A);
496        m.cpu_write(0x7FFF, 0xA5);
497        m.ppu_write(0x0000, 0x11);
498        // Inside 1 KiB slot 0 (bank 0 at power-on), so the read path's
499        // banking and the write path agree whatever the registers hold.
500        m.ppu_write(0x03FF, 0x22);
501        let blob = m.save_state();
502        let mut m2 = Vrc2::new(synth(8), Box::new([]), 22, 0, Mirroring::Vertical).unwrap();
503        m2.load_state(&blob).expect("round-trip");
504        assert_eq!(m2.cpu_read(0x6000), 0x5A);
505        assert_eq!(m2.cpu_read(0x7FFF), 0xA5);
506        assert_eq!(m2.ppu_read(0x0000), 0x11);
507        assert_eq!(m2.ppu_read(0x03FF), 0x22);
508    }
509
510    /// v2.9.8 (ADR 0042): a v1 blob (no RAM tail, written through v2.9.1)
511    /// is refused. Until then it loaded and left the RAM as it was.
512    #[test]
513    fn vrc2_v1_blob_is_refused() {
514        let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
515        m.cpu_write(0x8000, 3);
516        let core_len = 12 + m.vram.len();
517        let mut v1 = m.save_state()[..core_len].to_vec();
518        v1[0] = 1;
519        let mut m2 = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
520        assert!(matches!(
521            m2.load_state(&v1),
522            Err(MapperError::UnsupportedVersion(1))
523        ));
524    }
525
526    /// A v2 blob one byte short (inside the RAM tail) is rejected.
527    #[test]
528    fn vrc2_truncated_ram_tail_is_rejected() {
529        let m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
530        let blob = m.save_state();
531        let mut m2 = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
532        let err = m2
533            .load_state(&blob[..blob.len() - 1])
534            .expect_err("a truncated v2 blob must be rejected");
535        assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
536    }
537}