Skip to main content

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}