rustynes_core/vs_dualsystem.rs
1//! Vs. `DualSystem` — two complete NES systems in one arcade cabinet
2//! (v2.0.0 beta.5, Workstream C of the "Timebase" plan).
3//!
4//! The `DualSystem` boards (Vs. Tennis, Vs. Mahjong, Vs. Wrecking Crew,
5//! Vs. Balloon Fight) carry **two CPUs, two PPUs, and two work RAMs**,
6//! sharing a small inter-CPU communication signal, a 2 KiB work RAM, and
7//! the coin/DIP panel — each half drives its own screen. `RustyNES` models
8//! this as a wrapper over two byte-identical [`Nes`] instances:
9//!
10//! ```text
11//! VsDualSystem
12//! ├── main: Nes (the primary cabinet half; $4016 bit 7 reads 0)
13//! ├── sub: Nes (the secondary half; $4016 bit 7 reads 0x80)
14//! └── the comms latch + shared-WRAM ownership (wrapper-owned)
15//! ```
16//!
17//! **The wrapper owns ALL cross-wiring** (the design rule from
18//! `docs/audit/vs-dualsystem-design-2026-06-11.md`): the two buses never
19//! hold references to each other. Each bus only *records* its `$4016`
20//! bit-1 (main/sub comms signal) levels and *accepts* an external-IRQ
21//! level; the wrapper polls the levels after every stepped instruction
22//! and applies the protocol (IRQ wiring per Mesen2
23//! `Core/NES/Mappers/VsSystem/VsControlManager.cpp`; memory model per
24//! MAME `src/mame/nintendo/vsnes.cpp`, where the four `DualSystem` games
25//! verifiably run):
26//!
27//! - a `$4016` bit-1 write going **LOW asserts the PARTNER console's
28//! external `/IRQ`**; going high clears it (`UpdateMainSubBit`; MAME:
29//! `cpu.set_input_line(0, (data & 2) ? CLEAR_LINE : ASSERT_LINE)`);
30//! - the shared 2 KiB WRAM at `$6000-$67FF` (mirrored ×4 across the 8 KiB
31//! window) is **simultaneously visible to both CPUs** — MAME maps ONE
32//! RAM `.share("nvram")` into both address spaces with no access mux.
33//! (nesdev/Mesen2 document a `$4016`-bit-1 access mux instead, but
34//! Balloon Fight's boot handshake polls a mailbox the partner writes
35//! while the mux would deny it access — under exclusive routing the
36//! boot provably deadlocks, so the MAME model is adopted.) Realized as
37//! two per-console copies converged by draining each mapper's write log
38//! into the partner after every stepped instruction;
39//! - coins 1/2 + service drive main; coins 3/4 drive sub;
40//! - each console owns its own DIP bank (Mesen2's `dipSwitches >> 8` /
41//! MAME's `DSW0`/`DSW1` for the sub is realized structurally: two
42//! buses, two `vs_dip` bytes).
43//!
44//! **Stepping** mirrors Mesen2 `NesConsole::RunFrame` +
45//! `RunVsSubConsole`: the main console steps one instruction, then the
46//! sub runs until it is within a **5-CPU-cycle gap** of the main (or has
47//! caught up to the main's frame count) — a *soft* lockstep. Both
48//! consoles run the same deterministic one-clock core, so the interleave
49//! is reproducible run-to-run (the determinism contract holds per
50//! console; the 5-cycle tolerance is the documented coupling knob).
51//!
52//! **Out of scope by design** (stated in the plan + the design doc):
53//! netplay (rollback assumes one state blob) and `RetroAchievements` (one
54//! memory map) do not support the dual path. Audio: the frontend drains
55//! the MAIN console's mixer (each cabinet half has its own speaker; the
56//! sub's audio is synthesized but undrained until a frontend feature
57//! surfaces it).
58
59use alloc::boxed::Box;
60use alloc::vec::Vec;
61
62use crate::nes::Nes;
63use crate::save_state::SnapshotError;
64use rustynes_mappers::RomError;
65
66/// Container magic for the dual-system save-state (see
67/// [`VsDualSystem::snapshot`]).
68const SNAPSHOT_MAGIC: [u8; 4] = *b"RVSD";
69/// Dual-container layout version.
70const SNAPSHOT_VERSION: u16 = 1;
71
72/// Two complete NES systems + the cabinet's cross-wiring. See the module
73/// docs for the architecture and protocol.
74pub struct VsDualSystem {
75 main: Nes,
76 sub: Nes,
77 /// The MAIN console's last-applied `$4016` bit-1 level (the sub's
78 /// `/IRQ` driver).
79 main_bit1: bool,
80 /// The SUB console's last-applied `$4016` bit-1 level (the main's
81 /// `/IRQ` driver).
82 sub_bit1: bool,
83 /// Reusable scratch buffer for `pump_comms`'s shared-WRAM drain, in
84 /// both directions. `Vec::drain` on the mapper side keeps the log's
85 /// OWN capacity; this buffer keeps the WRAPPER side allocation-free
86 /// too, once warmed up — `pump_comms` runs after every stepped
87 /// instruction on a `DualSystem` cart, so a per-call heap allocation
88 /// here would be a real hot-path cost, not a theoretical one.
89 comms_scratch: Vec<(u16, u8)>,
90}
91
92impl VsDualSystem {
93 /// Construct the dual system from one ROM image. A proper `DualSystem`
94 /// dump carries BOTH CPUs' programs (64 KiB PRG: main half then sub
95 /// half — MAME's `prg` + `sub` regions; Mesen2's `prgOuter` split);
96 /// the sub console's mapper banks the second half. A 32 KiB
97 /// (main-half-only) dump constructs and runs, but its boot handshake
98 /// cannot complete — the sub-CPU program is simply absent.
99 ///
100 /// The caller has already determined the cart is a `DualSystem` board
101 /// (the SHA-keyed `vs_db` `dual_system` flag — see [`crate::Emu`]).
102 ///
103 /// # Errors
104 ///
105 /// Returns the underlying [`RomError`] if the bytes don't parse.
106 pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
107 Ok(Self::from_pair(
108 Nes::from_rom(bytes)?,
109 Nes::from_rom(bytes)?,
110 ))
111 }
112
113 /// Construct the dual system with an explicit audio sample rate (the
114 /// frontend's output-device rate), applied to BOTH consoles.
115 ///
116 /// The sample rate is baked into each `Nes` at construction (there is no
117 /// runtime setter), so the desktop present path — which knows the cpal
118 /// device rate — must build the pair through this constructor for the main
119 /// console's audio to resample correctly. Otherwise identical to
120 /// [`Self::from_rom`].
121 ///
122 /// # Errors
123 ///
124 /// Returns the underlying [`RomError`] if the bytes don't parse.
125 pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
126 Ok(Self::from_pair(
127 Nes::from_rom_with_sample_rate(bytes, sample_rate)?,
128 Nes::from_rom_with_sample_rate(bytes, sample_rate)?,
129 ))
130 }
131
132 /// Wire two freshly-constructed consoles into a `DualSystem` cabinet: mark
133 /// the sub half, provision the shared WRAM on both, and seed the reset-time
134 /// bit-1 handshake. Shared by every constructor so the wiring can never
135 /// drift between them.
136 fn from_pair(main: Nes, sub: Nes) -> Self {
137 let mut dual = Self {
138 main,
139 sub,
140 main_bit1: false,
141 sub_bit1: false,
142 comms_scratch: Vec::new(),
143 };
144 // Cabinet wiring: mark the sub half (its $4016 bit 7 reads 0x80;
145 // its mapper banks the second PRG half + upper CHR pages — the two
146 // CPUs run different programs on real DualSystem boards) and
147 // provision the shared 2 KiB WRAM on BOTH consoles (each holds a
148 // copy; `pump_comms` converges them — the MAME `.share("nvram")`
149 // model).
150 dual.sub.bus_mut().set_vs_sub(true);
151 dual.sub.bus_mut().set_vs_dual_sub();
152 dual.main.bus_mut().enable_vs_dual_wram();
153 dual.sub.bus_mut().enable_vs_dual_wram();
154 // Reset-time seed (Mesen2 `VsControlManager::Reset`:
155 // `UpdateMainSubBit(main ? 0x00 : 0x02)`): the main half boots with
156 // its bit-1 signal LOW — which asserts the SUB's external /IRQ —
157 // and the sub boots with its signal HIGH (the main's /IRQ clear).
158 // Wrecking Crew requires this seed to progress past its handshake.
159 dual.apply_main_bit1(false);
160 dual.apply_sub_bit1(true);
161 dual
162 }
163
164 /// Apply a MAIN-console bit-1 level: drive the sub's `/IRQ` (LOW
165 /// asserts, per Mesen2 `UpdateMainSubBit` / MAME
166 /// `(data & 2) ? CLEAR_LINE : ASSERT_LINE`).
167 const fn apply_main_bit1(&mut self, level: bool) {
168 self.main_bit1 = level;
169 self.sub.bus_mut().set_vs_external_irq(!level);
170 }
171
172 /// Apply a SUB-console bit-1 level: drive the main's `/IRQ`.
173 const fn apply_sub_bit1(&mut self, level: bool) {
174 self.sub_bit1 = level;
175 self.main.bus_mut().set_vs_external_irq(!level);
176 }
177
178 /// Drain both consoles' `$4016` comms levels + shared-WRAM write logs
179 /// and apply the protocol. Called after every stepped instruction on
180 /// either console, so partner-visible effects land with at most one
181 /// instruction of latency (within the 5-cycle soft-lockstep window).
182 fn pump_comms(&mut self) {
183 if let Some(level) = self.main.bus_mut().take_vs_mainsub_edge() {
184 self.apply_main_bit1(level);
185 }
186 if let Some(level) = self.sub.bus_mut().take_vs_mainsub_edge() {
187 self.apply_sub_bit1(level);
188 }
189 // Converge the shared-WRAM copies (both directions). The logs are
190 // usually empty; a handful of entries during the boot handshake and
191 // per-frame gameplay exchange. `comms_scratch` is drained (not
192 // replaced) each round, so its allocated capacity — and the
193 // mapper-side log's own capacity, via `drain_vs_dual_wram_writes`'s
194 // `Vec::drain` — survives across calls: steady-state, this loop is
195 // allocation-free.
196 self.main
197 .bus_mut()
198 .drain_vs_dual_wram_writes(&mut self.comms_scratch);
199 for (off, val) in self.comms_scratch.drain(..) {
200 self.sub.bus_mut().apply_vs_dual_wram_write(off, val);
201 }
202 self.sub
203 .bus_mut()
204 .drain_vs_dual_wram_writes(&mut self.comms_scratch);
205 for (off, val) in self.comms_scratch.drain(..) {
206 self.main.bus_mut().apply_vs_dual_wram_write(off, val);
207 }
208 }
209
210 /// Run one MAIN-console frame with the sub console soft-locksteped to
211 /// within a 5-CPU-cycle gap (Mesen2 `RunFrame` + `RunVsSubConsole`).
212 ///
213 /// Returns when the main console's PPU completes a frame (or its CPU
214 /// jams / the frame budget trips — mirroring `Nes::run_frame`'s
215 /// guards).
216 pub fn run_frame(&mut self) {
217 /// Same stuck-frame guard as `Nes::run_frame`.
218 const MAX_CYCLES_PER_FRAME: u64 = 150_000;
219 let start_frame = self.main.frame();
220 let start_cycle = self.main.cycle();
221 while self.main.frame() == start_frame {
222 if self.main.is_jammed() {
223 break;
224 }
225 if self.main.cycle().wrapping_sub(start_cycle) > MAX_CYCLES_PER_FRAME {
226 break;
227 }
228 self.main.step_instruction();
229 self.pump_comms();
230 // Drain the sub to within the 5-cycle gap (or its frame parity).
231 // The comparison must be overshoot-safe: an instruction advances
232 // 2..=8 cycles, so the sub routinely lands AHEAD of the main by
233 // a few cycles — a naive `wrapping_sub(..) > 5` then wraps to a
234 // huge unsigned value and runs the sub away forever.
235 while !self.sub.is_jammed()
236 && (self.main.cycle() > self.sub.cycle().saturating_add(5)
237 || self.main.frame() > self.sub.frame())
238 {
239 self.sub.step_instruction();
240 self.pump_comms();
241 }
242 }
243 // Consume both PPUs' frame-complete latches so external users of the
244 // underlying `Nes` (none today) never observe a stale latch.
245 let _ = self.main.bus_mut().take_frame_complete();
246 let _ = self.sub.bus_mut().take_frame_complete();
247 }
248
249 /// The main console's 256x240 RGBA8 framebuffer (the left screen).
250 #[must_use]
251 pub fn main_framebuffer(&self) -> &[u8] {
252 self.main.framebuffer()
253 }
254
255 /// The sub console's 256x240 RGBA8 framebuffer (the right screen).
256 #[must_use]
257 pub fn sub_framebuffer(&self) -> &[u8] {
258 self.sub.framebuffer()
259 }
260
261 /// Route controller input: ports 0/1 (P1/P2) → the main console's
262 /// ports 0/1; ports 2/3 (P3/P4) → the sub console's ports 0/1.
263 pub const fn set_buttons(&mut self, port: usize, buttons: crate::Buttons) {
264 match port {
265 0 | 1 => self.main.set_buttons(port, buttons),
266 2 | 3 => self.sub.set_buttons(port - 2, buttons),
267 _ => {}
268 }
269 }
270
271 /// Coin routing (Mesen2 `VsControlManager`): acceptors 0/1 latch on the
272 /// MAIN console, 2/3 on the SUB console.
273 pub const fn insert_coin(&mut self, acceptor: u8) {
274 match acceptor {
275 0 | 1 => self.main.insert_coin(acceptor),
276 2 | 3 => self.sub.insert_coin(acceptor - 2),
277 _ => {}
278 }
279 }
280
281 /// Clear both consoles' latched coin signals.
282 pub const fn clear_coin(&mut self) {
283 self.main.clear_coin();
284 self.sub.clear_coin();
285 }
286
287 /// The service button (main panel) / service-2 (sub panel).
288 pub const fn set_vs_service(&mut self, panel: u8, pressed: bool) {
289 match panel {
290 0 => self.main.set_vs_service(pressed),
291 1 => self.sub.set_vs_service(pressed),
292 _ => {}
293 }
294 }
295
296 /// Borrow the main console (read-only diagnostics).
297 #[must_use]
298 pub const fn main(&self) -> &Nes {
299 &self.main
300 }
301
302 /// Borrow the sub console (read-only diagnostics).
303 #[must_use]
304 pub const fn sub(&self) -> &Nes {
305 &self.sub
306 }
307
308 /// Mutably borrow the main console (debugger / diagnostics — e.g. the
309 /// side-effect-free `debug_peek_cpu`, which needs `&mut` for the
310 /// mapper's banked lookups). Cross-wiring stays wrapper-owned; don't
311 /// drive `$4016` writes through this handle.
312 #[must_use]
313 pub const fn main_mut(&mut self) -> &mut Nes {
314 &mut self.main
315 }
316
317 /// Mutably borrow the sub console (debugger / diagnostics; see
318 /// [`Self::main_mut`]).
319 #[must_use]
320 pub const fn sub_mut(&mut self) -> &mut Nes {
321 &mut self.sub
322 }
323
324 /// Simultaneously borrow both consoles (diagnostics — e.g. a tracing
325 /// harness replicating the lockstep loop with instrumented pumping).
326 #[must_use]
327 pub const fn split_mut(&mut self) -> (&mut Nes, &mut Nes) {
328 (&mut self.main, &mut self.sub)
329 }
330
331 /// Serialize the dual system: a versioned container nesting the two
332 /// standard [`Nes`] snapshots plus the wrapper's latch state.
333 ///
334 /// Layout: `RVSD` magic, `u16` version, latch byte
335 /// (`bit0 = main_bit1, bit1 = sub_bit1`; bit 2 reserved — it carried
336 /// a WRAM-ownership flag in a pre-release layout and is ignored on
337 /// load), then two `u32`-length-prefixed `Nes` snapshots (main, sub).
338 #[must_use]
339 pub fn snapshot(&self) -> Vec<u8> {
340 let main = self.main.snapshot();
341 let sub = self.sub.snapshot();
342 let mut out = Vec::with_capacity(4 + 2 + 1 + 8 + main.len() + sub.len());
343 out.extend_from_slice(&SNAPSHOT_MAGIC);
344 out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
345 let latch = u8::from(self.main_bit1) | (u8::from(self.sub_bit1) << 1);
346 out.push(latch);
347 #[allow(clippy::cast_possible_truncation)]
348 out.extend_from_slice(&(main.len() as u32).to_le_bytes());
349 out.extend_from_slice(&main);
350 #[allow(clippy::cast_possible_truncation)]
351 out.extend_from_slice(&(sub.len() as u32).to_le_bytes());
352 out.extend_from_slice(&sub);
353 out
354 }
355
356 /// Restore a dual-system snapshot produced by [`Self::snapshot`].
357 ///
358 /// # Errors
359 ///
360 /// Returns [`SnapshotError`] on a bad container or when either nested
361 /// console snapshot fails to restore.
362 pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
363 // A malformed dual container reports as an unsupported format with
364 // the container version we could read (0 when even the header is
365 // short) — the closest fit among the existing error variants until
366 // rc.1's save-state rework gives the dual container its own.
367 let fail = |got: u16| SnapshotError::UnsupportedFormat {
368 got,
369 max: SNAPSHOT_VERSION,
370 };
371 if data.len() < 4 + 2 + 1 + 4 || data[0..4] != SNAPSHOT_MAGIC {
372 return Err(fail(0));
373 }
374 let version = u16::from_le_bytes([data[4], data[5]]);
375 if version != SNAPSHOT_VERSION {
376 return Err(fail(version));
377 }
378 let latch = data[6];
379 let mut cursor = 7usize;
380 let read_block = |cursor: &mut usize| -> Result<&[u8], SnapshotError> {
381 let len_end = cursor.checked_add(4).ok_or_else(|| fail(version))?;
382 let len_bytes: [u8; 4] = data
383 .get(*cursor..len_end)
384 .ok_or_else(|| fail(version))?
385 .try_into()
386 .map_err(|_| fail(version))?;
387 let len = u32::from_le_bytes(len_bytes) as usize;
388 let end = len_end.checked_add(len).ok_or_else(|| fail(version))?;
389 let block = data.get(len_end..end).ok_or_else(|| fail(version))?;
390 *cursor = end;
391 Ok(block)
392 };
393 let main_block = read_block(&mut cursor)?;
394 let sub_block = read_block(&mut cursor)?;
395 self.main.restore(main_block)?;
396 self.sub.restore(sub_block)?;
397 // Re-derive the wrapper latch + re-drive the cross-console signals
398 // (the buses' transient comms fields are not serialized; the wrapper
399 // owns the authoritative copies).
400 self.main_bit1 = (latch & 0x01) != 0;
401 self.sub_bit1 = (latch & 0x02) != 0;
402 self.sub.bus_mut().set_vs_sub(true);
403 self.sub.bus_mut().set_vs_external_irq(!self.main_bit1);
404 self.main.bus_mut().set_vs_external_irq(!self.sub_bit1);
405 // Re-converge the shared-WRAM copies from ONE buffer: the nested
406 // snapshots each carry a copy (identical at snapshot time — the
407 // write logs are always drained within `run_frame`), but a restore
408 // into a fresh wrapper must not trust both blindly. The main's
409 // copy is authoritative; it is cloned onto the sub.
410 let wram = self
411 .main
412 .bus_mut()
413 .take_vs_dual_wram()
414 .or_else(|| self.sub.bus_mut().take_vs_dual_wram())
415 .unwrap_or_else(|| alloc::vec![0u8; 0x0800].into_boxed_slice());
416 self.sub.bus_mut().set_vs_dual_wram(wram.clone());
417 self.main.bus_mut().set_vs_dual_wram(wram);
418 Ok(())
419 }
420}
421
422/// The top-level emulator: one standard console, or a Vs. `DualSystem` pair.
423///
424/// v2.0.0 beta.5 — the API reshape scoped to the major (the plan's
425/// Workstream C/D): a NEW `rustynes-core` consumer would construct via
426/// [`Emu::from_rom`] and match on the variant; every existing single-console
427/// surface lives unchanged on [`Nes`]. **`rustynes-frontend` does NOT yet
428/// consume this type** — it still constructs `Nes` directly
429/// (`Nes::from_rom`/`from_rom_with_sample_rate`), so the `DualSystem` path
430/// is core-and-test-harness-only in this release; wiring the desktop/mobile
431/// UI onto `Emu` (dual-console rendering + 4-port input routing) is
432/// explicitly deferred, tracked as a beta.5 known gap (see the beta.5
433/// CHANGELOG entry and `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md`
434/// for the current disposition).
435pub enum Emu {
436 /// A standard single-console system (every cart except the four
437 /// `DualSystem` boards).
438 Single(Box<Nes>),
439 /// A Vs. `DualSystem` cabinet (two consoles + the cross-wiring).
440 Dual(Box<VsDualSystem>),
441}
442
443impl Emu {
444 /// Construct the right emulator shape for the ROM: a
445 /// [`VsDualSystem`] when the SHA-keyed `vs_db` flags the cart as a
446 /// `DualSystem` board, else a standard [`Nes`].
447 ///
448 /// # Errors
449 ///
450 /// Returns the underlying [`RomError`] if the bytes don't parse.
451 pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
452 let nes = Nes::from_rom(bytes)?;
453 // Two detection sources, OR'd: the NES 2.0 header (byte-13 high
454 // nibble = Vs. hardware type 5/6) and the SHA-keyed `vs_db` record.
455 // The db is load-bearing — the circulating DualSystem dumps are
456 // iNES 1.0 (no byte 13), so the header alone can never flag them.
457 let db_dual = crate::vs_db::lookup(nes.rom_sha256()).is_some_and(|e| e.dual_system);
458 if nes.is_vs_dual_system() || db_dual {
459 // Reuse the probe as the MAIN console; parse once more for the SUB
460 // (two parses total, not three). `from_pair` applies the cabinet
461 // wiring to the pair.
462 let sub = Nes::from_rom(bytes)?;
463 Ok(Self::Dual(Box::new(VsDualSystem::from_pair(nes, sub))))
464 } else {
465 Ok(Self::Single(Box::new(nes)))
466 }
467 }
468
469 /// Like [`Self::from_rom`], but bakes the frontend's audio sample rate into
470 /// the console(s) — the desktop present path uses this so a `DualSystem`
471 /// cabinet's main-console audio resamples to the cpal device rate.
472 ///
473 /// # Errors
474 ///
475 /// Returns the underlying [`RomError`] if the bytes don't parse.
476 pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
477 let nes = Nes::from_rom_with_sample_rate(bytes, sample_rate)?;
478 let db_dual = crate::vs_db::lookup(nes.rom_sha256()).is_some_and(|e| e.dual_system);
479 if nes.is_vs_dual_system() || db_dual {
480 // Reuse the probe as MAIN; parse once more for SUB (two parses, not
481 // three).
482 let sub = Nes::from_rom_with_sample_rate(bytes, sample_rate)?;
483 Ok(Self::Dual(Box::new(VsDualSystem::from_pair(nes, sub))))
484 } else {
485 Ok(Self::Single(Box::new(nes)))
486 }
487 }
488}