rustynes_core/vs_dualsystem.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the Vs. DualSystem orchestration (main/sub stepping per `NesConsole::RunFrame` / `RunVsSubConsole`, the reset-time seed per `VsControlManager::Reset`, the main/sub bit per `UpdateMainSubBit`, and the coin routing per `VsControlManager`) is derived from Mesen2 (GPL-3.0-or-later). See docs/originality-and-provenance.md (Section 1) and NOTICE. Classified v2.9.9 (core re-audit NC-17, maintainer's decision 2026-10-04): the in-source citations below record the derivation and are kept as written.
4
5//! Vs. `DualSystem` — two complete NES systems in one arcade cabinet
6//! (v2.0.0 beta.5, Workstream C of the "Timebase" plan).
7//!
8//! The `DualSystem` boards (Vs. Tennis, Vs. Mahjong, Vs. Wrecking Crew,
9//! Vs. Balloon Fight) carry **two CPUs, two PPUs, and two work RAMs**,
10//! sharing a small inter-CPU communication signal, a 2 KiB work RAM, and
11//! the coin/DIP panel — each half drives its own screen. `RustyNES` models
12//! this as a wrapper over two byte-identical [`Nes`] instances:
13//!
14//! ```text
15//! VsDualSystem
16//! ├── main: Nes (the primary cabinet half; $4016 bit 7 reads 0)
17//! ├── sub: Nes (the secondary half; $4016 bit 7 reads 0x80)
18//! └── the comms latch + shared-WRAM ownership (wrapper-owned)
19//! ```
20//!
21//! **The wrapper owns ALL cross-wiring** (the design rule from
22//! `docs/audit/vs-dualsystem-design-2026-06-11.md`): the two buses never
23//! hold references to each other. Each bus only *records* its `$4016`
24//! bit-1 (main/sub comms signal) levels and *accepts* an external-IRQ
25//! level; the wrapper polls the levels after every stepped instruction
26//! and applies the protocol (IRQ wiring per Mesen2
27//! `Core/NES/Mappers/VsSystem/VsControlManager.cpp`; memory model per
28//! MAME `src/mame/nintendo/vsnes.cpp`, where the four `DualSystem` games
29//! verifiably run):
30//!
31//! - a `$4016` bit-1 write going **LOW asserts the PARTNER console's
32//! external `/IRQ`**; going high clears it (`UpdateMainSubBit`; MAME:
33//! `cpu.set_input_line(0, (data & 2) ? CLEAR_LINE : ASSERT_LINE)`);
34//! - the shared 2 KiB WRAM at `$6000-$67FF` (mirrored ×4 across the 8 KiB
35//! window) is **simultaneously visible to both CPUs** — MAME maps ONE
36//! RAM `.share("nvram")` into both address spaces with no access mux.
37//! (nesdev/Mesen2 document a `$4016`-bit-1 access mux instead, but
38//! Balloon Fight's boot handshake polls a mailbox the partner writes
39//! while the mux would deny it access — under exclusive routing the
40//! boot provably deadlocks, so the MAME model is adopted.) Realized as
41//! two per-console copies converged by draining each mapper's write log
42//! into the partner after every stepped instruction;
43//! - coins 1/2 + service drive main; coins 3/4 drive sub;
44//! - each console owns its own DIP bank (Mesen2's `dipSwitches >> 8` /
45//! MAME's `DSW0`/`DSW1` for the sub is realized structurally: two
46//! buses, two `vs_dip` bytes).
47//!
48//! **Stepping** mirrors Mesen2 `NesConsole::RunFrame` +
49//! `RunVsSubConsole`: the main console steps one instruction, then the
50//! sub runs until it is within a **5-CPU-cycle gap** of the main (or has
51//! caught up to the main's frame count) — a *soft* lockstep. Both
52//! consoles run the same deterministic one-clock core, so the interleave
53//! is reproducible run-to-run (the determinism contract holds per
54//! console; the 5-cycle tolerance is the documented coupling knob).
55//!
56//! **Out of scope by design** (stated in the plan + the design doc):
57//! netplay (rollback assumes one state blob) and `RetroAchievements` (one
58//! memory map) do not support the dual path. Audio: the frontend drains
59//! the MAIN console's mixer (each cabinet half has its own speaker; the
60//! sub's audio is synthesized but undrained until a frontend feature
61//! surfaces it).
62
63use alloc::boxed::Box;
64use alloc::vec::Vec;
65
66use crate::nes::Nes;
67use crate::rewind::{REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindRing};
68use crate::save_state::SnapshotError;
69use rustynes_mappers::RomError;
70
71/// Container magic for the dual-system save-state (see
72/// [`VsDualSystem::snapshot`]).
73const SNAPSHOT_MAGIC: [u8; 4] = *b"RVSD";
74/// Dual-container layout version.
75const SNAPSHOT_VERSION: u16 = 1;
76
77/// Two complete NES systems + the cabinet's cross-wiring. See the module
78/// docs for the architecture and protocol.
79pub struct VsDualSystem {
80 main: Nes,
81 sub: Nes,
82 /// The MAIN console's last-applied `$4016` bit-1 level (the sub's
83 /// `/IRQ` driver).
84 main_bit1: bool,
85 /// The SUB console's last-applied `$4016` bit-1 level (the main's
86 /// `/IRQ` driver).
87 sub_bit1: bool,
88 /// Reusable scratch buffer for `pump_comms`'s shared-WRAM drain, in
89 /// both directions. `Vec::drain` on the mapper side keeps the log's
90 /// OWN capacity; this buffer keeps the WRAPPER side allocation-free
91 /// too, once warmed up — `pump_comms` runs after every stepped
92 /// instruction on a `DualSystem` cart, so a per-call heap allocation
93 /// here would be a real hot-path cost, not a theoretical one.
94 comms_scratch: Vec<(u16, u8)>,
95 /// v2.9.1 (NL-09) — one console's snapshot on its way into the cabinet's
96 /// container, reused by [`Self::snapshot_into`]. A console's encoder
97 /// clears the buffer it is given (it writes its own header first), so the
98 /// two blocks cannot be encoded straight into the container; they pass
99 /// through this instead. Not state: never serialized, never compared.
100 block_scratch: Vec<u8>,
101 /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032 amendment of 2026-10-07) — the
102 /// cabinet's rewind ring, holding whole-cabinet `RVSD` containers.
103 /// `None` (the default) until a frontend opts in with
104 /// [`Self::enable_rewind`].
105 ///
106 /// The ring lives on the cabinet, not on either console, because the unit
107 /// of rewind is the cabinet: the two consoles share a 2 KiB WRAM and drive
108 /// each other's `/IRQ`, so stepping one back without the other produces a
109 /// cabinet from two timelines. Each console's own [`Nes`] ring stays
110 /// disabled. Not state: never serialized, never compared.
111 rewind: Option<RewindRing>,
112 /// When `false`, [`Self::run_frame`] skips the rewind capture. Run-ahead
113 /// clears it across its hidden and visible frames, so the ring holds the
114 /// persistent timeline only (the same contract as
115 /// [`Nes::set_rewind_capture`]).
116 rewind_capture_enabled: bool,
117 /// Reused buffer for the per-frame rewind capture (a cabinet container is
118 /// about 520 KB before the ring compresses it).
119 rewind_snap_buf: Vec<u8>,
120}
121
122impl VsDualSystem {
123 /// Construct the dual system from one ROM image. A proper `DualSystem`
124 /// dump carries BOTH CPUs' programs (64 KiB PRG: main half then sub
125 /// half — MAME's `prg` + `sub` regions; Mesen2's `prgOuter` split);
126 /// the sub console's mapper banks the second half. A 32 KiB
127 /// (main-half-only) dump constructs and runs, but its boot handshake
128 /// cannot complete — the sub-CPU program is simply absent.
129 ///
130 /// The caller has already determined the cart is a `DualSystem` board
131 /// (the SHA-keyed `vs_db` `dual_system` flag — see [`crate::Emu`]).
132 ///
133 /// # Errors
134 ///
135 /// Returns the underlying [`RomError`] if the bytes don't parse.
136 pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
137 Ok(Self::from_pair(
138 Nes::from_rom(bytes)?,
139 Nes::from_rom(bytes)?,
140 ))
141 }
142
143 /// Construct the dual system with an explicit audio sample rate (the
144 /// frontend's output-device rate), applied to BOTH consoles.
145 ///
146 /// The sample rate is baked into each `Nes` at construction (there is no
147 /// runtime setter), so the desktop present path — which knows the cpal
148 /// device rate — must build the pair through this constructor for the main
149 /// console's audio to resample correctly. Otherwise identical to
150 /// [`Self::from_rom`].
151 ///
152 /// # Errors
153 ///
154 /// Returns the underlying [`RomError`] if the bytes don't parse.
155 pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
156 Ok(Self::from_pair(
157 Nes::from_rom_with_sample_rate(bytes, sample_rate)?,
158 Nes::from_rom_with_sample_rate(bytes, sample_rate)?,
159 ))
160 }
161
162 /// Wire two freshly-constructed consoles into a `DualSystem` cabinet: mark
163 /// the sub half, provision the shared WRAM on both, and seed the reset-time
164 /// bit-1 handshake. Shared by every constructor so the wiring can never
165 /// drift between them.
166 fn from_pair(main: Nes, sub: Nes) -> Self {
167 let mut dual = Self {
168 main,
169 sub,
170 main_bit1: false,
171 sub_bit1: false,
172 comms_scratch: Vec::new(),
173 block_scratch: Vec::new(),
174 rewind: None,
175 rewind_capture_enabled: true,
176 rewind_snap_buf: Vec::new(),
177 };
178 dual.wire();
179 dual
180 }
181
182 /// Install the cabinet wiring on a freshly constructed or freshly
183 /// power-cycled pair. Shared by [`Self::from_pair`] and
184 /// [`Self::power_cycle`] (v2.9.8), so a power-cycled cabinet is wired
185 /// exactly as a new one is.
186 fn wire(&mut self) {
187 let dual = self;
188 // Cabinet wiring: mark the sub half (its $4016 bit 7 reads 0x80;
189 // its mapper banks the second PRG half + upper CHR pages — the two
190 // CPUs run different programs on real DualSystem boards) and
191 // provision the shared 2 KiB WRAM on BOTH consoles (each holds a
192 // copy; `pump_comms` converges them — the MAME `.share("nvram")`
193 // model).
194 dual.sub.bus_mut().set_vs_sub(true);
195 dual.sub.bus_mut().set_vs_dual_sub();
196 dual.main.bus_mut().enable_vs_dual_wram();
197 dual.sub.bus_mut().enable_vs_dual_wram();
198 // Reset-time seed (Mesen2 `VsControlManager::Reset`:
199 // `UpdateMainSubBit(main ? 0x00 : 0x02)`): the main half boots with
200 // its bit-1 signal LOW — which asserts the SUB's external /IRQ —
201 // and the sub boots with its signal HIGH (the main's /IRQ clear).
202 // Wrecking Crew requires this seed to progress past its handshake.
203 dual.apply_main_bit1(false);
204 dual.apply_sub_bit1(true);
205 }
206
207 /// Apply a MAIN-console bit-1 level: drive the sub's `/IRQ` (LOW
208 /// asserts, per Mesen2 `UpdateMainSubBit` / MAME
209 /// `(data & 2) ? CLEAR_LINE : ASSERT_LINE`).
210 const fn apply_main_bit1(&mut self, level: bool) {
211 self.main_bit1 = level;
212 self.sub.bus_mut().set_vs_external_irq(!level);
213 }
214
215 /// Apply a SUB-console bit-1 level: drive the main's `/IRQ`.
216 const fn apply_sub_bit1(&mut self, level: bool) {
217 self.sub_bit1 = level;
218 self.main.bus_mut().set_vs_external_irq(!level);
219 }
220
221 /// Drain both consoles' `$4016` comms levels + shared-WRAM write logs
222 /// and apply the protocol. Called after every stepped instruction on
223 /// either console, so partner-visible effects land with at most one
224 /// instruction of latency (within the 5-cycle soft-lockstep window).
225 fn pump_comms(&mut self) {
226 if let Some(level) = self.main.bus_mut().take_vs_mainsub_edge() {
227 self.apply_main_bit1(level);
228 }
229 if let Some(level) = self.sub.bus_mut().take_vs_mainsub_edge() {
230 self.apply_sub_bit1(level);
231 }
232 // Converge the shared-WRAM copies (both directions). The logs are
233 // usually empty; a handful of entries during the boot handshake and
234 // per-frame gameplay exchange. `comms_scratch` is drained (not
235 // replaced) each round, so its allocated capacity — and the
236 // mapper-side log's own capacity, via `drain_vs_dual_wram_writes`'s
237 // `Vec::drain` — survives across calls: steady-state, this loop is
238 // allocation-free.
239 self.main
240 .bus_mut()
241 .drain_vs_dual_wram_writes(&mut self.comms_scratch);
242 for (off, val) in self.comms_scratch.drain(..) {
243 self.sub.bus_mut().apply_vs_dual_wram_write(off, val);
244 }
245 self.sub
246 .bus_mut()
247 .drain_vs_dual_wram_writes(&mut self.comms_scratch);
248 for (off, val) in self.comms_scratch.drain(..) {
249 self.main.bus_mut().apply_vs_dual_wram_write(off, val);
250 }
251 }
252
253 /// Run one MAIN-console frame with the sub console soft-locksteped to
254 /// within a 5-CPU-cycle gap (Mesen2 `RunFrame` + `RunVsSubConsole`).
255 ///
256 /// Returns when the main console's PPU completes a frame (or its CPU
257 /// jams / the frame budget trips — mirroring `Nes::run_frame`'s
258 /// guards).
259 pub fn run_frame(&mut self) {
260 /// Same stuck-frame guard as `Nes::run_frame`.
261 const MAX_CYCLES_PER_FRAME: u64 = 150_000;
262 let start_frame = self.main.frame();
263 let start_cycle = self.main.cycle();
264 while self.main.frame() == start_frame {
265 if self.main.is_jammed() {
266 break;
267 }
268 if self.main.cycle().wrapping_sub(start_cycle) > MAX_CYCLES_PER_FRAME {
269 break;
270 }
271 self.main.step_instruction();
272 self.pump_comms();
273 // Drain the sub to within the 5-cycle gap (or its frame parity).
274 // The comparison must be overshoot-safe: an instruction advances
275 // 2..=8 cycles, so the sub routinely lands AHEAD of the main by
276 // a few cycles — a naive `wrapping_sub(..) > 5` then wraps to a
277 // huge unsigned value and runs the sub away forever.
278 while !self.sub.is_jammed()
279 && (self.main.cycle() > self.sub.cycle().saturating_add(5)
280 || self.main.frame() > self.sub.frame())
281 {
282 self.sub.step_instruction();
283 self.pump_comms();
284 }
285 }
286 // Consume both PPUs' frame-complete latches so external users of the
287 // underlying `Nes` (none today) never observe a stale latch.
288 let _ = self.main.bus_mut().take_frame_complete();
289 let _ = self.sub.bus_mut().take_frame_complete();
290 // v3.1.0 — push the completed frame into the cabinet's rewind ring.
291 if self.rewind.is_some() && self.rewind_capture_enabled {
292 self.rewind_capture();
293 }
294 }
295
296 /// The main console's 256x240 RGBA8 framebuffer (the left screen).
297 #[must_use]
298 pub fn main_framebuffer(&self) -> &[u8] {
299 self.main.framebuffer()
300 }
301
302 /// The sub console's 256x240 RGBA8 framebuffer (the right screen).
303 #[must_use]
304 pub fn sub_framebuffer(&self) -> &[u8] {
305 self.sub.framebuffer()
306 }
307
308 /// Route controller input: ports 0/1 (P1/P2) → the main console's
309 /// ports 0/1; ports 2/3 (P3/P4) → the sub console's ports 0/1.
310 pub const fn set_buttons(&mut self, port: usize, buttons: crate::Buttons) {
311 match port {
312 0 | 1 => self.main.set_buttons(port, buttons),
313 2 | 3 => self.sub.set_buttons(port - 2, buttons),
314 _ => {}
315 }
316 }
317
318 /// Coin routing (Mesen2 `VsControlManager`): acceptors 0/1 latch on the
319 /// MAIN console, 2/3 on the SUB console.
320 pub const fn insert_coin(&mut self, acceptor: u8) {
321 match acceptor {
322 0 | 1 => self.main.insert_coin(acceptor),
323 2 | 3 => self.sub.insert_coin(acceptor - 2),
324 _ => {}
325 }
326 }
327
328 /// Clear both consoles' latched coin signals.
329 pub const fn clear_coin(&mut self) {
330 self.main.clear_coin();
331 self.sub.clear_coin();
332 }
333
334 /// The service button (main panel) / service-2 (sub panel).
335 pub const fn set_vs_service(&mut self, panel: u8, pressed: bool) {
336 match panel {
337 0 => self.main.set_vs_service(pressed),
338 1 => self.sub.set_vs_service(pressed),
339 _ => {}
340 }
341 }
342
343 /// Borrow the main console (read-only diagnostics).
344 #[must_use]
345 pub const fn main(&self) -> &Nes {
346 &self.main
347 }
348
349 /// Borrow the sub console (read-only diagnostics).
350 #[must_use]
351 pub const fn sub(&self) -> &Nes {
352 &self.sub
353 }
354
355 /// Mutably borrow the main console (debugger / diagnostics — e.g. the
356 /// side-effect-free `debug_peek_cpu`, which needs `&mut` for the
357 /// mapper's banked lookups). Cross-wiring stays wrapper-owned; don't
358 /// drive `$4016` writes through this handle.
359 #[must_use]
360 pub const fn main_mut(&mut self) -> &mut Nes {
361 &mut self.main
362 }
363
364 /// Mutably borrow the sub console (debugger / diagnostics; see
365 /// [`Self::main_mut`]).
366 #[must_use]
367 pub const fn sub_mut(&mut self) -> &mut Nes {
368 &mut self.sub
369 }
370
371 /// Simultaneously borrow both consoles (diagnostics — e.g. a tracing
372 /// harness replicating the lockstep loop with instrumented pumping).
373 #[must_use]
374 pub const fn split_mut(&mut self) -> (&mut Nes, &mut Nes) {
375 (&mut self.main, &mut self.sub)
376 }
377
378 /// v2.9.8 — power-cycle the whole cabinet: both consoles cold-boot and
379 /// the cabinet wiring is installed again, so the result is the cabinet
380 /// [`Self::from_rom`] builds (with each console's settings and battery
381 /// RAM kept, as [`Nes::power_cycle`] keeps them).
382 ///
383 /// Power-cycling the two consoles alone is NOT enough, and that is why
384 /// this exists. [`Nes::power_cycle`] rebuilds each mapper from the ROM,
385 /// which drops the wiring construction installed on it: the sub
386 /// console's second-half PRG / upper-CHR banking (`set_vs_dual_sub`) and
387 /// both consoles' shared 2 KiB WRAM window (`enable_vs_dual_wram`). The
388 /// sub then runs the MAIN program, fails its `$4016` identity check, and
389 /// the boot handshake never completes. The wrapper's latches (the two
390 /// bit-1 levels, and through them each console's external `/IRQ`) are
391 /// re-seeded to their reset values, and the scratch buffers emptied.
392 pub fn power_cycle(&mut self) {
393 self.main.power_cycle();
394 self.sub.power_cycle();
395 self.comms_scratch.clear();
396 self.block_scratch.clear();
397 self.wire();
398 // The ring describes the timeline the power cycle just ended.
399 self.rewind_clear();
400 }
401
402 /// Serialize the dual system: a versioned container nesting the two
403 /// standard [`Nes`] snapshots plus the wrapper's latch state.
404 ///
405 /// Layout: `RVSD` magic, `u16` version, latch byte
406 /// (`bit0 = main_bit1, bit1 = sub_bit1`; bit 2 reserved — it carried
407 /// a WRAM-ownership flag in a pre-release layout and is ignored on
408 /// load), then two `u32`-length-prefixed `Nes` snapshots (main, sub).
409 #[must_use]
410 pub fn snapshot(&self) -> Vec<u8> {
411 let main = self.main.snapshot();
412 let sub = self.sub.snapshot();
413 let mut out = Vec::with_capacity(4 + 2 + 1 + 8 + main.len() + sub.len());
414 out.extend_from_slice(&SNAPSHOT_MAGIC);
415 out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
416 let latch = u8::from(self.main_bit1) | (u8::from(self.sub_bit1) << 1);
417 out.push(latch);
418 #[allow(clippy::cast_possible_truncation)]
419 out.extend_from_slice(&(main.len() as u32).to_le_bytes());
420 out.extend_from_slice(&main);
421 #[allow(clippy::cast_possible_truncation)]
422 out.extend_from_slice(&(sub.len() as u32).to_le_bytes());
423 out.extend_from_slice(&sub);
424 out
425 }
426
427 /// v2.9.1 (libretro re-audit NL-09) — [`Self::snapshot`] without the two
428 /// consoles' `THM ` thumbnails, encoded into a caller-owned buffer that is
429 /// reused across calls. The libretro core's `retro_serialize` path.
430 ///
431 /// Same container, same layout, and it restores through [`Self::restore`]
432 /// like a full snapshot: each console block is a
433 /// [`Nes::snapshot_core_into`] blob, and `THM ` is optional by format. What
434 /// it drops is the work nobody on this path reads. [`Self::snapshot`]
435 /// builds a 128x120 RGBA thumbnail per console (the desktop's slot picker
436 /// shows one; `RetroArch` never does), then a fresh buffer per console and a
437 /// third for the container, about 645 KB allocated on every call. v2.9.0
438 /// measured that at 314-1,630 us per call against 46-78 us for this shape
439 /// (`docs/performance.md`); the A/B that adopted it is recorded there.
440 ///
441 /// `&mut self` only for the pooled scratch buffer; nothing emulated
442 /// changes.
443 pub fn snapshot_into(&mut self, out: &mut Vec<u8>) {
444 out.clear();
445 out.extend_from_slice(&SNAPSHOT_MAGIC);
446 out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
447 out.push(u8::from(self.main_bit1) | (u8::from(self.sub_bit1) << 1));
448 let mut scratch = core::mem::take(&mut self.block_scratch);
449 for console in [&self.main, &self.sub] {
450 console.snapshot_core_into(&mut scratch);
451 #[allow(clippy::cast_possible_truncation)] // a console snapshot is ~260 KB
452 out.extend_from_slice(&(scratch.len() as u32).to_le_bytes());
453 out.extend_from_slice(&scratch);
454 }
455 self.block_scratch = scratch;
456 }
457
458 /// Restore a dual-system snapshot produced by [`Self::snapshot`] or
459 /// [`Self::snapshot_into`].
460 ///
461 /// # Errors
462 ///
463 /// Returns [`SnapshotError`] on a bad container or when either nested
464 /// console snapshot fails to restore. Both consoles are then unchanged
465 /// (since v2.9.0; before it a rejected sub block left main restored).
466 ///
467 /// A successful restore empties the cabinet's rewind ring (v3.1.0): the
468 /// entries describe the timeline the restore replaced, as
469 /// [`Nes::restore`] treats its own ring.
470 pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
471 self.restore_inner(data, false)?;
472 self.rewind_clear();
473 Ok(())
474 }
475
476 /// v3.1.0 (`T-PS-dual-runahead`) — [`Self::restore`] without its
477 /// side effects: the cabinet's rewind ring is kept, and each console is
478 /// restored with [`Nes::restore_quiet`] (no timeline-generation bump, no
479 /// rewind clear). For restores that return to the cabinet's own
480 /// timeline rather than replace it: run-ahead's rollback and a rewind
481 /// step. Same container, same all-or-nothing contract.
482 ///
483 /// # Errors
484 ///
485 /// As [`Self::restore`].
486 pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
487 self.restore_inner(data, true)
488 }
489
490 fn restore_inner(&mut self, data: &[u8], quiet: bool) -> Result<(), SnapshotError> {
491 // A malformed dual container reports as an unsupported format with
492 // the container version we could read (0 when even the header is
493 // short) — the closest fit among the existing error variants until
494 // rc.1's save-state rework gives the dual container its own.
495 let fail = |got: u16| SnapshotError::UnsupportedFormat {
496 got,
497 max: SNAPSHOT_VERSION,
498 };
499 if data.len() < 4 + 2 + 1 + 4 || data[0..4] != SNAPSHOT_MAGIC {
500 return Err(fail(0));
501 }
502 let version = u16::from_le_bytes([data[4], data[5]]);
503 if version != SNAPSHOT_VERSION {
504 return Err(fail(version));
505 }
506 let latch = data[6];
507 let mut cursor = 7usize;
508 let read_block = |cursor: &mut usize| -> Result<&[u8], SnapshotError> {
509 let len_end = cursor.checked_add(4).ok_or_else(|| fail(version))?;
510 let len_bytes: [u8; 4] = data
511 .get(*cursor..len_end)
512 .ok_or_else(|| fail(version))?
513 .try_into()
514 .map_err(|_| fail(version))?;
515 let len = u32::from_le_bytes(len_bytes) as usize;
516 let end = len_end.checked_add(len).ok_or_else(|| fail(version))?;
517 let block = data.get(len_end..end).ok_or_else(|| fail(version))?;
518 *cursor = end;
519 Ok(block)
520 };
521 let main_block = read_block(&mut cursor)?;
522 let sub_block = read_block(&mut cursor)?;
523 // v2.9.0 (re-audit NC-05 / NL-01b) — all-or-nothing across BOTH
524 // consoles. Each `Nes::restore` is atomic on its own (a rejected block
525 // leaves that console untouched), but the pair was not: a valid main
526 // block followed by a rejected sub block left main on the file's
527 // timeline and sub on the running one, a cabinet from two timelines
528 // whose shared WRAM is re-converged only on the success path below.
529 // So main is snapshotted before it is restored and put back if the sub
530 // block fails. The rollback is `restore_quiet` because the timeline
531 // generation was already bumped (and main's rewind ring cleared) by
532 // the loud restore that succeeded; neither can be un-done, and neither
533 // is emulated state. A console's own snapshot always restores (the
534 // round-trip invariant the save-state tests pin), asserted in debug
535 // builds as `Nes::restore` does for its own backup.
536 //
537 // v2.9.1 (NL-09): a pooled backup buffer here was measured and
538 // REJECTED -- the restore A/B moved by exactly its order-bias control
539 // in both runs (-12.2% vs -11.1%, -10.6% vs -10.9%), so the one
540 // allocation per restore is not where the time goes. Two full console
541 // restores are.
542 let mut main_backup = Vec::new();
543 self.main.snapshot_core_into(&mut main_backup);
544 let restore = |nes: &mut Nes, block: &[u8]| {
545 if quiet {
546 nes.restore_quiet(block)
547 } else {
548 nes.restore(block)
549 }
550 };
551 restore(&mut self.main, main_block)?;
552 if let Err(e) = restore(&mut self.sub, sub_block) {
553 let rolled_back = self.main.restore_quiet(&main_backup);
554 debug_assert!(
555 rolled_back.is_ok(),
556 "rolling the main console back to its own snapshot failed: {rolled_back:?}"
557 );
558 return Err(e);
559 }
560 // Re-derive the wrapper latch + re-drive the cross-console signals
561 // (the buses' transient comms fields are not serialized; the wrapper
562 // owns the authoritative copies).
563 self.main_bit1 = (latch & 0x01) != 0;
564 self.sub_bit1 = (latch & 0x02) != 0;
565 self.sub.bus_mut().set_vs_sub(true);
566 self.sub.bus_mut().set_vs_external_irq(!self.main_bit1);
567 self.main.bus_mut().set_vs_external_irq(!self.sub_bit1);
568 // Re-converge the shared-WRAM copies from ONE buffer: the nested
569 // snapshots each carry a copy (identical at snapshot time — the
570 // write logs are always drained within `run_frame`), but a restore
571 // into a fresh wrapper must not trust both blindly. The main's
572 // copy is authoritative; it is cloned onto the sub.
573 let wram = self
574 .main
575 .bus_mut()
576 .take_vs_dual_wram()
577 .or_else(|| self.sub.bus_mut().take_vs_dual_wram())
578 .unwrap_or_else(|| alloc::vec![0u8; 0x0800].into_boxed_slice());
579 self.sub.bus_mut().set_vs_dual_wram(wram.clone());
580 self.main.bus_mut().set_vs_dual_wram(wram);
581 Ok(())
582 }
583
584 /// v3.1.0 (`T-PS-dual-runahead`) — enable the cabinet's rewind ring with
585 /// the default byte budget and keyframe period.
586 pub fn enable_rewind(&mut self) {
587 self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD);
588 }
589
590 /// Enable the cabinet's rewind ring with an explicit byte budget and
591 /// keyframe period. Replaces (and so empties) any ring already enabled.
592 pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) {
593 self.rewind = Some(RewindRing::new(max_bytes, keyframe_period));
594 }
595
596 /// Disable the cabinet's rewind ring and free its memory.
597 pub fn disable_rewind(&mut self) {
598 self.rewind = None;
599 self.rewind_snap_buf = Vec::new();
600 }
601
602 /// `true` if the cabinet's rewind ring is enabled.
603 #[must_use]
604 pub const fn rewind_enabled(&self) -> bool {
605 self.rewind.is_some()
606 }
607
608 /// Number of buffered rewind entries (0 when rewind is disabled).
609 #[must_use]
610 pub fn rewind_len(&self) -> usize {
611 self.rewind.as_ref().map_or(0, RewindRing::len)
612 }
613
614 /// Turn the per-frame rewind capture in [`Self::run_frame`] on or off.
615 /// Run-ahead turns it off across its hidden and visible frames, which
616 /// are not the cabinet's timeline.
617 pub const fn set_rewind_capture(&mut self, enabled: bool) {
618 self.rewind_capture_enabled = enabled;
619 }
620
621 /// `true` while [`Self::run_frame`] captures into the rewind ring.
622 #[must_use]
623 pub const fn rewind_capture_enabled(&self) -> bool {
624 self.rewind_capture_enabled
625 }
626
627 /// Push the cabinet's current state into the rewind ring, keyed by the
628 /// main console's frame. [`Self::run_frame`] calls this after every
629 /// frame while capture is on; a no-op while rewind is disabled.
630 ///
631 /// Each entry is a whole `RVSD` container ([`Self::snapshot_into`]):
632 /// both consoles, framebuffers included, and the bit-1 latch. Unlike the
633 /// single console's ring, which stores SLIM entries and re-renders the
634 /// picture after a step back, the cabinet keeps the framebuffers in the
635 /// entry. That is what makes a step back exact for BOTH screens without
636 /// running the cabinet forward and back: a re-render would run the
637 /// five-cycle soft lockstep for a frame and restore again, twice the
638 /// work of a single console. The cost is two 245,760-byte framebuffers
639 /// per entry, which the ring's XOR delta and LZ4 reduce to the pixels
640 /// that changed since the last keyframe.
641 pub fn rewind_capture(&mut self) {
642 if self.rewind.is_none() {
643 return;
644 }
645 let frame = self.main.frame();
646 let mut buf = core::mem::take(&mut self.rewind_snap_buf);
647 self.snapshot_into(&mut buf);
648 if let Some(ring) = &mut self.rewind {
649 ring.push(frame, &buf);
650 }
651 self.rewind_snap_buf = buf;
652 }
653
654 /// Pop the most recent rewind entry and restore the cabinet to it, both
655 /// framebuffers included. Returns `true` on success and `false` when
656 /// the ring is empty, rewind is disabled, or the entry fails to decode
657 /// or restore (the cabinet is then unchanged, per [`Self::restore`]'s
658 /// all-or-nothing contract).
659 ///
660 /// The restore is quiet ([`Self::restore_quiet`]): the ring survives,
661 /// since the user is mid-rewind.
662 pub fn rewind_step_back(&mut self) -> bool {
663 let Some(ring) = self.rewind.as_mut() else {
664 return false;
665 };
666 let Some(Ok(bytes)) = ring.pop_back() else {
667 return false;
668 };
669 self.restore_quiet(&bytes).is_ok()
670 }
671
672 /// Drop every buffered rewind entry (the ring stays enabled).
673 pub fn rewind_clear(&mut self) {
674 if let Some(ring) = &mut self.rewind {
675 ring.clear();
676 }
677 }
678}
679
680/// The top-level emulator: one standard console, or a Vs. `DualSystem` pair.
681///
682/// v2.0.0 beta.5 — the API reshape scoped to the major (the plan's
683/// Workstream C/D): a NEW `rustynes-core` consumer would construct via
684/// [`Emu::from_rom`] and match on the variant; every existing single-console
685/// surface lives unchanged on [`Nes`]. **`rustynes-frontend` does NOT yet
686/// consume this type** — it still constructs `Nes` directly
687/// (`Nes::from_rom`/`from_rom_with_sample_rate`), so the `DualSystem` path
688/// is core-and-test-harness-only in this release; wiring the desktop/mobile
689/// UI onto `Emu` (dual-console rendering + 4-port input routing) is
690/// explicitly deferred, tracked as a beta.5 known gap (see the beta.5
691/// CHANGELOG entry and `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md`
692/// for the current disposition).
693pub enum Emu {
694 /// A standard single-console system (every cart except the four
695 /// `DualSystem` boards).
696 Single(Box<Nes>),
697 /// A Vs. `DualSystem` cabinet (two consoles + the cross-wiring).
698 Dual(Box<VsDualSystem>),
699}
700
701impl Emu {
702 /// Construct the right emulator shape for the ROM: a
703 /// [`VsDualSystem`] when the SHA-keyed `vs_db` flags the cart as a
704 /// `DualSystem` board, else a standard [`Nes`].
705 ///
706 /// # Errors
707 ///
708 /// Returns the underlying [`RomError`] if the bytes don't parse.
709 pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
710 let nes = Nes::from_rom(bytes)?;
711 // Two detection sources, OR'd: the NES 2.0 header (byte-13 high
712 // nibble = Vs. hardware type 5/6) and the SHA-keyed `vs_db` record.
713 // The db is load-bearing — the circulating DualSystem dumps are
714 // iNES 1.0 (no byte 13), so the header alone can never flag them.
715 let db_dual = crate::vs_db::lookup(&nes).is_some_and(|e| e.dual_system);
716 if nes.is_vs_dual_system() || db_dual {
717 // Reuse the probe as the MAIN console; parse once more for the SUB
718 // (two parses total, not three). `from_pair` applies the cabinet
719 // wiring to the pair.
720 let sub = Nes::from_rom(bytes)?;
721 Ok(Self::Dual(Box::new(VsDualSystem::from_pair(nes, sub))))
722 } else {
723 Ok(Self::Single(Box::new(nes)))
724 }
725 }
726
727 /// Like [`Self::from_rom`], but bakes the frontend's audio sample rate into
728 /// the console(s) — the desktop present path uses this so a `DualSystem`
729 /// cabinet's main-console audio resamples to the cpal device rate.
730 ///
731 /// # Errors
732 ///
733 /// Returns the underlying [`RomError`] if the bytes don't parse.
734 pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
735 let nes = Nes::from_rom_with_sample_rate(bytes, sample_rate)?;
736 let db_dual = crate::vs_db::lookup(&nes).is_some_and(|e| e.dual_system);
737 if nes.is_vs_dual_system() || db_dual {
738 // Reuse the probe as MAIN; parse once more for SUB (two parses, not
739 // three).
740 let sub = Nes::from_rom_with_sample_rate(bytes, sample_rate)?;
741 Ok(Self::Dual(Box::new(VsDualSystem::from_pair(nes, sub))))
742 } else {
743 Ok(Self::Single(Box::new(nes)))
744 }
745 }
746}