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}