Skip to main content

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}