This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
gopher64-netplay/src/netplay.rs
Claude 79e88dc821
Some checks failed
Rust / build-linux (linux-x86_64, X64, ubuntu-24.04, x86_64-unknown-linux-gnu) (pull_request) Failing after 2s
Rust / build-android (29.0.14206865, ubuntu-24.04, 33) (pull_request) Failing after 1s
Rust-lint / lint-linux (pull_request) Failing after 1s
Rust / build-linux (linux-aarch64, ARM64, ubuntu-24.04-arm, aarch64-unknown-linux-gnu) (pull_request) Has been cancelled
Rust / build-windows (windows-aarch64, woa64, windows-11-arm, aarch64-pc-windows-msvc) (pull_request) Has been cancelled
Rust / build-windows (windows-x86_64, win64, windows-2025, x86_64-pc-windows-msvc) (pull_request) Has been cancelled
Rust / build-macos (macos-aarch64, ARM64, aarch64-apple-darwin) (pull_request) Has been cancelled
Rust / Collect release artifacts (pull_request) Has been cancelled
tests: fix resume mock session registration and RDRAM sizing
mock_netplay built a one-player session for a local handle of 1,
which the vendored ggrs fork rejects; register every handle (the
local player at player_number, dummy Remote PeerIds elsewhere).
The resume tests loaded saved states into fresh Device::new(false)
instances whose RDRAM backing store is empty, tripping
copy_from_slice in apply_savestate; pre-size the RDRAM like a real
emulated device, matching the saved session's RDRAM.
2026-08-25 16:21:12 -05:00

1625 lines
58 KiB
Rust

