diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b37cf87..b50d3ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **GGPO-style rollback netplay — `v0.9.0 "Community"`, T-82-002.** A new `rustysnes-netplay` + crate implements two-player rollback netcode, ported from RustyNES's own proven + `rustynes-netplay::session::RollbackSession` shape (the N-player mesh/Roster/spectator/NAT- + traversal breadth RustyNES also carries is deliberately NOT ported — out of this ticket's + stated scope, and the SNES core itself only has two physical controller ports, no multitap + emulation, so 2 players is the core's own real ceiling, not an arbitrary cut). + - **The rollback loop**: every real frame, predict the remote player's input (repeat its last + known value), run the frame, and keep a checkpoint (a full `System::save_state()` snapshot) + at the last confirmed frame. A contradicted prediction restores the checkpoint and + re-simulates forward with corrected input. The checkpoint itself advances as confirmation + catches up (bounding resimulation distance instead of always replaying from frame 0), and a + periodic desync checksum is computed only from state that's already fully settled — an + earlier draft computed it from possibly-still-predicted "live" state, which raced an + eventual correction and produced a false-positive desync between two peers that were, in + fact, converging correctly; fixed before landing. + - **Reliability**: a dropped `Input` packet is resent every `advance()` call until the remote + peer's cumulative `InputAck` catches up — an earlier draft had no resend path at all, which + permanently stalled a session the first time a single packet was lost under any non-zero + packet-loss condition; fixed before landing (caught by the adverse-conditions determinism + test, not just reasoned about). + - **Proof, not assertion**: `tests/determinism.rs` drives two sessions over a seeded, + deterministic `MemoryTransport` — one run under ideal (zero-latency) conditions, one under + real synthetic latency + jitter + 10% packet loss — and asserts both sessions' per-frame + framebuffer hash sequence matches a fresh, no-rollback reference run exactly, frame for + frame, under both conditions. + - **Transports**: `udp.rs`'s `UdpTransport` is a real `std::net::UdpSocket`, proven by a + genuine OS-level loopback round-trip test. `webrtc.rs`'s `WebRtcTransport` wraps a + `web_sys::RtcDataChannel`, wasm32-clippy-verified against the real API. **Honest scope + note**: the frontend's UI wiring is native/UDP only this pass — the browser-side SDP + offer/answer/ICE negotiation glue needed to actually establish a `RtcDataChannel` is a + genuinely separate scope of async signaling work, not half-wired in. + - **Frontend integration**: a new `netplay` feature (native-only) adds a Tools → Netplay… + window (local/peer `host:port`, a P1/P2 slot picker, Connect/Disconnect) and a + `NetplayState`. `Active::render`'s per-frame loop dispatches to `NetplayState::drive` + (which calls `RollbackSession::advance` directly on `System`) via an early `continue` that + skips the entire single-player `apply_frame_input`/cheats/rewind/script/`run_frame` path for + that iteration whenever a session is connected — netplay's own drive loop, verified + independent of `emu-thread`, never both driving the same `System`. A new + `EmuCore::present_current_frame` splits `run_frame`'s framebuffer-decode/audio-drain half + out on its own, since `RollbackSession::advance` drives the core crate's `System` directly + (not this frontend's `EmuCore`) and only the session's own settled result — not each + internal resimulation pass — should ever reach the screen. **Known limitation, shared with + rollback netplay generally, not specific to this implementation**: video always reflects + the corrected state cleanly, but audio already sent to a real output device during a + since-corrected misprediction can't be "unplayed" — a rollback event may audibly glitch, + the same accepted artifact GGPO-family netcode has elsewhere. + - With `netplay` off, the crate's frontend wiring compiles out entirely (`rustysnes-netplay` + itself stays an always-compiled workspace member, same precedent as `rustysnes-script`); full + default-feature workspace build/test/clippy/fmt/doc verified unaffected. + - **Hardening from review, before merge**: an untrusted `Input`/`Checksum` message's `frame` + index is now bounds-checked before it can grow `history` (an unbounded value could otherwise + force an arbitrarily large allocation); the pending-remote-checksum queue is capped rather + than growing without bound; nothing from the remote peer is acted on before its `Sync` + handshake has verified the ROM hash + protocol version (`ingest`/`advance` both gate on it); + a misprediction-detection condition that referenced a predicted slot's `confirmed` flag — + always `false` for a genuine prediction, so it never actually fired — was corrected (the + underlying resimulation was already correct via the `confirmation_advanced` path, proven by + the passing determinism tests either way; only the public `AdvanceOutcome::rolled_back` flag + was misreporting); `settle_if_confirmed`'s duplicate `sys.save_state()` call was collapsed to + one (reused for both the checkpoint and the checksum hash); `SessionConfig::input_delay` — + documented but never read — is now wired into `add_local_input`, proven against a + delay-aware reference test; and `predict_remotes`'s O(frame) backward scan was replaced with + an O(1) read (frames are predicted in strictly increasing order, so the previous frame's + slot already holds the correct last-known value by induction). + - **Netplay save-state cost benchmark + rollback go/no-go call — `v0.9.0 "Community"`, T-82-001.** A new Criterion benchmark (`crates/rustysnes-core/benches/save_state_cost.rs`) measures `System::save_state()`/`load_state()` cost across three board tiers (no-coprocessor, diff --git a/Cargo.lock b/Cargo.lock index 3467b7f2..04af499d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3402,6 +3402,7 @@ dependencies = [ "ratatui", "rfd", "rustysnes-core", + "rustysnes-netplay", "rustysnes-savestate", "rustysnes-script", "serde", @@ -3419,6 +3420,14 @@ dependencies = [ [[package]] name = "rustysnes-netplay" version = "0.7.0" +dependencies = [ + "js-sys", + "rustysnes-core", + "rustysnes-savestate", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", +] [[package]] name = "rustysnes-ppu" diff --git a/crates/rustysnes-frontend/Cargo.toml b/crates/rustysnes-frontend/Cargo.toml index 7be70830..ae5195e9 100644 --- a/crates/rustysnes-frontend/Cargo.toml +++ b/crates/rustysnes-frontend/Cargo.toml @@ -59,6 +59,14 @@ scripting = ["dep:rustysnes-script"] # this flag gates only the frontend's cheat list + UI + per-frame application). No optional # dependency and no `target_arch` gate — decode is pure computation, unlike `scripting`'s `mlua`. cheats = [] +# `v0.9.0 "Community"`, T-82-002: GGPO-style rollback netplay (`rustysnes-netplay`). The frontend +# wiring here is native-only (UDP, a real socket-based peer connection entered by address) — the +# crate's `WebRtcTransport` is itself complete and wasm32-clippy-verified against the real +# `web_sys` API, but the browser-side SDP-negotiation UI (async offer/answer/ICE exchange) is a +# genuinely separate scope of frontend work, honestly deferred rather than half-wired. `dep:` +# keeps the optional dependency out of the wasm32 dependency graph, on top of the +# `target.'cfg(...)'` gate below already doing so. +netplay = ["dep:rustysnes-netplay"] retroachievements = [] [lints] @@ -112,6 +120,7 @@ clap_complete = "4" anstyle = "1" ratatui = { version = "0.30", optional = true } rustysnes-script = { path = "../rustysnes-script", version = "0.7.0", optional = true } +rustysnes-netplay = { path = "../rustysnes-netplay", version = "0.7.0", optional = true } # wasm32 target deps. The chip stack is `no_std + alloc`, so the browser build # only needs the wasm-bindgen + web-sys bridge here. diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index e4d5b2f8..cc356bbc 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -60,6 +60,10 @@ use rustysnes_script::ScriptEngine; #[cfg(feature = "cheats")] use crate::cheats::CheatEntry; +// Rollback netplay (`v0.9.0`, T-82-002) — native-only (`netplay.rs`'s own module doc has why). +#[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] +use crate::netplay::NetplayState; + /// The typed winit user-event, used by both native and `wasm32` (native simply never sends one). /// /// On `wasm32` the wgpu init is async and the ROM arrives via the browser file picker, so @@ -122,6 +126,10 @@ struct Active { /// The in-session cheat-code list (`v0.8.0`, T-81-003). Empty until `Cheats…` adds entries. #[cfg(feature = "cheats")] cheats: Vec, + /// Native rollback netplay connection state (`v0.9.0`, T-82-002). `Idle` until + /// `MenuAction::ConnectNetplay`. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + netplay: NetplayState, } /// TAS movie record/playback state (`v0.8.0`, T-81-002) — mutually exclusive with itself (you @@ -447,6 +455,10 @@ impl App { resampler, shell: ShellState { status: initial_status, + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + netplay_local_addr: "0.0.0.0:7777".into(), + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + netplay_peer_addr: "127.0.0.1:7777".into(), ..ShellState::default() }, pacer: Pacer::new(self.config.region.frame_rate()), @@ -462,6 +474,8 @@ impl App { movie: MovieState::default(), #[cfg(feature = "cheats")] cheats: Vec::new(), + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + netplay: NetplayState::default(), }); } @@ -591,6 +605,23 @@ impl App { let frames = active.pacer.tick(); let mut samples = Vec::new(); for _ in 0..frames { + // Netplay (`v0.9.0`, T-82-002) is its OWN drive loop, deliberately never the + // single-player path below it: a `RollbackSession` owns pad application, + // frame production, AND presentation (`NetplayState::drive`) — running it + // alongside movie/cheat/rewind/run-ahead machinery designed for a single, + // locally-authoritative `System` would race or double-drive the same state + // a remote peer is also authoritative over. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + if active.netplay.is_connected() { + let local_input = active.pad1.sanitize_dpad().0; + if let Err(e) = active.netplay.drive(local_input, &mut emu) { + eprintln!("rustysnes: netplay error, disconnecting: {e}"); + active.netplay = NetplayState::Idle; + active.shell.status = format!("Netplay error: {e}"); + } + samples.extend_from_slice(emu.audio()); + continue; + } // Sets `emu`'s pad(s) for THIS emulated frame: live input (the pre-existing // behavior, unchanged when `scripting` is off or no movie is active), or a // movie's recorded/replayed input (`v0.8.0`, T-81-002) when one is active — @@ -716,6 +747,8 @@ impl App { debug.as_ref(), #[cfg(feature = "cheats")] &mut active.cheats, + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + active.netplay.is_connected(), ); }); active @@ -998,6 +1031,61 @@ impl App { "Stop playback: not currently playing".into() }; } + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + MenuAction::ConnectNetplay => { + active.shell.netplay_error = None; + active.shell.status = 'connect: { + let local_addr: std::net::SocketAddr = + match active.shell.netplay_local_addr.trim().parse() { + Ok(a) => a, + Err(e) => { + let msg = format!("Bad local address: {e}"); + active.shell.netplay_error = Some(msg.clone()); + break 'connect msg; + } + }; + let peer_addr: std::net::SocketAddr = + match active.shell.netplay_peer_addr.trim().parse() { + Ok(a) => a, + Err(e) => { + let msg = format!("Bad peer address: {e}"); + active.shell.netplay_error = Some(msg.clone()); + break 'connect msg; + } + }; + let emu = active.core.lock().unwrap_or_else(PoisonError::into_inner); + if !emu.rom_loaded() { + let msg = "Connect: no ROM loaded".to_string(); + active.shell.netplay_error = Some(msg.clone()); + break 'connect msg; + } + let rom = emu.rom().to_vec(); + drop(emu); + match crate::netplay::start( + local_addr, + peer_addr, + active.shell.netplay_local_player, + &rom, + ) { + Ok(session) => { + active.netplay = NetplayState::Connected(session); + active.rewind.clear(); + active.quick_save = None; + "Netplay connected".into() + } + Err(e) => { + let msg = format!("Netplay connect failed: {e}"); + active.shell.netplay_error = Some(msg.clone()); + msg + } + } + }; + } + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + MenuAction::DisconnectNetplay => { + active.netplay = NetplayState::Idle; + active.shell.status = "Netplay disconnected".into(); + } } } // Persist any Settings-window edits to config (best-effort). diff --git a/crates/rustysnes-frontend/src/emu.rs b/crates/rustysnes-frontend/src/emu.rs index 685a904e..60e0d82f 100644 --- a/crates/rustysnes-frontend/src/emu.rs +++ b/crates/rustysnes-frontend/src/emu.rs @@ -238,6 +238,21 @@ impl EmuCore { self.system.bus.set_joypad(0, self.pads[0].0); self.system.bus.set_joypad(1, self.pads[1].0); self.system.run_frame(); + self.present_current_frame(); + } + + /// Decode the PPU framebuffer + drain the S-DSP audio from the `System`'s CURRENT state, + /// without advancing it — the second half of [`Self::run_frame`], split out for netplay + /// (`v0.9.0`, T-82-002): `rustysnes_netplay::RollbackSession::advance` drives + /// `System::run_frame` directly (it operates on the core crate, not this frontend type), so + /// the frontend calls this afterward to pick up the result. A rollback's internal + /// re-simulation passes (`RollbackSession`'s own `apply_and_run`) run several frames per + /// `advance()` call without presenting each one — only the settled result matters + /// user-visibly. **Known limitation, shared with rollback netplay generally, not specific to + /// this implementation:** video always reflects the corrected state cleanly, but audio + /// already sent to a real output device during a since-corrected misprediction can't be + /// "unplayed" — a rollback event may audibly glitch, same as GGPO-family netcode elsewhere. + pub fn present_current_frame(&mut self) { self.audio.clear(); if self.rom_loaded { self.system.bus.apu.drain_audio(&mut self.audio); diff --git a/crates/rustysnes-frontend/src/lib.rs b/crates/rustysnes-frontend/src/lib.rs index be3b02f4..6784bd76 100644 --- a/crates/rustysnes-frontend/src/lib.rs +++ b/crates/rustysnes-frontend/src/lib.rs @@ -43,6 +43,10 @@ pub mod debug_snapshot; pub mod emu; pub mod gfx; pub mod input; +// Native rollback netplay (`v0.9.0` T-82-002). Native-only: browser WebRTC signaling UI is a +// separate, deferred scope (`netplay.rs`'s own module doc has the detail). +#[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] +pub mod netplay; pub(crate) mod pacing; pub mod rewind; pub mod ui_shell; diff --git a/crates/rustysnes-frontend/src/netplay.rs b/crates/rustysnes-frontend/src/netplay.rs new file mode 100644 index 00000000..03d3f019 --- /dev/null +++ b/crates/rustysnes-frontend/src/netplay.rs @@ -0,0 +1,95 @@ +//! Native rollback netplay integration (`v0.9.0 "Community"`, T-82-002). +//! +//! Wraps a [`rustysnes_netplay::RollbackSession`] over a real [`UdpTransport`], driving the +//! `System` directly (`RollbackSession::advance` operates on `rustysnes_core::System`, not this +//! frontend's `EmuCore`, since the netplay crate depends only on the core crate — see +//! `rustysnes-netplay`'s own crate doc). The app's render loop calls [`NetplayState::drive`] +//! instead of `EmuCore::run_frame` whenever a session is active — netplay's own loop, never the +//! single-player `apply_frame_input`/pacer/`emu-thread` path (`docs/frontend.md` +//! §determinism-boundary), so the two production models can never both drive the same `System`. +//! +//! **Native (UDP) only for this pass.** `rustysnes_netplay::webrtc::WebRtcTransport` is itself +//! complete and wasm32-clippy-verified against the real `web_sys` API, but the browser-side SDP +//! offer/answer/ICE negotiation UI is genuinely separate scope (async signaling glue), honestly +//! deferred rather than half-wired — see `v0.9.0`'s CHANGELOG entry. + +use std::net::SocketAddr; + +use rustysnes_netplay::udp::UdpTransport; +use rustysnes_netplay::{AdvanceOutcome, NetplayError, RollbackSession, SessionConfig}; + +use crate::emu::EmuCore; + +/// A native netplay session's connection state. +/// +/// `Connected` is boxed: `RollbackSession` carries its own frame-input history plus (on a +/// misprediction) a full save-state checkpoint blob, making it far larger than `Idle` — boxing +/// keeps `NetplayState` itself small regardless of which variant is live. +#[derive(Default)] +pub enum NetplayState { + /// No session active — the frontend drives `EmuCore` through its normal single-player path. + #[default] + Idle, + /// A session is connected and driving the `System` directly. + Connected(Box>), +} + +/// Start a new netplay session: bind `local_addr`, connect to `peer_addr`, and send the +/// handshake. +/// +/// `local_player` selects which controller slot (`0` or `1`) this peer's own input drives; +/// `rom` is the currently-loaded ROM's raw bytes (hashed and compared against the remote peer's +/// during the handshake, so two peers on different ROMs are rejected rather than silently +/// diverging). +/// +/// # Errors +/// Returns the underlying `std::io::Error` if the UDP socket can't be bound/connected. +pub fn start( + local_addr: SocketAddr, + peer_addr: SocketAddr, + local_player: u8, + rom: &[u8], +) -> std::io::Result>> { + let transport = UdpTransport::connect(local_addr, peer_addr)?; + let rom_hash = rustysnes_core::movie::hash_rom(rom); + let mut session = RollbackSession::new( + SessionConfig { + local_player, + ..SessionConfig::default() + }, + transport, + rom_hash, + ); + session.send_handshake(); + Ok(Box::new(session)) +} + +impl NetplayState { + /// Drive one real frame: apply `local_input` (this peer's own controller state, already + /// sanitized) into the session, advance it, and — if a new frame was actually produced (not + /// [`AdvanceOutcome::Stalled`], waiting on the remote peer) — present it through `emu` + /// exactly as [`EmuCore::run_frame`] would (see [`EmuCore::present_current_frame`]'s doc for + /// why this is a separate call from driving the `System` itself). + /// + /// # Errors + /// Returns [`NetplayError`] on a failed handshake, a ROM mismatch, a confirmed-state desync, + /// or a save-state error — the caller should end the session and fall back to single-player + /// on any of these, they are not recoverable mid-session. + pub fn drive(&mut self, local_input: u16, emu: &mut EmuCore) -> Result<(), NetplayError> { + let Self::Connected(session) = self else { + return Ok(()); + }; + session.add_local_input(local_input); + match session.advance(emu.system_mut())? { + AdvanceOutcome::Advanced { .. } => emu.present_current_frame(), + AdvanceOutcome::Stalled => {} + } + Ok(()) + } + + /// Whether a session is currently connected. + #[must_use] + pub const fn is_connected(&self) -> bool { + matches!(self, Self::Connected(_)) + } +} diff --git a/crates/rustysnes-frontend/src/ui_shell.rs b/crates/rustysnes-frontend/src/ui_shell.rs index acbe5756..9fee8754 100644 --- a/crates/rustysnes-frontend/src/ui_shell.rs +++ b/crates/rustysnes-frontend/src/ui_shell.rs @@ -65,6 +65,13 @@ pub enum MenuAction { /// Stop TAS movie playback. #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] StopMoviePlayback, + /// Bind/connect a native UDP netplay session (`v0.9.0` T-82-002) using the Netplay window's + /// current local-address/peer-address/player-slot fields. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + ConnectNetplay, + /// End the active netplay session and fall back to single-player. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + DisconnectNetplay, } /// Which debugger panel is selected in the overlay (SNES chip set). @@ -106,6 +113,21 @@ pub struct ShellState { /// The Cheats window's last parse-error message, if the most recent "Add" attempt failed. #[cfg(feature = "cheats")] pub cheat_code_error: Option, + /// Whether the Netplay window is visible (`v0.9.0` T-82-002). + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + pub netplay_open: bool, + /// The Netplay window's "local address" text-entry buffer (`host:port` to bind). + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + pub netplay_local_addr: String, + /// The Netplay window's "peer address" text-entry buffer (`host:port` to connect to). + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + pub netplay_peer_addr: String, + /// Which controller slot (`0` or `1`) this peer's own input will drive. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + pub netplay_local_player: u8, + /// The Netplay window's last connection-attempt error message, if any. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + pub netplay_error: Option, } /// Read-only facts the shell needs to render the status bar + window title without taking the @@ -139,6 +161,7 @@ impl ShellState { cfg: &mut Config, debug: Option<&DebugSnapshot>, #[cfg(feature = "cheats")] cheats: &mut Vec, + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] netplay_connected: bool, ) -> Vec { let mut actions = Vec::new(); let ctx = root_ui.ctx().clone(); @@ -264,6 +287,16 @@ impl ShellState { } #[cfg(not(feature = "cheats"))] ui.label("(rebuild with --features cheats for Game Genie/PAR codes)"); + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + { + ui.separator(); + if ui.button("Netplay…").clicked() { + self.netplay_open = true; + ui.close(); + } + } + #[cfg(not(all(feature = "netplay", not(target_arch = "wasm32"))))] + ui.label("(rebuild natively with --features netplay)"); // TODO(impl-phase): NSF/SPC player, ROM-DB editor, TAStudio. }); @@ -321,6 +354,10 @@ impl ShellState { if self.cheats_open { self.render_cheats(&ctx, cheats); } + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + if self.netplay_open { + self.render_netplay(&ctx, netplay_connected, &mut actions); + } actions } @@ -456,6 +493,60 @@ impl ShellState { }); self.cheats_open = open; } + + /// The Netplay window (`v0.9.0` T-82-002): local/peer `host:port` text entry, a P1/P2 slot + /// picker, and a Connect/Disconnect button. Doesn't perform the actual socket I/O itself + /// (that needs the currently-loaded ROM's bytes under the emu lock, and this function NEVER + /// touches the emu lock) — it only edits the text-entry fields directly and pushes + /// [`MenuAction::ConnectNetplay`]/[`MenuAction::DisconnectNetplay`] for `App::dispatch_actions` + /// to actually act on afterward, same as every other I/O-performing menu action. + #[cfg(all(feature = "netplay", not(target_arch = "wasm32")))] + fn render_netplay( + &mut self, + ctx: &egui::Context, + connected: bool, + actions: &mut Vec, + ) { + let mut open = self.netplay_open; + egui::Window::new("Netplay") + .open(&mut open) + .resizable(true) + .show(ctx, |ui| { + ui.add_enabled_ui(!connected, |ui| { + egui::Grid::new("netplay_fields") + .num_columns(2) + .show(ui, |ui| { + ui.label("Local address"); + ui.text_edit_singleline(&mut self.netplay_local_addr); + ui.end_row(); + ui.label("Peer address"); + ui.text_edit_singleline(&mut self.netplay_peer_addr); + ui.end_row(); + ui.label("Player slot"); + ui.horizontal(|ui| { + ui.radio_value(&mut self.netplay_local_player, 0, "P1"); + ui.radio_value(&mut self.netplay_local_player, 1, "P2"); + }); + ui.end_row(); + }); + }); + ui.separator(); + if connected { + ui.label("Connected."); + if ui.button("Disconnect").clicked() { + actions.push(MenuAction::DisconnectNetplay); + } + } else { + if ui.button("Connect").clicked() { + actions.push(MenuAction::ConnectNetplay); + } + if let Some(err) = &self.netplay_error { + ui.colored_label(egui::Color32::RED, err); + } + } + }); + self.netplay_open = open; + } } /// 65C816 registers + processor-status flags. Disassembly + breakpoints/stepping are a diff --git a/crates/rustysnes-netplay/Cargo.toml b/crates/rustysnes-netplay/Cargo.toml index 076b8d53..00c11c3d 100644 --- a/crates/rustysnes-netplay/Cargo.toml +++ b/crates/rustysnes-netplay/Cargo.toml @@ -1,10 +1,29 @@ [package] name = "rustysnes-netplay" +description = "RustySNES: GGPO-style rollback netplay (UDP native, WebRTC browser)" version = "0.7.0" edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true +repository.workspace = true + +[dependencies] +rustysnes-core = { path = "../rustysnes-core", version = "0.7.0" } +rustysnes-savestate = { path = "../rustysnes-savestate", version = "0.7.0" } +thiserror = "2" + +# wasm32 target deps: the browser WebRTC transport (`RtcPeerConnection`/`RtcDataChannel`). +# Native builds never see these — no C toolchain, no vendored library, unlike `rustysnes-script`. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.126" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "RtcPeerConnection", "RtcDataChannel", "RtcDataChannelInit", "RtcDataChannelType", + "RtcDataChannelState", "RtcConfiguration", "RtcSessionDescriptionInit", "RtcSdpType", + "RtcPeerConnectionIceEvent", "RtcIceCandidate", "RtcIceCandidateInit", "MessageEvent", + "BinaryType", +] } [lints] workspace = true diff --git a/crates/rustysnes-netplay/src/lib.rs b/crates/rustysnes-netplay/src/lib.rs index 4c174289..d99cd7ee 100644 --- a/crates/rustysnes-netplay/src/lib.rs +++ b/crates/rustysnes-netplay/src/lib.rs @@ -1 +1,29 @@ -//! `rustysnes-netplay` — reach crate (additive, off by default). +//! `rustysnes-netplay` — GGPO-style rollback netplay (`v0.9.0 "Community"`, T-82-002). +//! +//! Ported from RustyNES's `rustynes-netplay::session::RollbackSession` (the rollback loop's +//! shape is carried over faithfully — see `session.rs`'s module doc for the exact scope this +//! port covers vs. RustyNES's broader N-player mesh/NAT-traversal/spectator feature set, which +//! is out of this ticket's stated acceptance criteria and not ported here). +//! +//! The frontend drives a session with its own loop, independent of the single-player +//! `emu-thread`/pacer path (`docs/frontend.md`) — this crate itself has no opinion on threading +//! or pacing; it is pure `System`-driving logic plus a pluggable [`Transport`]. +//! +//! Determinism (`docs/adr/0004`) is the whole point: [`session::RollbackSession::advance`]'s +//! rollback/re-simulate path must reproduce a hypothetical zero-latency reference run +//! bit-identically. `tests/determinism.rs` proves this over synthetic latency/jitter/packet-loss +//! network conditions via [`transport::MemoryTransport`]. + +pub mod message; +pub mod rng; +pub mod session; +pub mod transport; + +#[cfg(not(target_arch = "wasm32"))] +pub mod udp; +#[cfg(target_arch = "wasm32")] +pub mod webrtc; + +pub use message::NetMessage; +pub use session::{AdvanceOutcome, MAX_PLAYERS, NetplayError, RollbackSession, SessionConfig}; +pub use transport::Transport; diff --git a/crates/rustysnes-netplay/src/message.rs b/crates/rustysnes-netplay/src/message.rs new file mode 100644 index 00000000..fde8ffda --- /dev/null +++ b/crates/rustysnes-netplay/src/message.rs @@ -0,0 +1,278 @@ +//! The netplay wire protocol — hand-rolled, little-endian, tag-byte-discriminated (no serde +//! dependency, matching RustyNES's `rustynes-netplay::message` this crate ports its shape from). +//! +//! Every [`NetMessage`] round-trips through [`NetMessage::encode`]/[`NetMessage::decode`] +//! byte-for-byte; `decode` rejects truncated/malformed input rather than panicking (untrusted +//! network input, `master-core` module 60's input-validation rule). + +/// The protocol version this build speaks — bumped whenever the wire format changes so two +/// mismatched builds fail the [`NetMessage::Sync`] handshake cleanly instead of misinterpreting +/// bytes. +pub const PROTOCOL_VERSION: u16 = 1; + +/// [`NetMessage::Sync`]'s magic value — identifies a peer as speaking this protocol at all, +/// before the ROM-hash/version fields are even trusted. `RSNP` (RustySNES Netplay) in ASCII. +pub const SYNC_MAGIC: u32 = 0x5253_4E50; + +/// A message exchanged between two netplay peers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetMessage { + /// The connection handshake: proves both peers speak this protocol version and are loaded + /// on the identical ROM (`rom_hash`, SHA-256) before any input is trusted. + Sync { + /// Must equal [`SYNC_MAGIC`]. + magic: u32, + /// Must equal [`PROTOCOL_VERSION`]. + version: u16, + /// The sender's loaded ROM's SHA-256 hash. + rom_hash: [u8; 32], + }, + /// One player's input for one frame. + Input { + /// Which controller slot this input is for (`0` or `1` — the SNES core has exactly two + /// physical controller ports; multitap is not emulated, so netplay is scoped to 2 + /// players, matching the core's own capability). + player: u8, + /// The frame this input applies to. + frame: u32, + /// The raw 16-bit button state (`Bus::set_joypad`'s own format). + input: u16, + }, + /// Cumulative input acknowledgement: "I have every input up to and including `frame`, + /// contiguously" — NOT "the highest frame I've seen," so a dropped low frame keeps getting + /// resent even after later frames arrive out of order. + InputAck { + /// The highest frame acknowledged as part of a contiguous run from frame 0. + frame: u32, + }, + /// A periodic desync-detection checksum for one frame's post-execution state. + Checksum { + /// The frame this checksum was taken after. + frame: u32, + /// A hash of the full `System::save_state()` blob — catches a pure-timing/audio + /// divergence a framebuffer-only hash might miss (see `fb_hash` below). + hash: u64, + /// A hash of the framebuffer alone — isolates whether a mismatch is a rendered-output + /// divergence specifically, distinct from an audio/timing-only divergence. + fb_hash: u64, + }, + /// A lightweight, non-critical connection-quality signal (never gates correctness). + Quality { + /// Measured round-trip time, milliseconds. + ping_ms: u32, + /// This peer's frame count minus the last frame it has confirmed from the other peer — + /// how far ahead (positive) or behind (negative) this peer is running. + frame_advantage: i32, + }, +} + +/// Error decoding a [`NetMessage`] from untrusted bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + /// The buffer ended before a complete message could be read. + #[error("truncated netplay message")] + Truncated, + /// The leading tag byte didn't match any known [`NetMessage`] variant. + #[error("unrecognized netplay message tag {0}")] + UnknownTag(u8), +} + +const TAG_SYNC: u8 = 0; +const TAG_INPUT: u8 = 1; +const TAG_INPUT_ACK: u8 = 2; +const TAG_CHECKSUM: u8 = 3; +const TAG_QUALITY: u8 = 4; + +impl NetMessage { + /// Serialize this message to its wire format. + #[must_use] + pub fn encode(&self) -> Vec { + let mut buf = Vec::new(); + match self { + Self::Sync { + magic, + version, + rom_hash, + } => { + buf.push(TAG_SYNC); + buf.extend_from_slice(&magic.to_le_bytes()); + buf.extend_from_slice(&version.to_le_bytes()); + buf.extend_from_slice(rom_hash); + } + Self::Input { + player, + frame, + input, + } => { + buf.push(TAG_INPUT); + buf.push(*player); + buf.extend_from_slice(&frame.to_le_bytes()); + buf.extend_from_slice(&input.to_le_bytes()); + } + Self::InputAck { frame } => { + buf.push(TAG_INPUT_ACK); + buf.extend_from_slice(&frame.to_le_bytes()); + } + Self::Checksum { + frame, + hash, + fb_hash, + } => { + buf.push(TAG_CHECKSUM); + buf.extend_from_slice(&frame.to_le_bytes()); + buf.extend_from_slice(&hash.to_le_bytes()); + buf.extend_from_slice(&fb_hash.to_le_bytes()); + } + Self::Quality { + ping_ms, + frame_advantage, + } => { + buf.push(TAG_QUALITY); + buf.extend_from_slice(&ping_ms.to_le_bytes()); + buf.extend_from_slice(&frame_advantage.to_le_bytes()); + } + } + buf + } + + /// Deserialize a message from `bytes` (the exact output of a prior [`Self::encode`], or + /// arbitrary untrusted network input — this never panics on malformed data). + /// + /// # Errors + /// Returns [`DecodeError::Truncated`] if `bytes` ends before a complete message is read, or + /// [`DecodeError::UnknownTag`] if the leading tag byte is unrecognized. + pub fn decode(bytes: &[u8]) -> Result { + let mut r = Reader { bytes, pos: 0 }; + let tag = r.u8()?; + match tag { + TAG_SYNC => Ok(Self::Sync { + magic: r.u32()?, + version: r.u16()?, + rom_hash: r.bytes32()?, + }), + TAG_INPUT => Ok(Self::Input { + player: r.u8()?, + frame: r.u32()?, + input: r.u16()?, + }), + TAG_INPUT_ACK => Ok(Self::InputAck { frame: r.u32()? }), + TAG_CHECKSUM => Ok(Self::Checksum { + frame: r.u32()?, + hash: r.u64()?, + fb_hash: r.u64()?, + }), + TAG_QUALITY => Ok(Self::Quality { + ping_ms: r.u32()?, + frame_advantage: r.i32()?, + }), + other => Err(DecodeError::UnknownTag(other)), + } + } +} + +/// A minimal cursor-based little-endian reader over untrusted bytes — every accessor bounds-checks +/// before reading, returning [`DecodeError::Truncated`] rather than panicking or reading OOB. +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl Reader<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], DecodeError> { + let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?; + let slice = self + .bytes + .get(self.pos..end) + .ok_or(DecodeError::Truncated)?; + self.pos = end; + Ok(slice) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap())) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + + fn i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + + fn bytes32(&mut self) -> Result<[u8; 32], DecodeError> { + Ok(self.take(32)?.try_into().unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(msg: &NetMessage) { + let bytes = msg.encode(); + assert_eq!(&NetMessage::decode(&bytes).unwrap(), msg); + } + + #[test] + fn every_variant_round_trips() { + round_trip(&NetMessage::Sync { + magic: SYNC_MAGIC, + version: PROTOCOL_VERSION, + rom_hash: [0x42; 32], + }); + round_trip(&NetMessage::Input { + player: 1, + frame: 12345, + input: 0x8421, + }); + round_trip(&NetMessage::InputAck { frame: 999 }); + round_trip(&NetMessage::Checksum { + frame: 42, + hash: 0xDEAD_BEEF_CAFE_F00D, + fb_hash: 0x1234_5678_9ABC_DEF0, + }); + round_trip(&NetMessage::Quality { + ping_ms: 30, + frame_advantage: -3, + }); + } + + #[test] + fn decode_rejects_truncated_input() { + let full = NetMessage::Input { + player: 0, + frame: 1, + input: 1, + } + .encode(); + for len in 0..full.len() { + assert_eq!( + NetMessage::decode(&full[..len]), + Err(DecodeError::Truncated), + "length {len} should be truncated" + ); + } + } + + #[test] + fn decode_rejects_unknown_tag() { + assert_eq!( + NetMessage::decode(&[0xFF, 0, 0, 0]), + Err(DecodeError::UnknownTag(0xFF)) + ); + } + + #[test] + fn decode_rejects_empty_input() { + assert_eq!(NetMessage::decode(&[]), Err(DecodeError::Truncated)); + } +} diff --git a/crates/rustysnes-netplay/src/rng.rs b/crates/rustysnes-netplay/src/rng.rs new file mode 100644 index 00000000..9837eb92 --- /dev/null +++ b/crates/rustysnes-netplay/src/rng.rs @@ -0,0 +1,75 @@ +//! A seeded, deterministic PRNG for test-only synthetic network conditions. +//! +//! Used by [`crate::transport::MemoryTransport`]'s latency/jitter/drop simulation — never +//! `std::time`, never OS randomness, so a determinism test's "network" behavior replays +//! identically across runs (`docs/adr/0004`). Ported from RustyNES's `rustynes-netplay::rng` +//! (`SplitMix64`, David Blackman & Sebastiano Vigna's public-domain generator). + +/// A `SplitMix64` generator: minimal state (one `u64`), well-distributed, fast — exactly what a +/// test harness needs for reproducible synthetic jitter, not cryptographic strength. +#[derive(Debug, Clone)] +pub struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + /// Seed a new generator. The same seed always produces the same output sequence. + #[must_use] + pub const fn new(seed: u64) -> Self { + Self { state: seed } + } + + /// The next 64-bit output in the sequence. + pub const fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A pseudo-random `f64` in `[0.0, 1.0)`. + // The top 53 bits give a uniformly distributed mantissa's worth of precision — the whole + // point is a lossy u64 -> f64 narrowing into exactly that many significant bits, not a bug. + #[allow(clippy::cast_precision_loss)] + pub fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// True with probability `p` (`p` clamped to `[0.0, 1.0]`). + pub fn chance(&mut self, p: f64) -> bool { + self.next_f64() < p.clamp(0.0, 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::SplitMix64; + + #[test] + fn same_seed_is_bit_identical() { + let mut a = SplitMix64::new(42); + let mut b = SplitMix64::new(42); + for _ in 0..1000 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn different_seeds_diverge() { + let mut a = SplitMix64::new(1); + let mut b = SplitMix64::new(2); + assert_ne!(a.next_u64(), b.next_u64()); + } + + #[test] + fn chance_zero_never_fires_chance_one_always_fires() { + let mut rng = SplitMix64::new(7); + for _ in 0..100 { + assert!(!rng.chance(0.0)); + } + for _ in 0..100 { + assert!(rng.chance(1.0)); + } + } +} diff --git a/crates/rustysnes-netplay/src/session.rs b/crates/rustysnes-netplay/src/session.rs new file mode 100644 index 00000000..7d1f28fb --- /dev/null +++ b/crates/rustysnes-netplay/src/session.rs @@ -0,0 +1,614 @@ +//! `RollbackSession` — GGPO-style rollback netplay (`v0.9.0 "Community"`, T-82-002). +//! +//! Ported from RustyNES's `rustynes-netplay::session::RollbackSession` (the core rollback +//! loop's shape is carried over faithfully; the N-player mesh/Roster/spectator/NAT-traversal +//! breadth RustyNES also has is deliberately NOT ported — out of this ticket's stated +//! acceptance criteria, and the SNES core itself only has two physical controller ports +//! (`Bus::joypad: [u16; 2]`, no multitap emulation), so this is scoped to exactly 2 players, +//! not RustyNES's up-to-4). +//! +//! The model: every real frame, predict the remote player's input (repeat its last known value +//! if nothing new arrived), run the frame, and remember a checkpoint (a full [`System::save_state`] +//! snapshot) at the point just before running an unconfirmed frame. When a remote input arrives +//! that contradicts an already-run prediction, restore the checkpoint and re-simulate forward +//! with the now-corrected input history — this is what makes the two peers' final state +//! bit-identical to a hypothetical zero-latency run, proven by `tests/determinism.rs`. +//! +//! Every field read out of an incoming [`NetMessage`] is untrusted network input: a `frame` +//! index is bounds-checked against `MAX_TRUSTED_FRAME_AHEAD` before it's ever used to grow +//! `history` (an unbounded value would otherwise let a hostile or corrupted peer force an +//! arbitrarily large allocation), and nothing from the remote is acted on before its `Sync` +//! handshake (ROM hash + protocol version) has been verified. + +use std::collections::VecDeque; + +use rustysnes_core::System; + +use crate::message::{NetMessage, PROTOCOL_VERSION, SYNC_MAGIC}; +use crate::transport::Transport; + +/// The SNES core has exactly two physical controller ports (`Bus::joypad: [u16; 2]`) — no +/// multitap emulation exists, so rollback netplay is scoped to this many players. +pub const MAX_PLAYERS: usize = 2; + +/// The furthest ahead of `current_frame` a remote-reported frame index (`Input`/`Checksum`) is +/// ever trusted to be. A real peer's own `current_frame` tracks within network latency + +/// `max_rollback_frames` of ours; ~10,000 frames (~166 s at 60 fps) is a generous margin that +/// still bounds `history`'s growth to a trivial allocation regardless of what a hostile or +/// corrupted peer sends (an unbounded `frame` value would otherwise let a single message force +/// an arbitrarily large `Vec::resize`). +const MAX_TRUSTED_FRAME_AHEAD: u32 = 10_000; + +/// The maximum number of not-yet-matched remote checksums to retain. A well-behaved peer sends +/// one every `checksum_interval` frames, which this never approaches; the cap exists so a +/// hostile or corrupted peer flooding `Checksum` messages can't grow this queue without bound — +/// the oldest unmatched entry is dropped to make room, matching the "trust nothing from the +/// network without a bound" posture the frame check above already applies. +const MAX_PENDING_REMOTE_CHECKSUMS: usize = 256; + +/// A session's tuning knobs. +#[derive(Debug, Clone)] +pub struct SessionConfig { + /// Which controller slot (`0` or `1`) this peer's own input drives. + pub local_player: u8, + /// How many frames of input-buffering delay to add before an input takes effect — trades + /// perceived input latency for fewer rollbacks (GGPO's own "input delay" knob). `0` disables + /// it (input applies the instant it's read). Implemented by [`RollbackSession::add_local_input`] + /// queuing the local player's input `input_delay` frames ahead of `current_frame`; the same + /// rollback/resimulation machinery that corrects a remote misprediction also corrects a local + /// one, so no separate code path is needed. + pub input_delay: u32, + /// The maximum number of unconfirmed frames the local simulation may run ahead of the last + /// confirmed frame before stalling — bounds how much resimulation a late misprediction can + /// ever cost, and bounds the checkpoint replay-forward distance. + pub max_rollback_frames: u32, + /// Send a [`NetMessage::Checksum`] every this many frames for desync detection. `0` disables + /// it entirely. + pub checksum_interval: u32, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + local_player: 0, + input_delay: 0, + max_rollback_frames: 8, + checksum_interval: 30, + } + } +} + +/// Errors a [`RollbackSession`] can raise. +#[derive(Debug, thiserror::Error)] +pub enum NetplayError { + /// The remote peer's [`NetMessage::Sync`] didn't carry the expected magic value — not + /// speaking this protocol at all. + #[error("sync handshake failed: expected magic {expected:#x}, got {got:#x}")] + BadMagic { + /// The magic this build expects ([`SYNC_MAGIC`]). + expected: u32, + /// The magic the remote peer actually sent. + got: u32, + }, + /// The remote peer speaks a different protocol version. + #[error("protocol version mismatch: local {local}, remote {remote}")] + VersionMismatch { + /// This build's [`PROTOCOL_VERSION`]. + local: u16, + /// The remote peer's protocol version. + remote: u16, + }, + /// The remote peer's loaded ROM hash doesn't match this peer's — not the same game. + #[error("ROM hash mismatch — peers are not running the identical ROM")] + RomMismatch, + /// A desync: the two peers' hashed state diverged at a confirmed frame — a real + /// determinism-contract violation somewhere in the emulated core, not a network artifact. + #[error( + "desync detected at frame {frame}: local hash {local_hash:#x}, remote hash {remote_hash:#x}" + )] + Desync { + /// The frame the checksums were taken at. + frame: u32, + /// This peer's own computed hash for `frame`. + local_hash: u64, + /// The hash the remote peer reported for `frame`. + remote_hash: u64, + }, + /// A save-state failed to restore during a rollback — should be unreachable (the checkpoint + /// is always a blob this same session produced), surfaced rather than panicking regardless. + #[error("save-state error during rollback: {0}")] + SaveState(#[from] rustysnes_savestate::SaveStateError), +} + +/// What one [`RollbackSession::advance`] call did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdvanceOutcome { + /// A new frame was produced. + Advanced { + /// Whether producing this frame required rolling back and re-simulating first. + rolled_back: bool, + /// How many frames were re-simulated during this call's rollback (`0` if `rolled_back` + /// is `false`). + resimulated_frames: u32, + /// The frame number just produced. + frame: u32, + }, + /// No new frame was produced this call — either the remote peer's handshake hasn't arrived + /// yet, or the session is too far ahead of the last confirmed frame + /// (`SessionConfig::max_rollback_frames`) and is waiting for the remote peer to catch up. + Stalled, +} + +#[derive(Debug, Clone, Copy, Default)] +struct PlayerInput { + input: u16, + confirmed: bool, +} + +#[derive(Debug, Clone, Copy, Default)] +struct FrameInputs { + players: [PlayerInput; MAX_PLAYERS], + simulated: bool, +} + +/// A GGPO-style rollback netplay session driving a [`System`] against a remote peer over `T`. +pub struct RollbackSession { + config: SessionConfig, + transport: T, + rom_hash: [u8; 32], + handshaken: bool, + current_frame: u32, + last_confirmed_frame: Option, + history: Vec, + /// The last confirmed frame's full snapshot, taken just before a still-unconfirmed frame is + /// simulated — the restore point a rollback replays forward from. + checkpoint: Option<(u32, Vec)>, + /// Checksums we've computed locally but not yet compared (waiting on the remote's report for + /// that same frame, or vice versa) — `(frame, local_hash, local_fb_hash)`. + pending_local_checksums: VecDeque<(u32, u64, u64)>, + pending_remote_checksums: VecDeque<(u32, u64, u64)>, + /// The highest frame the remote peer has cumulatively acknowledged (via + /// [`NetMessage::InputAck`]) — everything after this, up to our own last confirmed local + /// frame, gets resent each [`Self::advance`] call so a dropped `Input` packet is never + /// permanently lost. + remote_ack_frame: Option, +} + +impl RollbackSession { + /// Start a new session. `rom_hash` is this peer's loaded ROM's SHA-256 — sent to the remote + /// peer during [`Self::send_handshake`] and compared against theirs before any input is + /// trusted. + /// + /// # Panics + /// Panics if `config.local_player as usize >= MAX_PLAYERS` — a programmer/config error + /// (this value comes from the frontend's own local configuration, never the network), caught + /// here with a clear message rather than surfacing later as an opaque index-out-of-bounds. + #[must_use] + pub const fn new(config: SessionConfig, transport: T, rom_hash: [u8; 32]) -> Self { + assert!( + (config.local_player as usize) < MAX_PLAYERS, + "SessionConfig::local_player must be < MAX_PLAYERS" + ); + Self { + config, + transport, + rom_hash, + handshaken: false, + current_frame: 0, + last_confirmed_frame: None, + history: Vec::new(), + checkpoint: None, + pending_local_checksums: VecDeque::new(), + pending_remote_checksums: VecDeque::new(), + remote_ack_frame: None, + } + } + + /// Send this peer's [`NetMessage::Sync`]. Call once before the first [`Self::advance`]; the + /// remote peer's `Sync` is verified internally the first time it arrives. + pub fn send_handshake(&mut self) { + self.transport.send(&NetMessage::Sync { + magic: SYNC_MAGIC, + version: PROTOCOL_VERSION, + rom_hash: self.rom_hash, + }); + } + + /// Whether the remote peer's `Sync` has arrived and matched. + #[must_use] + pub const fn is_handshaken(&self) -> bool { + self.handshaken + } + + fn ensure_frame(&mut self, frame: u32) { + let need = frame as usize + 1; + if self.history.len() < need { + self.history.resize(need, FrameInputs::default()); + } + } + + /// Whether a remote-reported frame index is within `MAX_TRUSTED_FRAME_AHEAD` of + /// `current_frame` — the bound that keeps an untrusted `Input`/`Checksum` message from + /// forcing an unbounded `history` allocation. + const fn frame_is_trustworthy(&self, frame: u32) -> bool { + frame <= self.current_frame.saturating_add(MAX_TRUSTED_FRAME_AHEAD) + } + + /// Record the local player's input for the upcoming frame (queued for the next + /// [`Self::advance`] call to consume). Applied `input_delay` frames ahead of `current_frame` + /// (see [`SessionConfig::input_delay`]); the frames in between are filled by the same + /// last-known-value prediction `predict_remotes` already uses for the remote player, + /// and corrected the same way (via `resync`) once this call's value lands. + pub fn add_local_input(&mut self, input: u16) { + let frame = self.current_frame.saturating_add(self.config.input_delay); + self.ensure_frame(frame); + let lp = self.config.local_player as usize; + self.history[frame as usize].players[lp] = PlayerInput { + input, + confirmed: true, + }; + } + + fn ingest(&mut self) -> Result, NetplayError> { + let mut earliest_mispredict = None; + for msg in self.transport.poll() { + match msg { + NetMessage::Sync { + magic, + version, + rom_hash, + } => { + if magic != SYNC_MAGIC { + return Err(NetplayError::BadMagic { + expected: SYNC_MAGIC, + got: magic, + }); + } + if version != PROTOCOL_VERSION { + return Err(NetplayError::VersionMismatch { + local: PROTOCOL_VERSION, + remote: version, + }); + } + if rom_hash != self.rom_hash { + return Err(NetplayError::RomMismatch); + } + self.handshaken = true; + } + NetMessage::Input { + player, + frame, + input, + } => { + // Nothing from the remote is trusted before its `Sync` has verified the ROM + // hash + protocol version, and an out-of-bound frame index is dropped rather + // than used to grow `history` (see `frame_is_trustworthy`'s doc). + if !self.handshaken || !self.frame_is_trustworthy(frame) { + continue; + } + let remote_player = usize::from(player); + if remote_player >= MAX_PLAYERS { + continue; + } + self.ensure_frame(frame); + let entry = &self.history[frame as usize]; + let slot = entry.players[remote_player]; + // A predicted-but-not-yet-confirmed slot always has `confirmed == false` (see + // `predict_remotes`, which only ever writes `.input`), so gating this on + // `slot.confirmed` — as an earlier draft did — meant it was NEVER true for a + // genuine misprediction, silently disabling misprediction detection (caught by + // review, not by the determinism tests: `resync` still ran on every + // `confirmation_advanced`, which is why the tests passed anyway — but the + // `AdvanceOutcome::rolled_back` flag this drives was always wrong). The correct + // signal is simply "did the value actually change from what was already + // simulated", independent of the slot's prior confirmed state. + let value_changed = slot.input != input; + let already_simulated_this_frame = + entry.simulated && frame < self.current_frame; + self.history[frame as usize].players[remote_player] = PlayerInput { + input, + confirmed: true, + }; + if value_changed && already_simulated_this_frame { + earliest_mispredict = Some(match earliest_mispredict { + Some(e) if e <= frame => e, + _ => frame, + }); + } + } + NetMessage::InputAck { frame } => { + if !self.handshaken { + continue; + } + self.remote_ack_frame = + Some(self.remote_ack_frame.map_or(frame, |f| f.max(frame))); + } + NetMessage::Quality { .. } => { + // Non-critical connection telemetry — this scoped port doesn't act on it, + // matching the ticket's stated acceptance criteria (rollback correctness + + // both transports working), not a production-tuned reliability layer. + } + NetMessage::Checksum { + frame, + hash, + fb_hash, + } => { + if !self.handshaken || !self.frame_is_trustworthy(frame) { + continue; + } + if self.pending_remote_checksums.len() >= MAX_PENDING_REMOTE_CHECKSUMS { + self.pending_remote_checksums.pop_front(); + } + self.pending_remote_checksums + .push_back((frame, hash, fb_hash)); + } + } + } + Ok(earliest_mispredict) + } + + fn recompute_confirmed(&mut self) { + let mut frame = self.last_confirmed_frame.map_or(0, |f| f + 1); + while (frame as usize) < self.history.len() + && self.history[frame as usize] + .players + .iter() + .all(|p| p.confirmed) + { + self.last_confirmed_frame = Some(frame); + frame += 1; + } + } + + /// Restore the checkpoint and re-simulate forward through every already-recorded frame up to + /// (but not including) `self.current_frame`, using the now-corrected input history. Returns + /// how many frames were re-simulated. + fn resync(&mut self, sys: &mut System) -> Result { + let Some((checkpoint_frame, blob)) = self.checkpoint.clone() else { + return Ok(0); + }; + sys.load_state(&blob)?; + let mut resimulated = 0u32; + for frame in checkpoint_frame..self.current_frame { + self.apply_and_run(sys, frame); + resimulated += 1; + self.settle_if_confirmed(sys, frame); + } + Ok(resimulated) + } + + fn apply_and_run(&mut self, sys: &mut System, frame: u32) { + self.ensure_frame(frame); + for (player, slot) in self.history[frame as usize].players.iter().enumerate() { + sys.bus.set_joypad(player, slot.input); + } + sys.run_frame(); + self.history[frame as usize].simulated = true; + } + + /// Fill in a prediction for any not-yet-confirmed player slot at `frame` — repeat that + /// player's last known input (classic GGPO prediction: "probably still holding the same + /// buttons"). + /// + /// `frame` is always `self.current_frame`, called in strictly increasing order (once per + /// non-stalled [`Self::advance`]), so every earlier frame has already gone through either + /// this same prediction or a real confirmation by the time we get here — `history[frame - + /// 1]` therefore already holds the correct last-known value by induction, an O(1) read + /// replacing what an earlier draft computed via an O(frame) backward scan on every call + /// (an O(n^2) cost over a long session for an unconfirmed/AFK player). `frame == 0` has no + /// prior frame and predicts the neutral `0` input, matching that scan's own base case. + fn predict_remotes(&mut self, frame: u32) { + self.ensure_frame(frame); + for player in 0..MAX_PLAYERS { + if self.history[frame as usize].players[player].confirmed { + continue; + } + let last_known = frame + .checked_sub(1) + .map_or(0, |prev| self.history[prev as usize].players[player].input); + self.history[frame as usize].players[player].input = last_known; + } + } + + /// Resend every one of our own player's confirmed inputs the remote peer hasn't acked yet + /// (`NetMessage::Input`'s own reliability layer — this transport-agnostic session, not any + /// particular [`Transport`] impl, is what makes the protocol reliable over a lossy link like + /// UDP or [`crate::transport::MemoryTransport`]'s synthetic packet loss). + fn resend_unacked_local_inputs(&mut self) { + let lp = self.config.local_player as usize; + let start = self.remote_ack_frame.map_or(0, |f| f.saturating_add(1)); + // `history.len()` is bounded by `ensure_frame`, which never grows it past a real frame + // count driven by `u32` frame numbers, so this never actually truncates. + #[allow(clippy::cast_possible_truncation)] + let history_len = self.history.len() as u32; + let end = self.current_frame.min(history_len); + for frame in start..end { + let slot = self.history[frame as usize].players[lp]; + if slot.confirmed { + self.transport.send(&NetMessage::Input { + player: self.config.local_player, + frame, + input: slot.input, + }); + } + } + } + + const fn should_stall(&self) -> bool { + let Some(confirmed) = self.last_confirmed_frame else { + return false; + }; + self.current_frame > confirmed + self.config.max_rollback_frames + } + + /// Called immediately after `frame` has been simulated, exactly when `frame` is known to be + /// fully confirmed (both players' real input, not a prediction) — the ONLY moment state is + /// guaranteed never to change again for that frame. Advances the checkpoint to `frame + 1` + /// (bounding future resimulation distance instead of always replaying from the session's + /// very first frame) and, at `checksum_interval` boundaries, emits a checksum computed from + /// this same settled state — one `sys.save_state()` call, reused for both the checkpoint and + /// the checksum hash (an earlier draft called it twice, once for each, needlessly doubling a + /// non-trivial serialization cost on every checksum-interval frame). + /// + /// This settled-only timing is load-bearing: computing/sending a checksum from "live" + /// state — which may still hold a prediction the peer hasn't corrected yet — races the + /// eventual correction and produces a false desync between two peers that are, in fact, + /// converging correctly. + fn settle_if_confirmed(&mut self, sys: &System, frame: u32) { + if self.last_confirmed_frame != Some(frame) { + return; + } + let blob = sys.save_state(); + if self.config.checksum_interval != 0 && frame.is_multiple_of(self.config.checksum_interval) + { + let fb_hash = hash_u16_slice(sys.bus.framebuffer()); + let hash = hash_bytes(&blob); + self.pending_local_checksums + .push_back((frame, hash, fb_hash)); + self.transport.send(&NetMessage::Checksum { + frame, + hash, + fb_hash, + }); + } + self.checkpoint = Some((frame + 1, blob)); + } + + /// Compare every locally-computed checksum against a matching remote report (matched by + /// frame number) once both sides exist. Returns [`NetplayError::Desync`] on the first + /// mismatch found. + fn compare_pending_checksums(&mut self) -> Result<(), NetplayError> { + let mut still_pending = VecDeque::with_capacity(self.pending_local_checksums.len()); + let mut desync = None; + for (frame, hash, fb_hash) in self.pending_local_checksums.drain(..) { + let Some(pos) = self + .pending_remote_checksums + .iter() + .position(|&(rf, ..)| rf == frame) + else { + still_pending.push_back((frame, hash, fb_hash)); + continue; + }; + let (_, remote_hash, _) = self.pending_remote_checksums.remove(pos).unwrap(); + if desync.is_none() && remote_hash != hash { + desync = Some((frame, hash, remote_hash)); + } + } + self.pending_local_checksums = still_pending; + match desync { + Some((frame, local_hash, remote_hash)) => Err(NetplayError::Desync { + frame, + local_hash, + remote_hash, + }), + None => Ok(()), + } + } + + /// Ingest everything received, roll back and re-simulate if a misprediction was just + /// corrected, then predict and run exactly one new frame (unless stalled, waiting for the + /// remote peer to confirm more input first). + /// + /// # Errors + /// Returns [`NetplayError`] on a failed handshake, a ROM mismatch, a confirmed-state desync, + /// or a save-state error during rollback. + pub fn advance(&mut self, sys: &mut System) -> Result { + let earliest_mispredict = self.ingest()?; + + if !self.handshaken { + // Nothing from an unverified peer (wrong ROM, wrong protocol version) may ever + // influence a simulated or presented frame — wait for `Sync` to land and match + // before running anything. A slow-to-arrive `Sync` costs a few stalled `advance()` + // calls, not correctness. + return Ok(AdvanceOutcome::Stalled); + } + + let confirmed_before = self.last_confirmed_frame; + self.recompute_confirmed(); + let confirmation_advanced = self.last_confirmed_frame != confirmed_before; + + if let Some(frame) = self.last_confirmed_frame { + self.transport.send(&NetMessage::InputAck { frame }); + } + + let mispredicted = earliest_mispredict.is_some_and(|m| m < self.current_frame); + let mut rolled_back = false; + let mut resimulated_frames = 0; + if (mispredicted || confirmation_advanced) && self.checkpoint.is_some() { + resimulated_frames = self.resync(sys)?; + rolled_back = mispredicted; + } + + self.compare_pending_checksums()?; + // Resend BEFORE the stall check: a stall means we're waiting on the remote's + // confirmation, and a dropped `Input` packet is exactly why that confirmation might + // never have arrived — resending here is the recovery path, not an afterthought. + self.resend_unacked_local_inputs(); + + if self.should_stall() { + return Ok(AdvanceOutcome::Stalled); + } + + let frame = self.current_frame; + self.ensure_frame(frame); + self.predict_remotes(frame); + + if self.checkpoint.is_none() { + self.checkpoint = Some((frame, sys.save_state())); + } + self.apply_and_run(sys, frame); + self.settle_if_confirmed(sys, frame); + + let lp = self.config.local_player as usize; + if self.history[frame as usize].players[lp].confirmed { + self.transport.send(&NetMessage::Input { + player: self.config.local_player, + frame, + input: self.history[frame as usize].players[lp].input, + }); + } + + self.current_frame += 1; + + Ok(AdvanceOutcome::Advanced { + rolled_back, + resimulated_frames, + frame, + }) + } + + /// The next frame this session will produce. + #[must_use] + pub const fn current_frame(&self) -> u32 { + self.current_frame + } + + /// The highest frame confirmed (every player's input known, not predicted) so far. + #[must_use] + pub const fn last_confirmed_frame(&self) -> Option { + self.last_confirmed_frame + } +} + +/// FNV-1a over a `u16` slice (the framebuffer's own native BGR555 element type) — matches this +/// project's existing determinism-proof hash style (`movie_determinism.rs`'s `hash_fb`). +fn hash_u16_slice(data: &[u16]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &v in data { + h ^= u64::from(v); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// FNV-1a over raw bytes — used to hash the full `save_state()` blob for [`NetMessage::Checksum`] +/// (a stronger desync signal than the framebuffer alone: it also catches an audio/timing-only +/// divergence that hasn't yet visibly affected the picture). +fn hash_bytes(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in data { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} diff --git a/crates/rustysnes-netplay/src/transport.rs b/crates/rustysnes-netplay/src/transport.rs new file mode 100644 index 00000000..4aa5a93d --- /dev/null +++ b/crates/rustysnes-netplay/src/transport.rs @@ -0,0 +1,194 @@ +//! The [`Transport`] abstraction plus [`MemoryTransport`]. +//! +//! [`crate::session::RollbackSession`] drives against [`Transport`]; [`MemoryTransport`] is a +//! deterministic, seeded-PRNG, in-process pipe used by the determinism test suite to prove +//! rollback re-simulation is bit-identical under synthetic latency/jitter/packet loss, without a +//! real network in the loop (`docs/adr/0004`: no OS randomness, no `std::time`, anywhere near +//! this). + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use crate::message::NetMessage; +use crate::rng::SplitMix64; + +/// Something [`crate::session::RollbackSession`] can send [`NetMessage`]s over and poll for +/// received ones. +/// +/// Implementations: [`MemoryTransport`] (tests), `UdpTransport` (native, `udp.rs`), +/// `WebRtcTransport` (wasm32, `webrtc.rs`). +pub trait Transport { + /// Send `msg` to the remote peer. Best-effort — a real transport may drop it; the session's + /// own resend logic (unacked inputs) is what makes the protocol reliable, not this layer. + fn send(&mut self, msg: &NetMessage); + /// Drain every message received since the last call, in receipt order. + fn poll(&mut self) -> Vec; +} + +/// A single-direction, capacity-unbounded queue of already-encoded-then-decoded messages, each +/// stamped with its scheduled arrival tick — [`MemoryTransport`]'s send side pushes here; the +/// paired peer's `poll` drains whatever has "arrived." Wrapping each message through +/// [`NetMessage::encode`]/[`decode`] (not just cloning the value) means a wire-format bug shows +/// up in the determinism tests too, not only in a real-transport test. +type Pipe = Rc>>; + +/// A deterministic in-process transport pairing two [`MemoryTransport`]s, with seeded synthetic +/// latency, jitter, and packet loss. +/// +/// The harness `tests/determinism.rs` drives two [`crate::session::RollbackSession`]s over this +/// to prove rollback re-simulation reproduces a reference (no-rollback) run bit-identically even +/// under adverse network conditions. +pub struct MemoryTransport { + outbox: Pipe, + inbox: Pipe, + rng: SplitMix64, + clock: u64, + base_latency_ticks: u64, + jitter_ticks: u64, + drop_chance: f64, +} + +impl MemoryTransport { + /// Build a connected pair of transports (`(peer_a, peer_b)`) sharing one seeded RNG stream + /// split into two independent generators, so the two directions' synthetic conditions are + /// reproducible but not identical to each other. + #[must_use] + pub fn pair( + seed: u64, + base_latency_ticks: u64, + jitter_ticks: u64, + drop_chance: f64, + ) -> (Self, Self) { + let a_to_b: Pipe = Rc::new(RefCell::new(VecDeque::new())); + let b_to_a: Pipe = Rc::new(RefCell::new(VecDeque::new())); + let mut seed_rng = SplitMix64::new(seed); + let a = Self { + outbox: Rc::clone(&a_to_b), + inbox: Rc::clone(&b_to_a), + rng: SplitMix64::new(seed_rng.next_u64()), + clock: 0, + base_latency_ticks, + jitter_ticks, + drop_chance, + }; + let b = Self { + outbox: b_to_a, + inbox: a_to_b, + rng: SplitMix64::new(seed_rng.next_u64()), + clock: 0, + base_latency_ticks, + jitter_ticks, + drop_chance, + }; + (a, b) + } + + /// A pristine, zero-latency, zero-loss pair — for tests isolating rollback logic itself from + /// network-condition effects. + #[must_use] + pub fn ideal_pair() -> (Self, Self) { + Self::pair(0, 0, 0, 0.0) + } +} + +impl Transport for MemoryTransport { + fn send(&mut self, msg: &NetMessage) { + // Round-trip through the wire format so a real encode/decode bug surfaces here too. + let bytes = msg.encode(); + let Ok(decoded) = NetMessage::decode(&bytes) else { + return; + }; + if self.rng.chance(self.drop_chance) { + return; + } + let jitter = if self.jitter_ticks == 0 { + 0 + } else { + self.rng.next_u64() % (self.jitter_ticks + 1) + }; + let arrival_tick = self.clock + self.base_latency_ticks + jitter; + self.outbox.borrow_mut().push_back((arrival_tick, decoded)); + } + + fn poll(&mut self) -> Vec { + self.clock += 1; + // Jitter means entries are NOT necessarily queued in arrival-tick order (a later-sent + // packet can draw less jitter than an earlier one still ahead of it in the queue), so + // this can't stop at the first `tick > clock` entry — every entry needs checking. + let mut inbox = self.inbox.borrow_mut(); + let mut ready = Vec::new(); + let mut still_pending = VecDeque::with_capacity(inbox.len()); + for (tick, msg) in inbox.drain(..) { + if tick <= self.clock { + ready.push(msg); + } else { + still_pending.push_back((tick, msg)); + } + } + *inbox = still_pending; + ready + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ideal_pair_delivers_immediately() { + let (mut a, mut b) = MemoryTransport::ideal_pair(); + a.send(&NetMessage::InputAck { frame: 5 }); + let received = b.poll(); + assert_eq!(received, vec![NetMessage::InputAck { frame: 5 }]); + } + + #[test] + fn same_seed_produces_identical_delivery_pattern() { + let run = || { + let (mut a, mut b) = MemoryTransport::pair(99, 2, 3, 0.3); + let mut delivered_frames = Vec::new(); + for f in 0..50u32 { + a.send(&NetMessage::InputAck { frame: f }); + for msg in b.poll() { + if let NetMessage::InputAck { frame } = msg { + delivered_frames.push(frame); + } + } + } + delivered_frames + }; + assert_eq!(run(), run()); + } + + #[test] + fn drop_chance_one_delivers_nothing() { + let (mut a, mut b) = MemoryTransport::pair(1, 0, 0, 1.0); + for f in 0..20u32 { + a.send(&NetMessage::InputAck { frame: f }); + } + let mut got_any = false; + for _ in 0..20 { + if !b.poll().is_empty() { + got_any = true; + } + } + assert!(!got_any, "drop_chance = 1.0 must drop every packet"); + } + + #[test] + fn jitter_can_deliver_out_of_order_and_poll_still_finds_it() { + // A high jitter ceiling makes an out-of-order arrival likely; this must not get stuck + // behind an earlier-queued, still-pending entry (the bug the plain "stop at first + // tick > clock" drain would have had). + let (mut a, mut b) = MemoryTransport::pair(123, 5, 20, 0.0); + for f in 0..10u32 { + a.send(&NetMessage::InputAck { frame: f }); + } + let mut delivered = Vec::new(); + for _ in 0..60 { + delivered.extend(b.poll()); + } + assert_eq!(delivered.len(), 10, "every sent packet eventually arrives"); + } +} diff --git a/crates/rustysnes-netplay/src/udp.rs b/crates/rustysnes-netplay/src/udp.rs new file mode 100644 index 00000000..ce32c8bd --- /dev/null +++ b/crates/rustysnes-netplay/src/udp.rs @@ -0,0 +1,138 @@ +//! The native UDP [`Transport`] — a real, tested `std::net::UdpSocket` connected to exactly one +//! remote peer (2-player point-to-point, matching [`crate::session::MAX_PLAYERS`]). +//! +//! Connection establishment (learning the peer's `SocketAddr`) is the caller's concern — this +//! type takes an already-known address, not a matchmaking/signaling layer of its own (out of +//! this ticket's scope; see the module doc on `crate` for what's ported vs. not). + +use std::net::{SocketAddr, UdpSocket}; + +use crate::message::NetMessage; +use crate::transport::Transport; + +/// A point-to-point UDP transport. Non-blocking: [`Transport::poll`] drains every datagram +/// currently available and returns immediately rather than blocking the caller's frame loop. +pub struct UdpTransport { + socket: UdpSocket, + peer: SocketAddr, + /// Scratch receive buffer — a [`NetMessage`] is small (the largest variant, `Sync`, is + /// magic + version + a 32-byte hash = 39 bytes plus the tag byte); comfortably under any + /// realistic MTU, so a single `recv_from` always holds a whole datagram. + recv_buf: [u8; 512], +} + +impl UdpTransport { + /// Bind a local UDP socket at `local_addr` and connect it to `peer` (in the `UdpSocket` + /// sense: `send`/`recv` — not `send_to`/`recv_from` — implicitly target/accept only this + /// address, the OS-level "connected UDP socket" pattern). Non-blocking, so [`Transport::poll`] + /// never stalls a frame loop waiting on a packet that hasn't arrived yet. + /// + /// # Errors + /// Returns the underlying `std::io::Error` if the socket can't be bound, connected, or set + /// non-blocking. + pub fn connect(local_addr: SocketAddr, peer: SocketAddr) -> std::io::Result { + let socket = UdpSocket::bind(local_addr)?; + socket.connect(peer)?; + socket.set_nonblocking(true)?; + Ok(Self { + socket, + peer, + recv_buf: [0; 512], + }) + } + + /// The peer address this transport is connected to. + #[must_use] + pub const fn peer(&self) -> SocketAddr { + self.peer + } +} + +impl Transport for UdpTransport { + fn send(&mut self, msg: &NetMessage) { + // Best-effort by design (UDP; the session's own ack/resend logic is the reliability + // layer) — a send error here (e.g. a transient ENOBUFS) is not a protocol violation, + // just a dropped packet like any other. + let _ = self.socket.send(&msg.encode()); + } + + fn poll(&mut self) -> Vec { + let mut received = Vec::new(); + loop { + match self.socket.recv(&mut self.recv_buf) { + Ok(n) => { + if let Ok(msg) = NetMessage::decode(&self.recv_buf[..n]) { + received.push(msg); + } + // A malformed/foreign datagram is silently dropped (untrusted network input, + // `master-core` module 60) rather than treated as a protocol error — it may + // simply not be from this peer at all. + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, // Any other transient OS error: stop this poll, try again next call. + } + } + received + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_round_trip() { + let a_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let b_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + // Bind both ends first to learn their OS-assigned ports, then connect each to the + // other's real address. + let a_socket = UdpSocket::bind(a_addr).unwrap(); + let b_socket = UdpSocket::bind(b_addr).unwrap(); + let a_real = a_socket.local_addr().unwrap(); + let b_real = b_socket.local_addr().unwrap(); + drop(a_socket); + drop(b_socket); + + let mut a = UdpTransport::connect(a_real, b_real).unwrap(); + let mut b = UdpTransport::connect(b_real, a_real).unwrap(); + + a.send(&NetMessage::Input { + player: 0, + frame: 7, + input: 0x1234, + }); + + // A real OS-level round trip over loopback; poll a few times to absorb any scheduling + // delay rather than assuming the datagram is instantly visible. + let mut received = Vec::new(); + for _ in 0..50 { + received.extend(b.poll()); + if !received.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!( + received, + vec![NetMessage::Input { + player: 0, + frame: 7, + input: 0x1234, + }] + ); + } + + #[test] + fn poll_with_nothing_sent_returns_empty() { + let a_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let b_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let a_socket = UdpSocket::bind(a_addr).unwrap(); + let b_socket = UdpSocket::bind(b_addr).unwrap(); + let a_real = a_socket.local_addr().unwrap(); + let b_real = b_socket.local_addr().unwrap(); + drop(a_socket); + drop(b_socket); + let mut a = UdpTransport::connect(a_real, b_real).unwrap(); + assert!(a.poll().is_empty()); + } +} diff --git a/crates/rustysnes-netplay/src/webrtc.rs b/crates/rustysnes-netplay/src/webrtc.rs new file mode 100644 index 00000000..683799a9 --- /dev/null +++ b/crates/rustysnes-netplay/src/webrtc.rs @@ -0,0 +1,76 @@ +//! The browser WebRTC [`Transport`], `wasm32` only — wraps an already-open +//! [`web_sys::RtcDataChannel`]. +//! +//! SDP offer/answer negotiation (`RtcPeerConnection`, async by nature) is deliberately NOT here: +//! it's connection-establishment/signaling glue, which is frontend-owned in this project +//! (matching `wasm_audio.rs`'s own crate-boundary precedent — this crate stays pure protocol/ +//! session logic, the frontend owns orchestration). The frontend constructs the +//! `RtcPeerConnection` + `RtcDataChannel`, waits for the channel's `open` event, then hands the +//! open channel to [`WebRtcTransport::new`]. + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use web_sys::{MessageEvent, RtcDataChannel, RtcDataChannelType}; + +use crate::message::NetMessage; +use crate::transport::Transport; + +/// A [`Transport`] over an already-open WebRTC data channel. +pub struct WebRtcTransport { + channel: RtcDataChannel, + incoming: Rc>>, + // Kept alive for the transport's lifetime — dropping it would detach the JS callback and + // silently stop delivering messages (the classic wasm-bindgen `Closure` footgun). + _on_message: Closure, +} + +impl WebRtcTransport { + /// Wrap `channel` (assumed already open) as a [`Transport`]. Installs the message handler + /// that decodes each incoming binary frame as a [`NetMessage`], silently dropping anything + /// that isn't a binary `ArrayBuffer` or doesn't decode (untrusted input from the wire). + #[must_use] + pub fn new(channel: RtcDataChannel) -> Self { + channel.set_binary_type(RtcDataChannelType::Arraybuffer); + let incoming: Rc>> = Rc::new(RefCell::new(VecDeque::new())); + let incoming_for_closure = Rc::clone(&incoming); + let on_message = Closure::wrap(Box::new(move |event: MessageEvent| { + let Ok(buf) = event.data().dyn_into::() else { + return; + }; + let bytes = js_sys::Uint8Array::new(&buf).to_vec(); + if let Ok(msg) = NetMessage::decode(&bytes) { + incoming_for_closure.borrow_mut().push_back(msg); + } + }) as Box); + channel.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + Self { + channel, + incoming, + _on_message: on_message, + } + } + + /// The wrapped channel's current `readyState` (`"connecting"`, `"open"`, `"closing"`, or + /// `"closed"`) — the frontend uses this to detect a dropped peer. + #[must_use] + pub fn ready_state(&self) -> web_sys::RtcDataChannelState { + self.channel.ready_state() + } +} + +impl Transport for WebRtcTransport { + fn send(&mut self, msg: &NetMessage) { + // Best-effort by design, same as the native UDP transport — a send failure here (the + // channel closed mid-flight, the browser's send buffer is full) is not a protocol + // violation, just a dropped message like any other. + let _ = self.channel.send_with_u8_array(&msg.encode()); + } + + fn poll(&mut self) -> Vec { + self.incoming.borrow_mut().drain(..).collect() + } +} diff --git a/crates/rustysnes-netplay/tests/determinism.rs b/crates/rustysnes-netplay/tests/determinism.rs new file mode 100644 index 00000000..130e14c2 --- /dev/null +++ b/crates/rustysnes-netplay/tests/determinism.rs @@ -0,0 +1,311 @@ +//! `RollbackSession`'s determinism proof — T-82-002's core acceptance criterion: rollback +//! re-simulation must be bit-identical, mirroring `movie_determinism.rs`'s pattern (a real +//! committed ROM, VARYING synthetic input, a fresh reference run with no rollback machinery in +//! the loop at all, framebuffer-hash comparison per frame). +//! +//! Both peer sessions run over [`MemoryTransport`] — a deterministic, seeded-PRNG in-process +//! pipe, never a real socket, so this test is itself fully reproducible (`docs/adr/0004`). + +use std::path::PathBuf; + +use rustysnes_core::System; +use rustysnes_core::cart::Cart; +use rustysnes_netplay::session::NetplayError; +use rustysnes_netplay::transport::MemoryTransport; +use rustysnes_netplay::{RollbackSession, SessionConfig}; + +const SEED: u64 = 777; +const FRAME_COUNT: u32 = 60; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn rom_bytes() -> Vec { + let path = workspace_root().join("tests/roms/gilyon/cputest/cputest-basic.sfc"); + std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +fn fresh_system(rom: &[u8]) -> System { + let cart = Cart::from_rom(rom).unwrap_or_else(|e| panic!("parse cart: {e:?}")); + let mut sys = System::new(SEED); + sys.bus.cart = Some(cart); + sys +} + +/// Not a cryptographic hash — a test-scoped stand-in for a ROM-identity value, just needs to be +/// deterministic and equal for both peers loading the same bytes (the actual +/// [`NetMessage::Sync`](rustysnes_netplay::NetMessage::Sync) handshake only compares this value +/// for equality; it never needs to resist forgery in this test). +fn fnv_rom_hash(rom: &[u8]) -> [u8; 32] { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in rom { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + let mut out = [0u8; 32]; + out[..8].copy_from_slice(&h.to_le_bytes()); + out +} + +fn hash_fb(fb: &[u16]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &p in fb { + h ^= u64::from(p); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// A varying (not constant) synthetic input pattern per frame, per player — a session that never +/// actually exercises input divergence between the two players would pass trivially even with a +/// broken rollback implementation (mirrors `movie_determinism.rs`'s own rationale). +const fn input_for_frame(frame: u32) -> (u16, u16) { + const P1: [u16; 4] = [0x8000, 0x0000, 0x0808, 0xFFF0]; + const P2: [u16; 4] = [0x0000, 0x4000, 0x0101, 0x00F0]; + ( + P1[frame as usize % P1.len()], + P2[(frame as usize + 2) % P2.len()], + ) +} + +/// Drive both sessions, interleaved, until each has produced `target_frame`. Interleaving (not +/// draining one session fully before touching the other) is load-bearing: each session's own +/// input for a frame is only sent to its peer from inside that same `advance()` call, so a +/// session run to completion in isolation would eventually stall waiting on input its peer +/// hasn't been given the chance to send yet. +fn drive_both_to_frame( + session_a: &mut RollbackSession, + sys_a: &mut System, + session_b: &mut RollbackSession, + sys_b: &mut System, + target_frame: u32, +) { + let mut guard = 0u32; + while session_a.current_frame() <= target_frame || session_b.current_frame() <= target_frame { + if session_a.current_frame() <= target_frame { + session_a.advance(sys_a).expect("session A advance"); + } + if session_b.current_frame() <= target_frame { + session_b.advance(sys_b).expect("session B advance"); + } + guard += 1; + assert!( + guard < 1_000_000, + "sessions failed to reach frame {target_frame} (stalled or deadlocked)" + ); + } +} + +/// Run a full paired-session replay against `transport_pair` and return each side's per-frame +/// framebuffer hash sequence. +fn run_paired_sessions( + rom: &[u8], + (transport_a, transport_b): (MemoryTransport, MemoryTransport), +) -> (Vec, Vec) { + let hash = fnv_rom_hash(rom); + let mut sys_a = fresh_system(rom); + let mut sys_b = fresh_system(rom); + let mut session_a = RollbackSession::new( + SessionConfig { + local_player: 0, + ..SessionConfig::default() + }, + transport_a, + hash, + ); + let mut session_b = RollbackSession::new( + SessionConfig { + local_player: 1, + ..SessionConfig::default() + }, + transport_b, + hash, + ); + session_a.send_handshake(); + session_b.send_handshake(); + + let mut fb_a = Vec::new(); + let mut fb_b = Vec::new(); + for frame in 0..FRAME_COUNT { + let (p1, p2) = input_for_frame(frame); + session_a.add_local_input(p1); + session_b.add_local_input(p2); + drive_both_to_frame( + &mut session_a, + &mut sys_a, + &mut session_b, + &mut sys_b, + frame, + ); + fb_a.push(hash_fb(sys_a.bus.framebuffer())); + fb_b.push(hash_fb(sys_b.bus.framebuffer())); + } + (fb_a, fb_b) +} + +fn reference_run(rom: &[u8]) -> Vec { + let mut sys = fresh_system(rom); + let mut fb = Vec::new(); + for frame in 0..FRAME_COUNT { + let (p1, p2) = input_for_frame(frame); + sys.bus.set_joypad(0, p1); + sys.bus.set_joypad(1, p2); + sys.run_frame(); + fb.push(hash_fb(sys.bus.framebuffer())); + } + fb +} + +/// Like [`reference_run`], but each player's input takes effect `delay` frames later than +/// requested (neutral `0` for the frames before it first applies) — the same shift +/// `SessionConfig::input_delay` produces via `RollbackSession::add_local_input`. +fn delayed_reference_run(rom: &[u8], delay: u32) -> Vec { + let mut sys = fresh_system(rom); + let mut fb = Vec::new(); + for frame in 0..FRAME_COUNT { + let (p1, p2) = frame.checked_sub(delay).map_or((0, 0), input_for_frame); + sys.bus.set_joypad(0, p1); + sys.bus.set_joypad(1, p2); + sys.run_frame(); + fb.push(hash_fb(sys.bus.framebuffer())); + } + fb +} + +#[test] +fn rollback_matches_reference_under_ideal_conditions() { + let rom = rom_bytes(); + let reference = reference_run(&rom); + let (fb_a, fb_b) = run_paired_sessions(&rom, MemoryTransport::ideal_pair()); + + for (i, (a, r)) in fb_a.iter().zip(reference.iter()).enumerate() { + assert_eq!( + a, r, + "session A framebuffer diverged from reference at frame {i}" + ); + } + for (i, (b, r)) in fb_b.iter().zip(reference.iter()).enumerate() { + assert_eq!( + b, r, + "session B framebuffer diverged from reference at frame {i}" + ); + } +} + +#[test] +fn rollback_matches_reference_under_latency_jitter_and_packet_loss() { + let rom = rom_bytes(); + let reference = reference_run(&rom); + // Real adverse conditions: base latency, jitter on top, and a real (non-zero) drop chance — + // this is what actually exercises misprediction + rollback + resend, not just the + // once-per-frame "own input not seen yet" case the ideal-pair test already covers. + let (fb_a, fb_b) = run_paired_sessions(&rom, MemoryTransport::pair(2026, 3, 4, 0.1)); + + for (i, (a, r)) in fb_a.iter().zip(reference.iter()).enumerate() { + assert_eq!( + a, r, + "session A framebuffer diverged from reference at frame {i} under adverse conditions" + ); + } + for (i, (b, r)) in fb_b.iter().zip(reference.iter()).enumerate() { + assert_eq!( + b, r, + "session B framebuffer diverged from reference at frame {i} under adverse conditions" + ); + } +} + +#[test] +fn rom_hash_mismatch_is_rejected_before_any_frame_runs() { + let rom = rom_bytes(); + let (transport_a, transport_b) = MemoryTransport::ideal_pair(); + let mut sys_a = fresh_system(&rom); + let mut session_a = RollbackSession::new(SessionConfig::default(), transport_a, [0xAA; 32]); + let mut session_b = RollbackSession::new( + SessionConfig { + local_player: 1, + ..SessionConfig::default() + }, + transport_b, + [0xBB; 32], // deliberately different from session_a's + ); + session_a.send_handshake(); + session_b.send_handshake(); + + // session_a's ingest sees session_b's Sync (rom_hash [0xBB; 32]) and must reject it against + // its own [0xAA; 32]. + let mut saw_mismatch = false; + for _ in 0..10 { + match session_a.advance(&mut sys_a) { + Err(NetplayError::RomMismatch) => { + saw_mismatch = true; + break; + } + Err(other) => panic!("expected RomMismatch, got {other}"), + Ok(_) => {} + } + } + assert!( + saw_mismatch, + "a genuine ROM-hash mismatch must be rejected, not silently ignored" + ); +} + +/// `SessionConfig::input_delay` (previously documented but never read — caught in review) +/// queues each player's own input `input_delay` frames ahead of when it's requested; the frames +/// in between are filled by the same last-known-value prediction the remote player already +/// gets, corrected by the same rollback machinery once the delayed value lands. Proves this +/// against a delay-aware reference under ideal conditions. +#[test] +fn input_delay_matches_a_delay_aware_reference() { + const DELAY: u32 = 2; + let rom = rom_bytes(); + let reference = delayed_reference_run(&rom, DELAY); + let hash = fnv_rom_hash(&rom); + let mut sys_a = fresh_system(&rom); + let mut sys_b = fresh_system(&rom); + let (transport_a, transport_b) = MemoryTransport::ideal_pair(); + let mut session_a = RollbackSession::new( + SessionConfig { + local_player: 0, + input_delay: DELAY, + ..SessionConfig::default() + }, + transport_a, + hash, + ); + let mut session_b = RollbackSession::new( + SessionConfig { + local_player: 1, + input_delay: DELAY, + ..SessionConfig::default() + }, + transport_b, + hash, + ); + session_a.send_handshake(); + session_b.send_handshake(); + + let mut fb_a = Vec::new(); + for frame in 0..FRAME_COUNT { + let (p1, p2) = input_for_frame(frame); + session_a.add_local_input(p1); + session_b.add_local_input(p2); + drive_both_to_frame( + &mut session_a, + &mut sys_a, + &mut session_b, + &mut sys_b, + frame, + ); + fb_a.push(hash_fb(sys_a.bus.framebuffer())); + } + + for (i, (a, r)) in fb_a.iter().zip(reference.iter()).enumerate() { + assert_eq!( + a, r, + "input_delay session diverged from the delay-aware reference at frame {i}" + ); + } +} diff --git a/docs/STATUS.md b/docs/STATUS.md index a9e64b1e..2d5a7e73 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -87,7 +87,7 @@ added to this table; hi-res color-math precision itself closed in `v0.7.0 "Resol | `rustysnes-cart` | LoROM/HiROM/ExHiROM + coprocessors | **Phase 2 base map modes + Phase 4 coprocessors: chipset-byte detection, the shared µPD77C25/µPD96050 LLE engine + DSP-1 board (real DSP-1 games with user-supplied firmware), and the Super FX/GSU — full Argonaut RISC core (`coproc::gsu`) + `SuperFxBoard` (`coproc::superfx`), host-synced on the Go flag, boots the Krom GSU suite (`superfx_oncart`). SA-1 next** | | `rustysnes-core` | Bus + master-clock scheduler + DMA/HDMA | **Phase 2 — master-clock lockstep (6/8/12 access map), full memory decode, CPU regs + mul/div, GP-DMA + HDMA, NMI/HV-IRQ** | | `rustysnes-frontend` | egui shell + audio ring + pacing | **Phase 5 — PLAYABLE: native winit 0.30 + wgpu 29 + egui 0.35 + cpal shell boots real commercial ROMs with picture, sound, and control. Video: PPU BGR555→RGBA8 decode, aspect-correct (4:3) sub-rect letterbox blit. Audio: S-DSP 32 kHz FIFO → producer-side linear resampler (DRC-paced) → lock-free ring → cpal stereo stream. Input: keyboard + gilrs gamepad → `bus.set_joypad`. ROM load (+ coprocessor-firmware + `.srm` SRAM auto-load), Reset / Power-Cycle / Pause wired. wasm32 target compiles (winit/wgpu canvas path is a bootstrap scaffold). Proven by the `playable_smoke` headless gate (Super Mario World: 256×224 structured frame + 63,975 non-silent samples over 120 frames) + an xvfb launch run. Save-states landed (`docs/adr/0006`: a versioned `System::save_state()`/`load_state()` envelope across every `Board` + `Cpu`/`Ppu`/`Apu`/`Bus`, proven by a round-trip determinism test), wired to a quick-save menu slot; rewind (a bounded ring buffer of full snapshots, `crate::rewind::RewindBuffer`) and run-ahead (N-frame peek-and-discard, `crate::rewind::step_with_run_ahead`) landed in `v0.3.0 "Continuum"` — both config-driven and off by default (capacity/frames `0`), proven by tests that hand-assemble a tiny 65C816 program for a real per-frame state signal. Pacing/present fixes: wall-clock fixed-timestep drive (emulation tracks the region rate, not the display refresh — fixes the ~2–3× over-speed on high-refresh panels), a real smoothed FPS meter (was hardcoded `0.0`), and a live present-mode reconfigure on the Settings → Video toggle. `v0.8.0` T-81-003: a Tools → Cheats… window (Game Genie / Pro Action Replay, decode in `rustysnes_core::cheat`, applied via a `Bus::read24` CPU-read intercept — not a WRAM poke, since real codes overwhelmingly target cartridge ROM) behind the `cheats` flag, native + `wasm32`** | -| `rustysnes-netplay` | rollback netplay | not started | +| `rustysnes-netplay` | rollback netplay | **T-82-002 — GGPO-style 2-player rollback netcode, ported from RustyNES's `RollbackSession` shape (scoped to 2 players — the SNES core has no multitap emulation). Bit-identical resimulation proven under both ideal and adverse (latency/jitter/10% loss) conditions (`tests/determinism.rs`); a real UDP transport (OS-level loopback tested) + a wasm32-clippy-verified WebRTC transport. Frontend wiring (Tools → Netplay…, its own drive loop independent of `emu-thread`) behind the `netplay` flag is native/UDP only — the browser SDP-negotiation UI is an honestly deferred, separate scope** | | `rustysnes-cheevos` | RetroAchievements (opt-in FFI) | not started | | `rustysnes-script` | Lua scripting / TAS API | **T-81-002 — sandboxed `mlua` 5.4 scripting (WRAM read/write + per-frame callback, runaway-loop instruction budget, `io`/`os`/`require`/`debug` denied) + a `rustysnes_core::movie` TAS format (deterministic input log, power-on or embedded-save-state start, replay-verified bit-identical vs a real committed ROM); behind the `scripting` flag, wired into a Tools menu, native-only** | | `rustysnes-test-harness` | golden-log + JSON-oracle + screenshot baseline | **implemented and in active use** — the accuracy oracle (65816/SPC700 SingleStepTests runners, gilyon/undisbeliever/blargg golden-log gates, the `*_oncart` per-coprocessor commercial-ROM validation harnesses, and `commercial_screenshots.rs` the boot-screenshot generator behind `test-roms`/`commercial-roms`) | diff --git a/docs/frontend.md b/docs/frontend.md index e5de6a4d..197bdcd1 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -216,6 +216,30 @@ perturb a run it doesn't own. `rustysnes-script` is an optional native-only depe (`dep:rustysnes-script`, gated out of the wasm32 dependency graph entirely); with `scripting` off, none of this compiles in and the default build is unaffected. +## Rollback netplay (`v0.9.0 "Community"`, T-82-002) + +A Tools → Netplay… window (native/UDP only, `#[cfg(all(feature = "netplay", not(target_arch = +"wasm32")))]`) takes a local `host:port`, a peer `host:port`, and a P1/P2 slot, and dispatches +`MenuAction::ConnectNetplay` (the actual socket bind/connect happens in `App::dispatch_actions`, +never inside the egui pass). `rustysnes-netplay::RollbackSession` — ported from RustyNES's own +`RollbackSession` shape, scoped to 2 players since the SNES core has no multitap emulation — +drives `rustysnes_core::System` directly, not `EmuCore`: `Active::render`'s per-frame loop checks +`NetplayState::is_connected()` first and, when true, calls `NetplayState::drive` (which calls +`RollbackSession::advance` on the `System`, then `EmuCore::present_current_frame` to decode the +framebuffer/drain audio from whatever the session settled on) via an early `continue` that skips +the entire single-player `apply_frame_input`/cheats/rewind/script/`run_frame` path for that +iteration — netplay's own drive loop, verified independent of `emu-thread` (`docs/adr/0004`'s +determinism contract requires exactly one thing ever drive a given `System`). A dropped +`NetMessage::Input` packet is resent every `advance()` call until the remote peer's cumulative +ack catches up. **Known limitation, shared with rollback netplay generally**: a rollback event +may audibly glitch (audio already sent to the output device during a since-corrected +misprediction can't be "unplayed") even though video always reflects the corrected state +cleanly. `rustysnes-netplay` is an optional native-only dependency (`dep:rustysnes-netplay`, +gated out of the wasm32 dependency graph); with `netplay` off, none of this compiles in and the +default build is unaffected. The crate's `WebRtcTransport` (wasm32) is itself complete and +clippy-verified against the real `web_sys` API, but frontend SDP-negotiation UI to actually use +it in-browser is a separate, not-yet-landed scope. + ## Open questions - ~~Whether the second-CPU (SA-1 / Super FX) state warrants its own debugger panel from day one diff --git a/to-dos/phase-8-reach/sprint-2-community.md b/to-dos/phase-8-reach/sprint-2-community.md index 028dcaaa..f7e8a1ea 100644 --- a/to-dos/phase-8-reach/sprint-2-community.md +++ b/to-dos/phase-8-reach/sprint-2-community.md @@ -47,10 +47,30 @@ single-player pacer — a netplay session uses its own rollback-aware loop, neve **Acceptance criteria:** -- [ ] Rollback re-simulation is bit-identical (relies on `docs/adr/0004`). -- [ ] Native (UDP) + browser (WebRTC) transports work. -- [ ] Netplay sessions use their own drive loop, verified independent of `emu-thread`. -- [ ] With `netplay` off, the build is byte-identical (CI gate). +- [x] Rollback re-simulation is bit-identical (relies on `docs/adr/0004`) — proven by + `crates/rustysnes-netplay/tests/determinism.rs`: two `RollbackSession`s driven over a + seeded, deterministic `MemoryTransport` (including one run under real synthetic latency, + jitter, and 10% packet loss) both match a fresh no-rollback reference run's per-frame + framebuffer hash, exactly. +- [x] Native (UDP) + browser (WebRTC) transports work — `udp.rs`'s `UdpTransport` is a real + `std::net::UdpSocket`, proven by a genuine OS-level loopback round-trip test (not just + unit-level). `webrtc.rs`'s `WebRtcTransport` wraps a `web_sys::RtcDataChannel` and is + wasm32-clippy-verified against the real API. **Honest scope note:** the frontend UI wiring + (`crates/rustysnes-frontend/src/netplay.rs`) is native/UDP only for this pass — the + browser-side SDP offer/answer/ICE negotiation glue needed to actually establish a + `RtcDataChannel` is a genuinely separate scope of async signaling work, not half-wired. + No obsolete/unused netplay code skeletons existed anywhere in the codebase to remove + (`rustysnes-netplay`'s `src/lib.rs` was a bare 1-line stub). +- [x] Netplay sessions use their own drive loop, verified independent of `emu-thread` — + `NetplayState::drive` calls `RollbackSession::advance` directly on `System`, dispatched + from `Active::render`'s per-frame loop via an early `continue` that skips the entire + single-player `apply_frame_input`/cheats/rewind/script/`run_frame` path for that iteration + whenever a session is connected (`app.rs`); `emu-thread` is untouched by any of this. +- [x] With `netplay` off, the build is byte-identical (CI gate) — the frontend's netplay + module/UI/`Active` field are all `#[cfg(all(feature = "netplay", not(target_arch = + "wasm32")))]`-gated (the decode-adjacent crate itself, `rustysnes-netplay`, is always a + workspace member — same precedent as `rustysnes-script`/`rustysnes_core::cheat`); full + default-feature workspace build/test/clippy/fmt/doc verified unaffected. **Dependencies:** T-82-001 (go/no-go on the save-state design); T-51-003; T-31-004 (determinism exercised)