Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions crates/rustysnes-frontend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions crates/rustysnes-frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CheatEntry>,
/// 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
Expand Down Expand Up @@ -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()),
Expand All @@ -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(),
});
}

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions crates/rustysnes-frontend/src/emu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions crates/rustysnes-frontend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading