Skip to main content

rustynes_mappers/
m099_vs_system.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the Vs. DualSystem sub-console banking (the second CHR half via an outer bank of 2, the second PRG half via an outer 8 KiB page of 4) is derived from Mesen2 (GPL-3.0-or-later) `VsSystem.h` (`chrOuter` / `prgOuter`), whose expressions the in-file comments quote. Classified as derived in v2.7.1 (core audit section 6.2). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5
6//! Nintendo Vs. System (iNES mapper 99) implementation.
7//!
8//! The Vs. `UniSystem` cartridge board is electrically a fixed-PRG board (8 KiB,
9//! 16 KiB, or 2x16 KiB = 32 KiB of PRG-ROM mapped straight into `$8000-$FFFF`)
10//! with an 8 KiB switchable CHR-ROM bank. The defining quirk of the board is
11//! that the CHR bank select is **bit 2 of the value written to `$4016`** (the
12//! Vs. coin/CHR register) — not a `$8000-$FFFF` write like most CHR-banked
13//! boards. The single CHR-select bit picks between the first two 8 KiB CHR
14//! banks; a cart with only 8 KiB of CHR ignores it.
15//!
16//! The `$4016` write is shared with controller strobing (the standard NES
17//! `OUT0` line), so the bus forwards every `$4016` write to the mapper *in
18//! addition to* committing the controller strobe — see
19//! `rustynes_core::bus` `$4016` write handling. Only mapper 99 consumes it; every
20//! other mapper's `cpu_write` ignores the `$4016` address.
21//!
22//! The Vs. System replaces the 2C02 composite PPU with an RGB PPU
23//! (2C03 / 2C04-000x / 2C05). RustyNES routes the RGB palette selection
24//! through the NES 2.0 header (`ConsoleType::VsSystem` + `VsPpuType`); the
25//! `crate::parse` dispatch promotes a mapper-99 cart to `VsSystem` + the most
26//! common 2C03 RGB PPU when the header does not already carry a resolved
27//! Vs. PPU type (iNES 1.0 has no byte-13). The mapper itself only handles
28//! banking; the palette lives in the PPU.
29//!
30//! The CPU board also carries 2 KiB of RAM at `$6000-$7FFF` (mirrored across
31//! the 8 KiB window). On a `UniSystem` cabinet the primary CPU's `$4016` bit 1
32//! (`OUT1`) decides whether it sees that RAM (1) or open bus (0); the
33//! `DualSystem` wrapper shares one copy between both consoles instead.
34//!
35//! Mirroring is fixed from the iNES header. There is no IRQ.
36//!
37//! See `docs/mappers.md` §Vs. System and `nesdev_wiki/INES_Mapper_099.xhtml`.
38
39#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
40
41use crate::cartridge::Mirroring;
42use crate::mapper::{Mapper, MapperCaps, MapperError};
43use alloc::{boxed::Box, vec::Vec};
44use alloc::{format, vec};
45
46const CHR_BANK_8K: usize = 0x2000;
47const NAMETABLE_SIZE: usize = 0x0400;
48const NAMETABLE_SIZE_U16: u16 = 0x0400;
49
50/// v2.0.0 beta.5: the save-state layout emitted when the Vs. `DualSystem`
51/// shared WRAM is provisioned (the v1 layout + the 2 KiB WRAM tail).
52const SAVE_STATE_VERSION_DUAL: u8 = 2;
53/// v2.9.8: the `UniSystem` layout once the board's 2 KiB RAM is modelled
54/// (the v1 layout + the `OUT1` latch + the 2 KiB RAM tail). Layout 1 itself
55/// (no RAM) is refused since v2.9.8 (ADR 0042); it used to load with the RAM
56/// cleared and `OUT1` low.
57const SAVE_STATE_VERSION_UNI_RAM: u8 = 3;
58/// The board's work RAM at `$6000-$7FFF`: 2 KiB, mirrored across the 8 KiB
59/// window (nesdev "Vs. System" and `INES_Mapper_099`).
60const WRAM_SIZE: usize = 0x0800;
61
62/// Nintendo Vs. System mapper (iNES mapper 99).
63pub struct VsSystem {
64    prg_rom: Box<[u8]>,
65    chr: Box<[u8]>,
66    vram: Box<[u8]>,
67    chr_is_ram: bool,
68    /// 8 KiB CHR bank index (only bit 0 is meaningful — `$4016` bit 2).
69    chr_bank: u8,
70    mirroring: Mirroring,
71    /// v2.0.0 beta.5 (Vs. `DualSystem`): this console's COPY of the cabinet's
72    /// shared 2 KiB work RAM at `$6000-$7FFF` (mirrored across the 8 KiB
73    /// window — MAME: `map(0x6000, 0x67ff).mirror(0x1800)`). `None` on
74    /// `UniSystem` carts — their `$6000` window keeps the pre-`DualSystem`
75    /// behavior byte-identically. Provisioned by
76    /// [`Mapper::enable_vs_dual_wram`] from the `VsDualSystem` wrapper on
77    /// BOTH consoles; the wrapper converges the two copies by draining
78    /// [`Mapper::drain_vs_dual_wram_writes`] into the partner's
79    /// [`Mapper::apply_vs_dual_wram_write`] after every stepped instruction
80    /// (MAME's fully-shared `.share("nvram")` at soft-lockstep granularity).
81    dual_wram: Option<Box<[u8]>>,
82    /// v2.9.8: the same 2 KiB RAM on a `UniSystem` cabinet (`dual_wram:
83    /// None`). The MDS-0x CPU board carries it in both cabinet types; on the
84    /// primary CPU, `$4016` bit 1 (`OUT1`) decides whether the CPU sees it
85    /// (1) or open bus (0) (nesdev "Vs. System", `$4016` write). Before
86    /// v2.9.8 a `UniSystem` cart read 0 here and dropped every write, so
87    /// *Vs. Super Mario Bros.*, which keeps its state in this RAM, never left
88    /// its first frame.
89    uni_wram: Box<[u8]>,
90    /// v2.9.8: the last `$4016` write's bit 1 (`OUT1`) -- on a `UniSystem`
91    /// cabinet, whether the CPU owns `uni_wram`. Low at power-on.
92    out1: bool,
93    /// v2.0.0 beta.5: the shared-WRAM write log the wrapper drains toward
94    /// the partner console. Transient (always drained within the stepping
95    /// loop) — deliberately NOT serialized in the save state.
96    dual_wram_log: Vec<(u16, u8)>,
97    /// v2.0.0 beta.5: `true` on the `DualSystem` SUB console's mapper
98    /// instance — banks the second PRG half + the upper CHR pages (the
99    /// two CPUs run different programs). Cabinet wiring, applied by the
100    /// wrapper at construction (like the bus's sub identity); not
101    /// serialized (it survives restore because restore never rebuilds the
102    /// mapper, and a fresh wrapper re-applies it in `from_rom`).
103    dual_sub: bool,
104}
105
106impl VsSystem {
107    /// Construct a new Vs. System mapper.
108    ///
109    /// `prg_rom` must be a non-zero multiple of 8 KiB (the common boards are
110    /// 8 KiB, 16 KiB, or 32 KiB). CHR-RAM is selected when `chr_rom` is empty;
111    /// otherwise CHR-ROM length must be a multiple of 8 KiB.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`MapperError::Invalid`] when the sizes don't match the
116    /// constraints.
117    pub fn new(
118        prg_rom: Box<[u8]>,
119        chr_rom: Box<[u8]>,
120        mirroring: Mirroring,
121    ) -> Result<Self, MapperError> {
122        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(CHR_BANK_8K) {
123            return Err(MapperError::Invalid(format!(
124                "Vs. System PRG-ROM size {} is not a non-zero multiple of 8 KiB",
125                prg_rom.len()
126            )));
127        }
128        let chr_is_ram = chr_rom.is_empty();
129        let chr: Box<[u8]> = if chr_is_ram {
130            vec![0u8; CHR_BANK_8K].into_boxed_slice()
131        } else if chr_rom.len().is_multiple_of(CHR_BANK_8K) {
132            chr_rom
133        } else {
134            return Err(MapperError::Invalid(format!(
135                "Vs. System expects an 8 KiB multiple of CHR; got {} bytes",
136                chr_rom.len()
137            )));
138        };
139        Ok(Self {
140            prg_rom,
141            chr,
142            vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
143            chr_is_ram,
144            chr_bank: 0,
145            mirroring,
146            dual_wram: None,
147            uni_wram: vec![0u8; WRAM_SIZE].into_boxed_slice(),
148            out1: false,
149            dual_wram_log: Vec::new(),
150            dual_sub: false,
151        })
152    }
153
154    const fn nametable_offset(&self, addr: u16) -> usize {
155        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
156        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
157        let physical = self.mirroring.physical_bank(table);
158        physical * NAMETABLE_SIZE + local
159    }
160
161    fn chr_offset(&self, addr: u16) -> usize {
162        let bank_count = (self.chr.len() / CHR_BANK_8K).max(1);
163        // v2.0.0 beta.5: the DualSystem SUB console banks the second CHR
164        // half (Mesen2 `VsSystem.h`: `chrOuter = IsVsMainConsole() ? 0 : 2`,
165        // OR'd with the `$4016`-bit-2 select). Wraps modulo the bank count,
166        // so a 16 KiB (or smaller) CHR — where main and sub share tiles —
167        // resolves to the same banks either way.
168        let outer = if self.dual_sub { 2 } else { 0 };
169        let bank = ((self.chr_bank as usize) | outer) % bank_count;
170        bank * CHR_BANK_8K + (addr as usize & (CHR_BANK_8K - 1))
171    }
172}
173
174impl Mapper for VsSystem {
175    // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus
176    // skips all four per-CPU-cycle dispatches for this board.
177    fn caps(&self) -> MapperCaps {
178        MapperCaps::NONE
179    }
180
181    fn cpu_read(&mut self, addr: u16) -> u8 {
182        if (0x8000..=0xFFFF).contains(&addr) {
183            // Fixed PRG: the cart's PRG-ROM is mapped straight into
184            // `$8000-$FFFF`, mirrored down for 8/16 KiB carts.
185            //
186            // v2.0.0 beta.5: the DualSystem SUB console runs the SECOND
187            // 32 KiB PRG half — the two CPUs execute DIFFERENT programs
188            // (MAME `balonfgt`: the `sub` region's `6d`/`6a` ROMs differ
189            // from the main's `1d`/`1a` by CRC; Mesen2 `VsSystem.h`:
190            // `prgOuter = IsVsMainConsole() ? 0 : 4` in 8 KiB pages). The
191            // modulo keeps a 32 KiB (main-half-only) dump wrapping onto
192            // the same program either way.
193            let off = (addr - 0x8000) as usize + if self.dual_sub { 0x8000 } else { 0 };
194            self.prg_rom[off % self.prg_rom.len()]
195        } else if (0x6000..=0x7FFF).contains(&addr) {
196            // v2.0.0 beta.5: the DualSystem's shared 2 KiB WRAM, mirrored
197            // across the 8 KiB window. v2.9.8: a UniSystem cart reads its
198            // own copy while `OUT1` is high; while it is low the bus reports
199            // open bus through `cpu_read_unmapped`, and this 0 is unused.
200            let off = (addr as usize - 0x6000) % WRAM_SIZE;
201            match self.dual_wram.as_ref() {
202                Some(w) => w[off % w.len()],
203                None if self.out1 => self.uni_wram[off],
204                None => 0,
205            }
206        } else {
207            0
208        }
209    }
210
211    fn cpu_write(&mut self, addr: u16, value: u8) {
212        // The CHR bank select is bit 2 of the `$4016` write (the Vs.
213        // coin/CHR register). The bus forwards every `$4016` write to the
214        // mapper alongside the standard controller strobe; we consume only
215        // bit 2 here. Writes to `$8000-$FFFF` have no banking effect on this
216        // board (PRG is fixed).
217        if addr == 0x4016 {
218            self.chr_bank = (value >> 2) & 0x01;
219            // v2.9.8: bit 1 (`OUT1`) arbitrates the work RAM.
220            self.out1 = (value & 0x02) != 0;
221        } else if (0x6000..=0x7FFF).contains(&addr) {
222            // v2.0.0 beta.5: DualSystem shared-WRAM write (see cpu_read).
223            // Written to this console's copy AND logged for the wrapper to
224            // replay into the partner's copy (the fully-shared MAME model).
225            if let Some(w) = self.dual_wram.as_mut() {
226                let len = w.len();
227                let off = (addr as usize - 0x6000) % len;
228                w[off] = value;
229                #[allow(clippy::cast_possible_truncation)] // off < 0x800
230                self.dual_wram_log.push((off as u16, value));
231            } else if self.out1 {
232                // v2.9.8: UniSystem -- the write lands only while the CPU
233                // owns the RAM.
234                self.uni_wram[(addr as usize - 0x6000) % WRAM_SIZE] = value;
235            }
236        }
237    }
238
239    fn enable_vs_dual_wram(&mut self) {
240        if self.dual_wram.is_none() {
241            // The DualSystem cabinet's shared RAM is 2 KiB (nesdev
242            // "Vs. System"; MAME maps the same 2 KiB `.share("nvram")`).
243            self.dual_wram = Some(vec![0u8; 0x0800].into_boxed_slice());
244        }
245    }
246
247    fn set_vs_dual_sub(&mut self) {
248        self.dual_sub = true;
249    }
250
251    fn drain_vs_dual_wram_writes(&mut self, dst: &mut Vec<(u16, u8)>) {
252        // `Vec::append` moves every element into `dst` but leaves
253        // `dual_wram_log` at length 0 WITH its allocated capacity intact
254        // for the next batch of writes (unlike `mem::take`, which would
255        // hand the caller the backing allocation and leave this log at
256        // capacity 0 -- forcing a fresh heap allocation on the very next
257        // write). Called after every stepped instruction on a
258        // `DualSystem` cart via `pump_comms`.
259        dst.append(&mut self.dual_wram_log);
260    }
261
262    fn apply_vs_dual_wram_write(&mut self, offset: u16, value: u8) {
263        // Partner-console replay: lands in this copy WITHOUT re-logging
264        // (a log entry here would echo back and forth forever).
265        if let Some(w) = self.dual_wram.as_mut() {
266            let len = w.len();
267            w[offset as usize % len] = value;
268        }
269    }
270
271    fn take_vs_dual_wram(&mut self) -> Option<Box<[u8]>> {
272        self.dual_wram.take()
273    }
274
275    fn set_vs_dual_wram(&mut self, wram: Box<[u8]>) {
276        self.dual_wram = Some(wram);
277    }
278
279    fn cpu_read_unmapped(&self, addr: u16) -> bool {
280        // v2.7.2 (core audit §5.5): `$6000-$7FFF` is "2 KiB RAM, swappable
281        // between CPUs (open bus when not available)"
282        // (`nesdev_wiki/INES_Mapper_099.xhtml`). The DualSystem's shared RAM is
283        // not a battery save, so this keys on its presence, not on `sram()`.
284        // v2.9.8: on a UniSystem cabinet it is "not available" exactly while
285        // `OUT1` is low.
286        (matches!(addr, 0x6000..=0x7FFF) && self.dual_wram.is_none() && !self.out1) || {
287            // PRG is mapped from `$8000`; the `$4020-$5FFF` window remains open
288            // bus (same as the NROM-class default).
289            (0x4020..=0x5FFF).contains(&addr)
290        }
291    }
292
293    fn ppu_read(&mut self, addr: u16) -> u8 {
294        let addr = addr & 0x3FFF;
295        match addr {
296            0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
297            0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
298            _ => 0,
299        }
300    }
301
302    fn ppu_write(&mut self, addr: u16, value: u8) {
303        let addr = addr & 0x3FFF;
304        match addr {
305            0x0000..=0x1FFF => {
306                if self.chr_is_ram {
307                    let off = self.chr_offset(addr);
308                    self.chr[off] = value;
309                }
310            }
311            0x2000..=0x3EFF => {
312                let off = self.nametable_offset(addr);
313                self.vram[off] = value;
314            }
315            _ => {}
316        }
317    }
318
319    fn current_mirroring(&self) -> Mirroring {
320        self.mirroring
321    }
322
323    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
324        let mut info = crate::mapper::MapperDebugInfo {
325            mapper_id: 99,
326            name: "Vs. System (99)".into(),
327            mirroring: crate::mapper::mirroring_name(self.mirroring),
328            ..Default::default()
329        };
330        info.chr_banks
331            .push(("CHR ($4016 bit2)".into(), format!("{:#04x}", self.chr_bank)));
332        info
333    }
334
335    fn save_state(&self) -> Vec<u8> {
336        // v1 layout: [version=1, chr_bank, vram, chr-if-ram].
337        // v2 layout (v2.0.0 beta.5, emitted only when the DualSystem shared
338        // WRAM is provisioned): [version=2, chr_bank, vram, chr-if-ram,
339        // wram(2 KiB)].
340        // v3 layout (v2.9.8, every UniSystem cart): [version=3, chr_bank,
341        // vram, chr-if-ram, out1, wram(2 KiB)]. v1 is neither emitted nor
342        // loaded (refused since v2.9.8, ADR 0042).
343        let mut out = Vec::with_capacity(
344            3 + self.vram.len() + if self.chr_is_ram { self.chr.len() } else { 0 } + WRAM_SIZE,
345        );
346        out.push(if self.dual_wram.is_some() {
347            SAVE_STATE_VERSION_DUAL
348        } else {
349            SAVE_STATE_VERSION_UNI_RAM
350        });
351        out.push(self.chr_bank);
352        out.extend_from_slice(&self.vram);
353        if self.chr_is_ram {
354            out.extend_from_slice(&self.chr);
355        }
356        if let Some(w) = self.dual_wram.as_ref() {
357            out.extend_from_slice(w);
358        } else {
359            out.push(u8::from(self.out1));
360            out.extend_from_slice(&self.uni_wram);
361        }
362        out
363    }
364
365    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
366        let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 };
367        let version = *data.first().unwrap_or(&0);
368        let (need_wram, need_uni) = match version {
369            SAVE_STATE_VERSION_DUAL => (WRAM_SIZE, 0),
370            SAVE_STATE_VERSION_UNI_RAM => (0, 1 + WRAM_SIZE),
371            v => return Err(MapperError::UnsupportedVersion(v)),
372        };
373        let expected = 2 + self.vram.len() + need_chr + need_wram + need_uni;
374        if data.len() != expected {
375            return Err(MapperError::WrongLength {
376                expected,
377                got: data.len(),
378            });
379        }
380        self.chr_bank = data[1];
381        let mut cursor = 2;
382        self.vram
383            .copy_from_slice(&data[cursor..cursor + self.vram.len()]);
384        cursor += self.vram.len();
385        if self.chr_is_ram {
386            self.chr
387                .copy_from_slice(&data[cursor..cursor + self.chr.len()]);
388            cursor += self.chr.len();
389        }
390        // `dual_wram_log` is transient (never serialized — see its field
391        // doc) and MUST be dropped on restore regardless of layout version:
392        // any writes logged before the restore point are now stale relative
393        // to the just-loaded `dual_wram` contents, and replaying them into
394        // the partner console afterward would silently corrupt its copy of
395        // the shared RAM with pre-restore data.
396        self.dual_wram_log.clear();
397        if need_wram > 0 {
398            // Re-provision on restore even if the live instance had not been
399            // through `enable_vs_dual_wram` yet (a fresh Nes restored from a
400            // dual snapshot).
401            let mut w = vec![0u8; need_wram].into_boxed_slice();
402            w.copy_from_slice(&data[cursor..cursor + need_wram]);
403            self.dual_wram = Some(w);
404        } else {
405            // v3 (UniSystem) layout: drop any dual WRAM the live
406            // instance may have been carrying so in-memory state matches the
407            // versioned layout just loaded (a UniSystem snapshot never
408            // described dual-WRAM state, so none should survive the restore).
409            self.dual_wram = None;
410        }
411        if need_uni > 0 {
412            self.out1 = data[cursor] != 0;
413            self.uni_wram
414                .copy_from_slice(&data[cursor + 1..cursor + 1 + WRAM_SIZE]);
415        } else {
416            // A DualSystem snapshot carries the shared WRAM instead; the
417            // UniSystem RAM and its arbitration latch return to power-on.
418            self.out1 = false;
419            self.uni_wram.fill(0);
420        }
421        Ok(())
422    }
423}
424
425#[cfg(test)]
426#[allow(clippy::cast_possible_truncation)]
427mod tests {
428    use super::*;
429
430    fn synth_prg(banks_8k: usize) -> Box<[u8]> {
431        let mut v = vec![0u8; banks_8k * CHR_BANK_8K];
432        for b in 0..banks_8k {
433            v[b * CHR_BANK_8K] = b as u8;
434        }
435        v.into_boxed_slice()
436    }
437
438    fn synth_chr(banks_8k: usize) -> Box<[u8]> {
439        let mut v = vec![0u8; banks_8k * CHR_BANK_8K];
440        for b in 0..banks_8k {
441            v[b * CHR_BANK_8K] = 0xA0 | b as u8;
442        }
443        v.into_boxed_slice()
444    }
445
446    #[test]
447    fn prg_fixed_and_mirrored() {
448        // 8 KiB PRG mirrors across the whole $8000-$FFFF window.
449        let mut m = VsSystem::new(synth_prg(1), synth_chr(2), Mirroring::Horizontal).unwrap();
450        assert_eq!(m.cpu_read(0x8000), 0);
451        assert_eq!(m.cpu_read(0xA000), 0); // mirror of bank 0
452        assert_eq!(m.cpu_read(0xE000), 0);
453    }
454
455    #[test]
456    fn prg_32k_maps_straight() {
457        // 32 KiB PRG (4x8 KiB) maps straight: bank b at $8000 + b*8K.
458        let mut m = VsSystem::new(synth_prg(4), synth_chr(2), Mirroring::Vertical).unwrap();
459        assert_eq!(m.cpu_read(0x8000), 0);
460        assert_eq!(m.cpu_read(0xA000), 1);
461        assert_eq!(m.cpu_read(0xC000), 2);
462        assert_eq!(m.cpu_read(0xE000), 3);
463    }
464
465    #[test]
466    fn chr_bank_select_via_4016_bit2() {
467        let mut m = VsSystem::new(synth_prg(2), synth_chr(2), Mirroring::Horizontal).unwrap();
468        // Default bank 0.
469        assert_eq!(m.ppu_read(0x0000), 0xA0);
470        // $4016 bit 2 set -> CHR bank 1.
471        m.cpu_write(0x4016, 0b0000_0100);
472        assert_eq!(m.ppu_read(0x0000), 0xA1);
473        // $4016 bit 2 clear -> back to bank 0. Other bits (controller strobe)
474        // are ignored by the mapper.
475        m.cpu_write(0x4016, 0b0000_0001);
476        assert_eq!(m.ppu_read(0x0000), 0xA0);
477    }
478
479    #[test]
480    fn cpu_write_8000_does_not_bank() {
481        // A $8000-$FFFF write must NOT change the CHR bank on this board.
482        let mut m = VsSystem::new(synth_prg(2), synth_chr(2), Mirroring::Horizontal).unwrap();
483        m.cpu_write(0x8000, 0xFF);
484        assert_eq!(m.ppu_read(0x0000), 0xA0); // still bank 0
485    }
486
487    #[test]
488    fn single_chr_bank_ignores_select() {
489        // A cart with only 8 KiB CHR wraps the bank to 0 regardless of bit 2.
490        let mut m = VsSystem::new(synth_prg(2), synth_chr(1), Mirroring::Horizontal).unwrap();
491        m.cpu_write(0x4016, 0b0000_0100);
492        assert_eq!(m.ppu_read(0x0000), 0xA0);
493    }
494
495    #[test]
496    fn save_state_round_trip() {
497        let mut m = VsSystem::new(synth_prg(2), synth_chr(2), Mirroring::Vertical).unwrap();
498        m.cpu_write(0x4016, 0b0000_0100);
499        let blob = m.save_state();
500        let mut m2 = VsSystem::new(synth_prg(2), synth_chr(2), Mirroring::Vertical).unwrap();
501        m2.load_state(&blob).unwrap();
502        assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
503    }
504
505    #[test]
506    fn unisystem_ram_follows_4016_bit1() {
507        // nesdev "Vs. System" ($4016 write, bit 1): "On the primary CPU only,
508        // controls which CPU can access 2 KiB of shared RAM mapped in the
509        // $6000-$7FFF region. When 1: the primary CPU has access ... When 0:
510        // ... the primary CPU sees open bus." INES_Mapper_099: "CPU
511        // $6000-$7FFF: 2 KiB RAM, swappable between CPUs (open bus when not
512        // available)". Vs. Super Mario Bros. keeps its state there.
513        let mut m = VsSystem::new(synth_prg(4), synth_chr(2), Mirroring::Vertical).unwrap();
514        // Power-on: the 2A03 OUT latch is clear, so the RAM is not ours.
515        assert!(m.cpu_read_unmapped(0x6000));
516        // OUT1 = 1 (with OUT2 = CHR bank 1): the primary owns the RAM.
517        m.cpu_write(0x4016, 0b0000_0110);
518        assert!(!m.cpu_read_unmapped(0x6000));
519        m.cpu_write(0x6123, 0x5A);
520        assert_eq!(m.cpu_read(0x6123), 0x5A);
521        // 2 KiB, mirrored across the 8 KiB window.
522        assert_eq!(m.cpu_read(0x6923), 0x5A);
523        assert_eq!(m.cpu_read(0x7923), 0x5A);
524        // A controller strobe that keeps OUT1 set keeps the RAM.
525        m.cpu_write(0x4016, 0b0000_0011);
526        assert_eq!(m.cpu_read(0x6123), 0x5A);
527        // OUT1 = 0: open bus, and writes do not land.
528        m.cpu_write(0x4016, 0b0000_0000);
529        assert!(m.cpu_read_unmapped(0x6123));
530        m.cpu_write(0x6123, 0x00);
531        m.cpu_write(0x4016, 0b0000_0010);
532        assert_eq!(m.cpu_read(0x6123), 0x5A);
533        // The RAM and the OUT1 latch survive a save state.
534        let blob = m.save_state();
535        let mut m2 = VsSystem::new(synth_prg(4), synth_chr(2), Mirroring::Vertical).unwrap();
536        m2.load_state(&blob).unwrap();
537        assert!(!m2.cpu_read_unmapped(0x6123));
538        assert_eq!(m2.cpu_read(0x6123), 0x5A);
539    }
540}