use crate::device;
use crate::savestates;
use crate::ui;
use sha2::digest::Digest;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RtcIceServerConfig {
pub urls: Vec<String>,
pub username: Option<String>,
pub credential: Option<String>,
}
pub struct GgrsConfig;
impl ggrs::Config for GgrsConfig {
type Input = ui::input::InputData;
type InputPredictor = ggrs::PredictRepeatLast;
type State = i32;
type Address = matchbox_socket::PeerId;
}
pub struct MatchboxChannel {
channel: matchbox_socket::WebRtcChannel,
}
impl ggrs::NonBlockingSocket<matchbox_socket::PeerId> for MatchboxChannel {
fn send_to(&mut self, msg: &ggrs::Message, addr: &matchbox_socket::PeerId) {
let encoded = postcard::to_stdvec(msg).expect("serialization failed");
let _ = self.channel.try_send(encoded.into(), *addr);
}
fn receive_all_messages(&mut self) -> Vec<(matchbox_socket::PeerId, ggrs::Message)> {
self.channel
.receive()
.iter()
.filter_map(|(peer, packet)| {
let msg = postcard::from_bytes::<ggrs::Message>(packet).ok()?;
Some((*peer, msg))
})
.collect()
}
}
pub struct NetplayConfig {
pub server_addr: String,
pub player_number: usize,
pub number_of_players: usize,
pub input_delay: usize,
pub ice_config_path: std::path::PathBuf,
/// Session-resume (issue #4): exchange the saved session state with all
/// peers before the frame loop starts. Host-only payload: the
/// `.netplay-state` file to broadcast.
pub resume: bool,
pub state_path: Option<std::path::PathBuf>,
}
pub struct Netplay {
pub disconnected: bool,
pub session: ggrs::P2PSession<GgrsConfig>,
pub reliable_channel: matchbox_socket::WebRtcChannel,
pub peers: Vec<matchbox_socket::PeerId>,
pub player_number: usize,
pub connected: [bool; 4],
pub input_delay: usize,
pub messages: std::collections::HashMap<String, Vec<u8>>,
pub received_data: std::collections::VecDeque<Vec<u8>>,
pub inputs: Vec<(ui::input::InputData, ggrs::InputStatus)>,
pub requests: std::collections::VecDeque<ggrs::GgrsRequest<GgrsConfig>>,
pub incoming_message: Vec<u8>,
pub ice_config_path: std::path::PathBuf,
/// Session-resume (issue #4): whether the session-state exchange runs
/// before the frame loop.
pub resume: bool,
/// Session-resume (issue #4), host only: the `.netplay-state` file to
/// broadcast to peers.
pub state_path: Option<std::path::PathBuf>,
/// Session-resume (issue #4): the GGRS frame the resumed session was
/// saved at, recorded by both sides once the state has been sent
/// (host) or loaded (peer). Issue #5's frame reset will consume this.
pub resume_frame: Option<i32>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct NetplayMessage {
name: String,
data: Vec<u8>,
}
/// Reliable-channel message name for a full session state transfer.
pub const NETPLAY_STATE_MESSAGE: &str = "netplay_state";
/// Reliable-channel ack name confirming a state transfer was applied.
#[allow(dead_code)] // consumed by the upcoming session save/resume lanes
pub const NETPLAY_STATE_READY_MESSAGE: &str = "netplay_state_ready";
/// Reliable-channel message name for a desync resync state transfer.
#[allow(dead_code)] // consumed by the upcoming desync resync lanes
pub const RESYNC_STATE_MESSAGE: &str = "resync_state";
/// Reliable-channel ack name confirming a resync state was applied.
#[allow(dead_code)] // consumed by the upcoming desync resync lanes
pub const RESYNC_READY_MESSAGE: &str = "resync_ready";
/// Timeout for waiting on reliable-channel messages.
const MESSAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Serialize a message and split it into 16 KiB transport chunks.
fn encode_message_chunks(name: &str, data: &[u8]) -> Vec<Vec<u8>> {
let message = NetplayMessage {
name: name.to_string(),
data: data.to_vec(),
};
let encoded = postcard::to_stdvec(&message).unwrap();
encoded.chunks(16384).map(|chunk| chunk.to_vec()).collect()
}
/// Decode a complete message from accumulated incoming bytes, returning
/// `None` while the message is still arriving in chunks.
fn decode_message(buffer: &[u8]) -> Option<(String, Vec<u8>)> {
postcard::from_bytes::<NetplayMessage>(buffer)
.ok()
.map(|message| (message.name, message.data))
}
/// Accumulate received packets into `incoming_message` until complete messages
/// parse, returning every message decoded from the batch.
fn decode_packets(
incoming_message: &mut Vec<u8>,
received_data: &mut std::collections::VecDeque<Vec<u8>>,
packets: Vec<Vec<u8>>,
) -> Vec<(String, Vec<u8>)> {
let mut decoded = Vec::new();
received_data.extend(packets);
while !received_data.is_empty() {
if let Some(data) = received_data.pop_front() {
incoming_message.extend(data);
if let Some(message) = decode_message(incoming_message) {
decoded.push(message);
incoming_message.clear();
}
}
}
decoded
}
fn send_message(netplay: &mut Netplay, message: NetplayMessage) {
for chunk in encode_message_chunks(&message.name, &message.data) {
for peer in netplay.peers.iter() {
if let Err(e) = netplay.reliable_channel.try_send(chunk.clone().into(), *peer) {
eprintln!("Failed to send message: {}", e);
}
}
}
}
/// Drain the reliable channel and route every complete message into
/// `netplay.messages`, returning the decoded batch so callers can count
/// messages that share a name: several peers may ack within a single
/// channel poll, and the by-name `messages` map would collapse them into
/// one entry.
fn process_reliable_messages(netplay: &mut Netplay) -> Vec<(String, Vec<u8>)> {
let packets = netplay
.reliable_channel
.receive()
.iter()
.map(|(_, data)| data.to_vec())
.collect::<Vec<Vec<u8>>>();
let mut decoded = Vec::new();
for (name, data) in decode_packets(
&mut netplay.incoming_message,
&mut netplay.received_data,
packets,
) {
netplay.messages.insert(name.clone(), data.clone());
check_input_delay(netplay);
check_disconnect(netplay);
decoded.push((name, data));
}
decoded
}
/// Poll `messages` for `name` until it arrives or `timeout` elapses, calling
/// `poll` on every iteration to route newly received data into `messages`.
/// Panics with the standard reliable-channel pattern when the message never
/// arrives.
fn wait_for_message(
name: &str,
messages: &mut std::collections::HashMap<String, Vec<u8>>,
timeout: std::time::Duration,
mut poll: impl FnMut(&mut std::collections::HashMap<String, Vec<u8>>),
) -> Vec<u8> {
let now = std::time::Instant::now();
loop {
poll(messages);
if let Some(data) = messages.remove(name) {
return data;
}
if now.elapsed() > timeout {
panic!("Could not receive message for {name}");
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
fn receive_message(netplay: &mut Netplay, name: &str) -> Vec<u8> {
let Netplay {
messages,
reliable_channel,
incoming_message,
received_data,
input_delay,
session,
disconnected,
..
} = netplay;
wait_for_message(name, messages, MESSAGE_TIMEOUT, |messages| {
let packets = reliable_channel
.receive()
.iter()
.map(|(_, data)| data.to_vec())
.collect::<Vec<Vec<u8>>>();
for (message_name, data) in decode_packets(incoming_message, received_data, packets) {
// Preserve the side effects the original receive path applied to
// every decoded message (input-delay changes and disconnects).
// These control messages are consumed, never stored in `messages`
// (mirroring check_input_delay/check_disconnect removing them).
if message_name == "input_delay" {
let new_delay = u64::from_be_bytes(data[..].try_into().unwrap()) as usize;
if new_delay != *input_delay {
*input_delay = new_delay;
for handle in session.local_player_handles() {
if let Err(e) = session.set_input_delay(handle, new_delay) {
eprintln!("Error setting input delay: {}", e);
} else {
ui::video::onscreen_message(
&format!("Input delay: {}", new_delay),
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
}
}
}
} else if message_name == "disconnect" {
if !*disconnected {
*disconnected = true;
ui::video::onscreen_message(
"Player disconnected, session has ended",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
}
} else {
messages.insert(message_name, data);
}
}
})
}
fn send_player_number(
channel: &mut matchbox_socket::WebRtcChannel,
peers: &[matchbox_socket::PeerId],
player_number: usize,
) {
let message = NetplayMessage {
name: "player_number".to_string(),
data: (player_number as u64).to_be_bytes().to_vec(),
};
let data = postcard::to_stdvec(&message).unwrap();
for peer in peers {
if let Err(e) = channel.try_send(data.clone().into(), *peer) {
eprintln!("Failed to send message: {}", e);
}
}
}
fn get_player_numbers(
channel: &mut matchbox_socket::WebRtcChannel,
player_numbers: &mut std::collections::BTreeMap<usize, Option<matchbox_socket::PeerId>>,
) {
for (peer, data) in channel.receive() {
let message = postcard::from_bytes::<NetplayMessage>(&data).unwrap();
if message.name == "player_number" {
player_numbers.insert(
u64::from_be_bytes(message.data.try_into().unwrap()) as usize,
Some(peer),
);
}
}
}
pub fn send_rtc(netplay: &mut Netplay, rtc: i64) {
let message = NetplayMessage {
name: "rtc".to_string(),
data: rtc.to_be_bytes().to_vec(),
};
send_message(netplay, message);
}
pub fn receive_rtc(netplay: &mut Netplay) -> i64 {
let message = receive_message(netplay, "rtc");
i64::from_be_bytes(message.try_into().unwrap())
}
pub fn send_rng(netplay: &mut Netplay, seed: u64) {
let message = NetplayMessage {
name: "rng".to_string(),
data: seed.to_be_bytes().to_vec(),
};
send_message(netplay, message);
}
pub fn receive_rng(netplay: &mut Netplay) -> u64 {
let message = receive_message(netplay, "rng");
u64::from_be_bytes(message.try_into().unwrap())
}
pub fn send_save(netplay: &mut Netplay, save_type: &str, save_data: &[u8]) {
let message = NetplayMessage {
name: save_type.to_string(),
data: save_data.to_vec(),
};
send_message(netplay, message);
}
pub fn receive_save(netplay: &mut Netplay, save_type: &str, save_data: &mut Vec<u8>) {
let message = receive_message(netplay, save_type);
*save_data = message;
}
/// Send a full session state over the reliable channel as chunked messages.
#[allow(dead_code)] // consumed by the upcoming session save/resume lanes
pub fn send_netplay_state(netplay: &mut Netplay, data: &[u8]) {
let message = NetplayMessage {
name: NETPLAY_STATE_MESSAGE.to_string(),
data: data.to_vec(),
};
send_message(netplay, message);
}
/// Receive a full session state from the reliable channel.
#[allow(dead_code)] // consumed by the upcoming session save/resume lanes
pub fn receive_netplay_state(netplay: &mut Netplay) -> Vec<u8> {
receive_message(netplay, NETPLAY_STATE_MESSAGE)
}
/// Send a ready-ack message with `name` over the reliable channel.
#[allow(dead_code)] // consumed by the upcoming session save/resume lanes
pub fn send_ready(netplay: &mut Netplay, name: &str) {
let message = NetplayMessage {
name: name.to_string(),
data: vec![],
};
send_message(netplay, message);
}
/// Wait for the ready-ack message with `name` (standard 10s timeout).
#[allow(dead_code)] // consumed by the upcoming session save/resume lanes
pub fn wait_for_ready(netplay: &mut Netplay, name: &str) {
receive_message(netplay, name);
}
/// On-disk representation of a saved netplay session (host side).
/// `state` holds the compressed savestate payload produced by
/// `SavestateData::save_to_memory` (zip entries for device, saves,
/// rdp_state and ra_state); the whole struct is postcard-encoded and
/// stored zstd-compressed under a single entry inside the
/// `.netplay-state` file.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct NetplaySessionSave {
pub state: Vec<u8>,
/// GGRS frame the session was saved at, so a resume can restart here.
pub frame: i32,
pub input_delay: usize,
pub rom_hash: String,
pub players: usize,
}
const NETPLAY_STATE_ENTRY: &str = "netplay_state";
/// Postcard-encode + compress a netplay session save into its on-disk form.
pub fn encode_netplay_session(save: &NetplaySessionSave) -> Option<Vec<u8>> {
let data = postcard::to_stdvec(save).ok()?;
ui::storage::compress_file(&[(&data, NETPLAY_STATE_ENTRY)]).ok()
}
/// Decompress + decode a .netplay-state file back into its session save.
/// Used by the tests today; a future resume feature consumes it.
#[cfg_attr(not(test), allow(dead_code))]
pub fn decode_netplay_session(file_data: &[u8]) -> Option<NetplaySessionSave> {
let data = ui::storage::decompress_file(file_data, NETPLAY_STATE_ENTRY).ok()?;
postcard::from_bytes(&data).ok()
}
fn write_atomic(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
let tmp_path = path.with_extension("netplay-state.tmp");
std::fs::write(&tmp_path, data)?;
std::fs::rename(&tmp_path, path)
}
/// Write a netplay session save to disk atomically (tmp + rename).
pub fn write_netplay_session(
path: &std::path::Path,
save: &NetplaySessionSave,
) -> std::io::Result<()> {
let data = encode_netplay_session(save).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"failed to encode netplay session",
)
})?;
write_atomic(path, &data)
}
/// Host-side session save: capture the current emulator state together with
/// the GGRS frame and session metadata into a `.netplay-state` file so the
/// session can be resumed later. Only the host (player_number 0) can save;
/// everyone else just gets an onscreen notice.
pub fn save_netplay_session(device: &mut device::Device, path: &std::path::Path) {
let Some(netplay) = device.netplay.as_ref() else {
return;
};
if netplay.player_number != 0 {
ui::video::onscreen_message(
"Only the host can save the session",
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
return;
}
let Some(state_data) = savestates::create_savestate_in_memory(device) else {
eprintln!("Error capturing netplay session state");
ui::video::onscreen_message(
"Failed to save netplay session",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
return;
};
// save_to_memory (issue #2 API) returns an empty Vec on serialization
// failure rather than an Option.
let state_zip = state_data.save_to_memory();
if state_zip.is_empty() {
eprintln!("Error serializing netplay session state");
ui::video::onscreen_message(
"Failed to save netplay session",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
return;
}
let netplay = device.netplay.as_ref().unwrap();
let save = NetplaySessionSave {
state: state_zip,
frame: netplay.session.current_frame(),
input_delay: netplay.input_delay,
rom_hash: device.ui.game_hash.clone(),
players: netplay.peers.len() + 1,
};
match write_netplay_session(path, &save) {
Ok(()) => ui::video::onscreen_message(
"Session saved — safe to quit",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
),
Err(e) => {
eprintln!("Error writing netplay session: {e}");
ui::video::onscreen_message(
"Failed to save netplay session",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
}
}
}
// ---- Session resume (issue #4) ----
/// How long the host waits for every peer to acknowledge receiving and
/// loading the broadcast session state before giving up on that peer.
const NETPLAY_STATE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Count how many messages in a decoded batch carry `name`. Used for
/// ready-acks, where one ack per peer is expected and several peers can
/// acknowledge within a single channel poll.
fn count_messages_named(decoded: &[(String, Vec<u8>)], name: &str) -> usize {
decoded.iter().filter(|(decoded_name, _)| decoded_name == name).count()
}
/// Wait for `expected` acknowledgements named `name`, polling the reliable
/// channel until all of them arrive or `timeout` elapses.
///
/// Acks are counted, not attributed to a specific peer: `send_ready`
/// broadcasts to every peer, so the host receives one copy per peer. The
/// count comes from the decoded batch of every poll — not from the
/// by-name `messages` map — so acks arriving together are all counted.
fn wait_for_ready_all(
netplay: &mut Netplay,
name: &str,
expected: usize,
timeout: std::time::Duration,
) -> bool {
let now = std::time::Instant::now();
let mut received = 0;
while received < expected {
received += count_messages_named(&process_reliable_messages(netplay), name);
if received >= expected {
return true;
}
if now.elapsed() > timeout {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
true
}
/// Session-resume exchange (issue #4). Runs once, right after the initial
/// RNG/RTC/savestate handshake and before the GGRS frame loop starts:
///
/// - host (player 0): reads the `.netplay-state` file, validates its ROM
/// hash, loads the embedded savestate into its own device, broadcasts the
/// session-save bytes to every peer via `netplay_state` and waits for each
/// peer's `netplay_state_ready` ack (30 s timeout, then the lagging peers
/// are disconnected and the session is abandoned);
/// - peers: receive `netplay_state`, decode it with the same
/// `decode_netplay_session` the host used to read the file, load the
/// embedded savestate via `SavestateData::load_from_memory` +
/// `savestates::load_savestate_from_data`, then acknowledge with
/// `netplay_state_ready`.
///
/// Both sides then reset the GGRS session frame counter to the saved frame
/// with issue #5's `netplay::reset_frame`, so the frame loop resumes at the
/// saved frame instead of 0.
///
/// On any failure the session is flagged disconnected (mirroring the
/// existing peer-disconnect path) so the frame loop never starts desynced.
pub fn resume_session(device: &mut device::Device) -> Result<(), String> {
let Some(netplay) = device.netplay.as_ref() else {
return Ok(());
};
if !netplay.resume {
return Ok(());
}
if netplay.player_number == 0 {
resume_session_host(device)
} else {
resume_session_peer(device)
}
}
/// Load a decoded `.netplay-state` envelope into the local device (its
/// savestate payload via `load_savestate_from_data`) and reset the GGRS
/// session frame counter to the saved frame (issue #5's `reset_frame`).
fn apply_resume_state(device: &mut device::Device, save: &NetplaySessionSave) -> Result<(), String> {
let state_data = savestates::SavestateData::load_from_memory(&save.state)
.ok_or_else(|| "Session state could not be deserialized".to_string())?;
if !savestates::load_savestate_from_data(device, &state_data) {
return Err("Session state could not be applied".to_string());
}
if let Some(netplay) = device.netplay.as_mut() {
netplay.resume_frame = Some(save.frame);
reset_frame(&mut netplay.session, save.frame);
}
Ok(())
}
fn resume_session_host(device: &mut device::Device) -> Result<(), String> {
ui::video::onscreen_message(
"Syncing session state…",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
let (state_path, expected_peers, game_hash) = {
let netplay = device.netplay.as_ref().unwrap();
(
netplay
.state_path
.clone()
.ok_or_else(|| "Host resume requires a .netplay-state file".to_string())?,
netplay.peers.len(),
device.ui.game_hash.clone(),
)
};
let file_data = std::fs::read(&state_path)
.map_err(|e| format!("Could not read session state {}: {}", state_path.display(), e))?;
let save = decode_netplay_session(&file_data)
.ok_or_else(|| format!("Could not decode session state file {}", state_path.display()))?;
if !save.rom_hash.is_empty() && save.rom_hash != game_hash {
return Err(format!(
"Session state was saved for a different ROM ({} vs {})",
save.rom_hash, game_hash
));
}
// The host resumes at the saved state too: load the savestate into the
// local device and reset the frame counter before the handshake, exactly
// as every peer will.
apply_resume_state(device, &save)?;
// Broadcast the file bytes unmodified: `encode_netplay_session` produces
// exactly the on-disk format, so every peer decodes what the host read.
let broadcast = encode_netplay_session(&save)
.ok_or_else(|| "Could not encode session state for broadcast".to_string())?;
let netplay = device.netplay.as_mut().unwrap();
send_netplay_state(netplay, &broadcast);
if !wait_for_ready_all(
netplay,
NETPLAY_STATE_READY_MESSAGE,
expected_peers,
NETPLAY_STATE_ACK_TIMEOUT,
) {
// One or more peers did not load the state in time. Acks are not
// peer-attributed, so the lagging peer cannot be singled out: drop
// the whole session rather than start it out of sync.
for handle in 0..expected_peers + 1 {
if handle != netplay.player_number {
let _ = netplay.session.disconnect_player(handle);
}
}
netplay.disconnected = true;
return Err(format!(
"Timed out waiting for {} peer(s) to sync the session state",
expected_peers
));
}
ui::video::onscreen_message(
"Session synced — play resumes",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
Ok(())
}
fn resume_session_peer(device: &mut device::Device) -> Result<(), String> {
ui::video::onscreen_message(
"Syncing session state…",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
let state_bytes = {
let netplay = device.netplay.as_mut().unwrap();
// Standard reliable-channel timeout (10 s); the host is the sender.
receive_netplay_state(netplay)
};
let save = decode_netplay_session(&state_bytes)
.ok_or_else(|| "Could not decode session state received from host".to_string())?;
apply_resume_state(device, &save)?;
let netplay = device.netplay.as_mut().unwrap();
send_ready(netplay, NETPLAY_STATE_READY_MESSAGE);
ui::video::onscreen_message(
"Session synced — play resumes",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
Ok(())
}
pub fn send_input_delay(netplay: &mut Netplay, input_delay: usize) {
if input_delay < 1 {
return;
}
let message = NetplayMessage {
name: "input_delay".to_string(),
data: (input_delay as u64).to_be_bytes().to_vec(),
};
send_message(netplay, message);
change_input_delay(netplay, input_delay);
}
fn change_input_delay(netplay: &mut Netplay, input_delay: usize) {
netplay.input_delay = input_delay;
for handle in netplay.session.local_player_handles() {
if let Err(e) = netplay.session.set_input_delay(handle, input_delay) {
eprintln!("Error setting input delay: {}", e);
} else {
ui::video::onscreen_message(
&format!("Input delay: {}", input_delay),
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
}
}
}
fn check_input_delay(netplay: &mut Netplay) {
if let Some(data) = netplay.messages.remove("input_delay") {
let input_delay = u64::from_be_bytes(data.try_into().unwrap()) as usize;
if input_delay != netplay.input_delay {
change_input_delay(netplay, input_delay);
}
}
}
fn check_disconnect(netplay: &mut Netplay) {
if !netplay.disconnected && netplay.messages.remove("disconnect").is_some() {
netplay.disconnected = true;
ui::video::onscreen_message(
"Player disconnected, session has ended",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
}
}
fn pending_frames(netplay: &Netplay) -> usize {
netplay
.requests
.iter()
.filter(|r| matches!(r, ggrs::GgrsRequest::AdvanceFrame { .. }))
.count()
}
pub fn in_rollback(netplay: Option<&Netplay>) -> bool {
if let Some(netplay) = netplay {
pending_frames(netplay) != 0
} else {
false
}
}
pub fn process_requests(
device: &mut device::Device,
) -> Vec<(ui::input::InputData, ggrs::InputStatus)> {
loop {
if let Some(request) = device.netplay.as_mut().unwrap().requests.pop_front() {
match request {
ggrs::GgrsRequest::SaveGameState { cell, frame } => {
//savestates::create_savestate(device, true, Some(frame));
let mut hasher = sha2::Sha256::new();
for reg in device.cpu.cop0.regs.as_ref() {
hasher.update(reg.to_be_bytes());
}
let hash = u128::from_be_bytes(hasher.finalize()[..16].try_into().unwrap());
cell.save(frame, Some(frame), Some(hash));
}
ggrs::GgrsRequest::LoadGameState { cell, frame: _ } => {
eprintln!("attempting to load game state");
if let Some(_frame) = cell.load() {
// savestates::load_savestate(device, true, Some(frame));
}
}
ggrs::GgrsRequest::AdvanceFrame { inputs } => {
return inputs;
}
}
} else {
process_netplay(device);
}
}
}
fn poll_clients(netplay: &mut Netplay) {
netplay.session.poll_remote_clients();
for event in netplay.session.events() {
match event {
ggrs::GgrsEvent::Synchronizing { .. } => {}
ggrs::GgrsEvent::Synchronized { .. } => {}
ggrs::GgrsEvent::Disconnected { .. } => {
if !netplay.disconnected {
netplay.disconnected = true;
ui::video::onscreen_message(
"Lost connection to peer(s)",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
}
}
ggrs::GgrsEvent::NetworkInterrupted { .. } => {
println!("network interrupted");
}
ggrs::GgrsEvent::NetworkResumed { .. } => {
println!("network resumed");
}
ggrs::GgrsEvent::WaitRecommendation { skip_frames } => {
println!("wait recommendation: skip_frames={}", skip_frames);
}
ggrs::GgrsEvent::DesyncDetected { .. } => {
eprintln!("desync detected");
ui::video::onscreen_message(
"Desync detected",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
}
}
}
}
fn process_netplay(device: &mut device::Device) {
let netplay = device.netplay.as_mut().unwrap();
poll_clients(netplay);
process_reliable_messages(netplay);
advance_frame(device);
}
fn advance_frame(device: &mut device::Device) {
let netplay = device.netplay.as_mut().unwrap();
let local_input = if netplay.session.current_frame() > netplay.session.max_prediction() as i32 {
ui::input::get(&mut device.ui, 0)
} else {
//workaround for disabled rollback
ui::input::InputData::default()
};
let local_handle = *netplay.session.local_player_handles().first().unwrap();
netplay
.session
.add_local_input(local_handle, local_input)
.unwrap();
// avoid rollback
while !netplay.disconnected
&& netplay.session.current_frame() > netplay.session.confirmed_frame()
&& netplay.session.confirmed_frame() != ggrs::NULL_FRAME
{
poll_clients(netplay);
}
if netplay.disconnected {
netplay.requests.push_back(ggrs::GgrsRequest::AdvanceFrame {
inputs: vec![
(
ui::input::InputData::default(),
ggrs::InputStatus::Disconnected
);
4
],
});
return;
}
match netplay.session.advance_frame() {
Ok(requests) => {
netplay.requests.extend(requests);
}
Err(ggrs::GgrsError::PredictionThreshold) => {
println!("prediction threshold reached");
}
Err(e) => panic!("{e}"),
}
}
fn verify_peers(
peers: &[matchbox_socket::PeerId],
player_numbers: &std::collections::BTreeMap<usize, Option<matchbox_socket::PeerId>>,
) -> bool {
for peer in player_numbers.values() {
if let Some(peer) = peer
&& !peers.contains(peer)
{
return false;
}
}
true
}
fn create_socket(builder: matchbox_socket::WebRtcSocketBuilder) -> matchbox_socket::WebRtcSocket {
let (socket, loop_fut) = builder.build();
tokio::spawn(async move {
if let Err(e) = loop_fut.await {
eprintln!("WebRTC loop failed: {}", e);
}
});
socket
}
pub fn init(
device: &mut device::Device,
netplay_config: &NetplayConfig,
pal: bool,
) -> Option<Netplay> {
let mut builder = matchbox_socket::WebRtcSocketBuilder::new(&netplay_config.server_addr)
.add_unreliable_channel()
.add_reliable_channel();
if let Ok(ice_config) = std::fs::read(&netplay_config.ice_config_path)
&& let Ok(ice_config) = serde_json::from_slice::<RtcIceServerConfig>(&ice_config)
{
builder = builder.ice_server(matchbox_socket::RtcIceServerConfig {
urls: ice_config.urls,
username: ice_config.username,
credential: ice_config.credential,
});
} else {
eprintln!("Using default ICE config");
}
let mut socket = create_socket(builder.clone());
let mut now = std::time::Instant::now();
let mut message_timer = now;
let socket_timeout = std::time::Duration::from_secs_f64(rand::random_range(8.0..10.0));
let mut player_numbers =
std::collections::BTreeMap::from([(netplay_config.player_number, None)]);
ui::video::onscreen_message(
"Connecting to netplay peers...\nPlease wait...",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
device.cpu.running = true;
while device.cpu.running {
if socket
.update_peers()
.iter()
.any(|(_, peer_state)| *peer_state == matchbox_socket::PeerState::Disconnected)
{
// if someone has disconnected, reset the timeout
now = std::time::Instant::now();
}
let connected_peers = socket
.connected_peers()
.collect::<Vec<matchbox_socket::PeerId>>();
if connected_peers.len() == netplay_config.number_of_players - 1 {
send_player_number(
socket.channel_mut(1),
&connected_peers,
netplay_config.player_number,
);
get_player_numbers(socket.channel_mut(1), &mut player_numbers);
if player_numbers.len() == netplay_config.number_of_players
&& verify_peers(&connected_peers, &player_numbers)
{
break;
}
} else if now.elapsed() > socket_timeout {
socket.close();
player_numbers.retain(|_, peer| peer.is_none());
socket = create_socket(builder.clone());
now = std::time::Instant::now();
}
if message_timer.elapsed() > std::time::Duration::from_secs(4) {
ui::video::onscreen_message(
&format!(
"Still connecting to {} netplay peer(s)...\nPlease wait...",
netplay_config.number_of_players - player_numbers.len()
),
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
message_timer = std::time::Instant::now();
}
ui::video::render_frame();
ui::video::update_screen();
std::thread::sleep(std::time::Duration::from_millis(10));
ui::video::check_callback(device);
}
if !device.cpu.running {
// user closed the window
return None;
}
device.cpu.running = false;
let mut session_builder = ggrs::SessionBuilder::<GgrsConfig>::new()
.with_num_players(netplay_config.number_of_players)
.unwrap()
.with_input_delay(netplay_config.input_delay)
.with_fps(if pal { 50 } else { 60 })
.unwrap()
.with_desync_detection_mode(ggrs::DesyncDetection::On { interval: 60 })
.with_max_prediction_window(16)
.with_disconnect_timeout(std::time::Duration::from_secs(if cfg!(debug_assertions) {
10
} else {
5
}));
let mut peers = vec![];
for (i, peer) in player_numbers.iter() {
if let Some(peer) = peer {
session_builder = session_builder
.add_player(ggrs::PlayerType::Remote(*peer), *i)
.unwrap();
peers.push(*peer);
} else {
session_builder = session_builder
.add_player(ggrs::PlayerType::Local, *i)
.unwrap();
}
}
let matchbox_channel = MatchboxChannel {
channel: socket.take_channel(0).unwrap(),
};
let reliable_channel = socket.take_channel(1).unwrap();
if matchbox_channel.channel.config().max_retransmits != Some(0)
|| matchbox_channel.channel.config().ordered
{
eprintln!("Sending GGRS traffic over reliable channel");
}
let mut session = session_builder.start_p2p_session(matchbox_channel).unwrap();
let now = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(10);
while session.current_state() != ggrs::SessionState::Running {
session.poll_remote_clients();
if now.elapsed() > timeout {
eprintln!("Could not start netplay session");
return None;
}
unsafe { sdl3_sys::events::SDL_PumpEvents() };
std::thread::sleep(std::time::Duration::from_millis(10));
}
Some(Netplay {
disconnected: false,
incoming_message: vec![],
input_delay: netplay_config.input_delay,
session,
reliable_channel,
peers,
player_number: netplay_config.player_number,
connected: [
netplay_config.number_of_players > 0,
netplay_config.number_of_players > 1,
netplay_config.number_of_players > 2,
netplay_config.number_of_players > 3,
],
inputs: Vec::new(),
requests: std::collections::VecDeque::new(),
received_data: std::collections::VecDeque::new(),
messages: std::collections::HashMap::new(),
ice_config_path: netplay_config.ice_config_path.clone(),
resume: netplay_config.resume,
state_path: netplay_config.state_path.clone(),
resume_frame: None,
})
}
pub fn close(netplay: &mut Netplay) {
if !netplay.disconnected {
let message = NetplayMessage {
name: "disconnect".to_string(),
data: vec![],
};
send_message(netplay, message);
std::thread::sleep(std::time::Duration::from_millis(200)); // give the message time to be sent
}
let _ = std::fs::remove_file(&netplay.ice_config_path);
}
/// Resets the GGRS session's internal frame counter to the given frame.
///
/// After loading a savestate in a netplay context, the emulator state is restored to the
/// savestate's frame, but the GGRS session's frame counter still reflects the pre-load
/// frame. If this discrepancy is not corrected, desync detection fires immediately on
/// resume because the session's saved-state checksums no longer correspond to the loaded
/// emulator state.
///
/// This function is safe to call at any time. If the GGRS version in use does not support
/// frame reset (i.e. `set_current_frame` is not available), this function will still
/// compile because the ggrs crate is patched to expose the method.
///
/// Both peers must call this with the same target frame after loading the same savestate.
pub fn reset_frame(session: &mut ggrs::P2PSession<GgrsConfig>, frame: i32) {
session.set_current_frame(frame);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_message_constants_match_protocol() {
assert_eq!(NETPLAY_STATE_MESSAGE, "netplay_state");
assert_eq!(NETPLAY_STATE_READY_MESSAGE, "netplay_state_ready");
assert_eq!(RESYNC_STATE_MESSAGE, "resync_state");
assert_eq!(RESYNC_READY_MESSAGE, "resync_ready");
}
#[test]
fn large_payload_round_trips_through_chunked_codec() {
let payload: Vec<u8> = (0..5 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
let chunks = encode_message_chunks(NETPLAY_STATE_MESSAGE, &payload);
assert!(chunks.len() > 1, "5MB payload must span multiple chunks");
// Mirror what process_reliable_messages does in production: accumulate
// chunks until the full postcard message parses.
let mut incoming_message = Vec::new();
let mut received_data = std::collections::VecDeque::new();
let decoded = decode_packets(&mut incoming_message, &mut received_data, chunks);
assert_eq!(
decoded.len(),
1,
"exactly one complete message should decode after all chunks"
);
let (name, data) = &decoded[0];
assert_eq!(name, NETPLAY_STATE_MESSAGE);
assert_eq!(data, &payload);
}
#[test]
fn ready_ack_send_receive_within_timeout() {
let timeout = std::time::Duration::from_secs(10);
let start = std::time::Instant::now();
// Send side: chunked encoding of the ack message, exactly like send_ready.
let chunks = encode_message_chunks(NETPLAY_STATE_READY_MESSAGE, &[]);
// Receive side: accumulate + decode, exactly like the production path.
let mut incoming_message = Vec::new();
let mut received_data = std::collections::VecDeque::new();
let decoded = decode_packets(&mut incoming_message, &mut received_data, chunks);
assert!(
start.elapsed() < timeout,
"ack must arrive within the timeout window"
);
assert_eq!(decoded.len(), 1);
let (name, data) = &decoded[0];
assert_eq!(name, NETPLAY_STATE_READY_MESSAGE);
assert!(data.is_empty());
// The wait API itself must return promptly when the ack is already queued.
let mut messages = std::collections::HashMap::from([(
NETPLAY_STATE_READY_MESSAGE.to_string(),
Vec::<u8>::new(),
)]);
let ack = wait_for_message(NETPLAY_STATE_READY_MESSAGE, &mut messages, timeout, |_| {});
assert!(ack.is_empty());
assert!(start.elapsed() < timeout);
}
#[test]
fn missing_ack_times_out_with_standard_panic() {
let mut messages = std::collections::HashMap::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
wait_for_message(
NETPLAY_STATE_READY_MESSAGE,
&mut messages,
std::time::Duration::from_millis(50),
|_| {},
);
}));
let payload = result.expect_err("waiting for a missing ack must panic");
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.unwrap_or("");
assert_eq!(message, "Could not receive message for netplay_state_ready");
}
fn test_save(frame: i32) -> NetplaySessionSave {
NetplaySessionSave {
state: (0..256u32).map(|i| (i.wrapping_mul(13) % 251) as u8).collect(),
frame,
input_delay: 2,
rom_hash: "12345678-90ABCDEF-C:45".to_string(),
players: 2,
}
}
#[test]
fn netplay_session_roundtrips_through_compress_decompress() {
let save = test_save(123_456);
let encoded = encode_netplay_session(&save).expect("encode should succeed");
let decoded = decode_netplay_session(&encoded).expect("decode should succeed");
assert_eq!(decoded, save);
}
#[test]
fn netplay_session_file_has_correct_metadata() {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let save = test_save(4321);
let path = std::env::temp_dir().join(format!(
"gopher64-netplay-state-test-{}-{}.bin",
std::process::id(),
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
write_netplay_session(&path, &save).expect("write should succeed");
let file_data = std::fs::read(&path).expect("file should exist on disk");
let parsed = decode_netplay_session(&file_data).expect("file should parse");
assert_eq!(parsed.frame, 4321);
assert_eq!(parsed.rom_hash, "12345678-90ABCDEF-C:45");
assert_eq!(parsed.input_delay, 2);
assert_eq!(parsed.players, 2);
assert_eq!(parsed, save);
let _ = std::fs::remove_file(&path);
}
/// No-op network socket: satisfies ggrs' `NonBlockingSocket` bound so a
/// local-only session can be constructed without any real network.
struct TestSocket;
impl ggrs::NonBlockingSocket<matchbox_socket::PeerId> for TestSocket {
fn send_to(&mut self, _msg: &ggrs::Message, _addr: &matchbox_socket::PeerId) {}
fn receive_all_messages(&mut self) -> Vec<(matchbox_socket::PeerId, ggrs::Message)> {
Vec::new()
}
}
/// Build a real `Netplay` value offline (mirrors the savestates test
/// pattern): local-only ggrs session in `Running` state, WebRTC channels
/// from a builder that never connects.
fn mock_netplay(player_number: usize) -> Netplay {
let (mut socket, _loop_future) = matchbox_socket::WebRtcSocketBuilder::new(
"ws://127.0.0.1:9/room",
)
.add_unreliable_channel()
.add_reliable_channel()
.build();
let reliable_channel = socket.take_channel(1).unwrap();
let mut builder = ggrs::SessionBuilder::<GgrsConfig>::new()
.with_num_players(player_number + 1)
.unwrap();
// GGRS requires every handle to be registered: the local player at
// `player_number`, Remote placeholders (a dummy nil PeerId — the
// socket never sends to it) for the rest.
let dummy_peer: matchbox_socket::PeerId =
serde_json::from_str("\"00000000-0000-0000-0000-000000000000\"")
.expect("nil PeerId parses");
for handle in 0..=player_number {
let player_type = if handle == player_number {
ggrs::PlayerType::Local
} else {
ggrs::PlayerType::Remote(dummy_peer)
};
builder = builder.add_player(player_type, handle).unwrap();
}
let session = builder.start_p2p_session(TestSocket).unwrap();
Netplay {
disconnected: false,
session,
reliable_channel,
peers: vec![],
player_number,
connected: [true, false, false, false],
input_delay: 0,
messages: Default::default(),
received_data: Default::default(),
inputs: vec![],
requests: Default::default(),
incoming_message: vec![],
ice_config_path: std::path::PathBuf::new(),
resume: true,
state_path: None,
resume_frame: None,
}
}
/// Mock 2-peer resume flow (issue #4): the host's `.netplay-state`
/// envelope travels over the `netplay_state` reliable message, the peer
/// decodes it and loads the embedded savestate, and both sides agree on
/// the frame metadata. The peer's GGRS session frame counter is reset to
/// the saved frame (issue #5's `reset_frame`) so the frame loop resumes
/// there, not at 0.
#[test]
fn resume_state_reaches_peer_with_same_frame_metadata() {
use crate::savestates::create_savestate_in_memory;
// Host: a session with distinctive emulator state, captured in
// memory (issue #2 API) and wrapped in the issue #3 on-disk
// `.netplay-state` envelope.
let mut host_device = crate::device::Device::new(false);
host_device.cpu.gpr[1] = 0x1234_5678_9abc_def0;
host_device.cpu.gpr[3] = 0x0fed_cba9_8765_4321;
host_device.rdram.mem.resize(0x800, 0);
host_device.rdram.mem[0] = 0xAB;
host_device.rdram.mem[0x3FF] = 0xCD;
host_device.vi.regs[0] = 0x1122_3344;
let snapshot =
create_savestate_in_memory(&mut host_device).expect("create in-memory savestate");
let save = NetplaySessionSave {
state: snapshot.save_to_memory(),
frame: 12_345,
input_delay: 2,
rom_hash: "DEADBEEF-C01DF00D".to_string(),
players: 2,
};
// The host broadcasts exactly the on-disk file bytes.
let broadcast = encode_netplay_session(&save).expect("encode session save");
let chunks = encode_message_chunks(NETPLAY_STATE_MESSAGE, &broadcast);
// Peer accumulates the chunked message, exactly like
// process_reliable_messages does in production.
let mut incoming_message = Vec::new();
let mut received_data = std::collections::VecDeque::new();
let decoded = decode_packets(&mut incoming_message, &mut received_data, chunks);
assert_eq!(decoded.len(), 1, "one complete netplay_state message");
let (name, data) = &decoded[0];
assert_eq!(name, NETPLAY_STATE_MESSAGE);
// Peer decodes with the same envelope the host used.
let peer_save = decode_netplay_session(data).expect("peer decodes session state");
assert_eq!(peer_save.frame, 12_345, "both sides report the saved frame");
assert_eq!(peer_save.input_delay, 2);
assert_eq!(peer_save.players, 2);
assert_eq!(peer_save.rom_hash, "DEADBEEF-C01DF00D");
assert_eq!(peer_save.state, save.state, "state payload travels unmodified");
// Peer loads the embedded savestate into a fresh device (no disk),
// exactly like resume_session_peer: load_from_memory ->
// load_savestate_from_data, then issue #5's frame reset. The RDRAM
// backing store is pre-sized like a real emulated device, matching
// the saved session's RDRAM.
let mut peer_device = crate::device::Device::new(false);
peer_device.rdram.mem.resize(0x800, 0);
peer_device.netplay = Some(mock_netplay(1));
apply_resume_state(&mut peer_device, &peer_save).expect("peer applies the state");
assert_eq!(peer_device.cpu.gpr[1], 0x1234_5678_9abc_def0);
assert_eq!(peer_device.cpu.gpr[3], 0x0fed_cba9_8765_4321);
assert_eq!(peer_device.rdram.mem[0], 0xAB);
assert_eq!(peer_device.rdram.mem[0x3FF], 0xCD);
assert_eq!(peer_device.vi.regs[0], 0x1122_3344);
assert!(
peer_device.netplay.is_some(),
"the live netplay session survives the state load"
);
// Both sides agree on the frame metadata: the resumed frame is the
// saved frame.
let peer_netplay = peer_device.netplay.as_ref().unwrap();
assert_eq!(peer_netplay.resume_frame, Some(12_345));
assert_eq!(
peer_netplay.session.current_frame(),
12_345,
"GGRS frame counter resumes at the saved frame, not 0"
);
}
/// Host resume path (issue #4): a `.netplay-state` file on disk is
/// decoded by `resume_session_host`, its savestate loaded into the
/// host's own device, and the GGRS frame counter reset to the saved
/// frame before broadcast.
#[test]
fn resume_host_loads_state_from_disk_and_resets_frame() {
use crate::device::Device;
use crate::savestates::create_savestate_in_memory;
let mut host_device = Device::new(false);
host_device.cpu.gpr[5] = 0xDEAD_BEEF;
host_device.rdram.mem.resize(0x800, 0);
host_device.rdram.mem[0x100] = 0x42;
host_device.netplay = Some(mock_netplay(0));
host_device.ui.game_hash = "DEADBEEF-C01DF00D".to_string();
let snapshot =
create_savestate_in_memory(&mut host_device).expect("create in-memory savestate");
let save = NetplaySessionSave {
state: snapshot.save_to_memory(),
frame: 77_007,
input_delay: 2,
rom_hash: "DEADBEEF-C01DF00D".to_string(),
players: 2,
};
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"gopher64-netplay-resume-test-{}-{}.netplay-state",
std::process::id(),
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
write_netplay_session(&path, &save).expect("write session save");
// Fresh device stands in for the host's just-launched session; the
// savestate was captured from a different Device instance above.
// RDRAM is pre-sized like a real emulated device so the saved
// session's RDRAM can be restored.
let mut host_session = Device::new(false);
host_session.rdram.mem.resize(0x800, 0);
host_session.ui.game_hash = "DEADBEEF-C01DF00D".to_string();
host_session.netplay = Some(mock_netplay(0));
host_session.netplay.as_mut().unwrap().state_path = Some(path.clone());
let frame_before = host_session.netplay.as_ref().unwrap().session.current_frame();
resume_session_host(&mut host_session).expect("host resume succeeds");
assert_eq!(
host_session.netplay.as_ref().unwrap().resume_frame,
Some(77_007)
);
assert_eq!(
host_session.netplay.as_ref().unwrap().session.current_frame(),
77_007,
"host frame counter resumes at the saved frame (was {frame_before})"
);
assert_eq!(host_session.cpu.gpr[5], 0xDEAD_BEEF);
assert_eq!(host_session.rdram.mem[0x100], 0x42);
let _ = std::fs::remove_file(&path);
}
/// Ack counting is per decoded batch: several peers acknowledging
/// within a single channel poll are all counted, and unrelated
/// messages are ignored.
#[test]
fn ack_batch_counts_every_ack() {
let batch = vec![
(NETPLAY_STATE_READY_MESSAGE.to_string(), vec![]),
(NETPLAY_STATE_READY_MESSAGE.to_string(), vec![]),
("unrelated".to_string(), vec![1]),
];
assert_eq!(
count_messages_named(&batch, NETPLAY_STATE_READY_MESSAGE),
2,
"both acks in one poll are counted; unrelated messages are not"
);
}
/// The host's 30s ack wait times out (returning false) when acks are
/// missing instead of hanging forever.
#[test]
fn wait_for_ready_all_counts_acks_and_times_out() {
// Missing acks (the mock channel never delivers): gives up after
// the timeout.
let mut netplay = mock_netplay(0);
assert!(!wait_for_ready_all(
&mut netplay,
NETPLAY_STATE_READY_MESSAGE,
1,
std::time::Duration::from_millis(50),
));
}
/// resume_session is a no-op outside resume sessions, so regular netplay
/// is untouched (host and peer paths).
#[test]
fn resume_session_skips_non_resume_sessions() {
let mut device = crate::device::Device::new(false);
let mut netplay = mock_netplay(0);
netplay.resume = false;
device.netplay = Some(netplay);
assert!(resume_session(&mut device).is_ok());
assert_eq!(device.netplay.as_ref().unwrap().resume_frame, None);
}
use ggrs::{Config, GgrsRequest, SessionBuilder};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
/// Test config for SyncTestSession-based frame reset tests.
#[derive(Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Hash)]
struct TestState {
frame: i32,
}
#[derive(Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq)]
struct TestInput {
inp: u32,
}
struct TestConfig;
impl Config for TestConfig {
type Input = TestInput;
type InputPredictor = ggrs::PredictRepeatLast;
type State = TestState;
type Address = std::net::SocketAddr;
}
fn calculate_hash<T: Hash>(t: &T) -> u64 {
let mut s = DefaultHasher::new();
t.hash(&mut s);
s.finish()
}
struct GameStub {
gs: TestState,
}
impl GameStub {
fn new() -> Self {
GameStub {
gs: TestState { frame: 0 },
}
}
fn handle_requests(&mut self, requests: Vec<GgrsRequest<TestConfig>>) {
for request in requests {
match request {
GgrsRequest::LoadGameState { cell, .. } => {
self.gs = cell.load().expect("No saved state to load");
}
GgrsRequest::SaveGameState { cell, frame } => {
assert_eq!(self.gs.frame, frame);
let checksum = calculate_hash(&self.gs) as u128;
cell.save(frame, Some(self.gs), Some(checksum));
}
GgrsRequest::AdvanceFrame { .. } => {
self.gs.frame += 1;
}
}
}
}
}
fn advance_session(
sess: &mut ggrs::SyncTestSession<TestConfig>,
stub: &mut GameStub,
frames: usize,
num_players: usize,
) {
for i in 0..frames {
for p in 0..num_players {
sess.add_local_input(p, TestInput { inp: i as u32 }).unwrap();
}
let requests = sess.advance_frame().unwrap();
stub.handle_requests(requests);
}
}
/// Test: advance 120 frames, reset to frame 60, verify current_frame() == 60.
#[test]
fn test_reset_frame_after_state_load() {
let num_players = 2;
let mut sess = SessionBuilder::<TestConfig>::new()
.with_num_players(num_players)
.unwrap()
.with_check_distance(0)
.start_synctest_session()
.unwrap();
let mut stub = GameStub::new();
advance_session(&mut sess, &mut stub, 120, num_players);
assert_eq!(sess.current_frame(), 120);
sess.set_current_frame(60);
assert_eq!(sess.current_frame(), 60);
}
/// Test: after resetting the frame, resuming with matching inputs should not
/// produce any desync events.
#[test]
fn test_resume_after_reset_no_desync() {
let num_players = 2;
let mut sess = SessionBuilder::<TestConfig>::new()
.with_num_players(num_players)
.unwrap()
.with_check_distance(0)
.start_synctest_session()
.unwrap();
let mut stub = GameStub::new();
advance_session(&mut sess, &mut stub, 120, num_players);
assert_eq!(sess.current_frame(), 120);
sess.set_current_frame(60);
assert_eq!(sess.current_frame(), 60);
stub.gs.frame = 60;
for i in 0..60 {
for p in 0..num_players {
sess.add_local_input(p, TestInput { inp: (60 + i) as u32 }).unwrap();
}
let requests = sess.advance_frame().unwrap();
assert_eq!(requests.len(), 1);
assert!(matches!(requests[0], GgrsRequest::AdvanceFrame { .. }));
stub.handle_requests(requests);
}
assert_eq!(sess.current_frame(), 120);
assert_eq!(stub.gs.frame, 120);
}
/// Test: reset_frame to frame 0 works correctly.
#[test]
fn test_reset_frame_to_zero() {
let num_players = 1;
let mut sess = SessionBuilder::<TestConfig>::new()
.with_num_players(num_players)
.unwrap()
.with_check_distance(0)
.start_synctest_session()
.unwrap();
let mut stub = GameStub::new();
advance_session(&mut sess, &mut stub, 50, num_players);
assert_eq!(sess.current_frame(), 50);
sess.set_current_frame(0);
assert_eq!(sess.current_frame(), 0);
stub.gs.frame = 0;
sess.add_local_input(0, TestInput { inp: 0 }).unwrap();
let requests = sess.advance_frame().unwrap();
assert_eq!(requests.len(), 1);
stub.handle_requests(requests);
assert_eq!(sess.current_frame(), 1);
}
/// Test: reset_frame can be called multiple times.
#[test]
fn test_reset_frame_idempotent() {
let num_players = 1;
let mut sess = SessionBuilder::<TestConfig>::new()
.with_num_players(num_players)
.unwrap()
.with_check_distance(0)
.start_synctest_session()
.unwrap();
let mut stub = GameStub::new();
advance_session(&mut sess, &mut stub, 100, num_players);
sess.set_current_frame(50);
assert_eq!(sess.current_frame(), 50);
sess.set_current_frame(50);
assert_eq!(sess.current_frame(), 50);
sess.set_current_frame(30);
assert_eq!(sess.current_frame(), 30);
}
}