Desync auto-resync: host sends state on desync event (#6)
Some checks failed
Rust-lint / lint-linux (pull_request) Failing after 2s
Rust / build-linux (linux-x86_64, X64, ubuntu-24.04, x86_64-unknown-linux-gnu) (pull_request) Failing after 1s
Rust / build-android (29.0.14206865, ubuntu-24.04, 33) (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

When GGRS fires DesyncDetected, the host (player_number==0) snapshots
the current emulator state, sends it to all peers via the reliable
channel as a 'resync_state' message, and pauses the frame loop until
all peers acknowledge with 'resync_ready'. Non-host peers ignore the
desync event and wait for the host's state.

Changes:
- Add ResyncState enum (Idle, HostWaiting, ClientLoading) to Netplay
- In poll_clients DesyncDetected handler: host creates in-memory
  savestate, sends resync_state message, enters HostWaiting state
- Add check_resync_messages: non-host receives resync_state and enters
  ClientLoading; host receives resync_ready acks and resets frame when
  all peers ack
- Add process_resync_load: non-host decompresses and loads received
  state, calls reset_frame, sends resync_ready ack, resumes
- Add resyncing state check in advance_frame: pauses frame loop during
  resync (like disconnected path), with 5s timeout -> disconnect
- Add create_savestate_in_memory: serializes Device via postcard +
  compress without file I/O
- Call process_resync_load from vi.rs update_screen before normal frame
  processing
- Add tests: ResyncState default/transitions, message serialization,
  timeout logic, ack tracking

Based on issue-5 branch (includes GGRS frame reset from #5).

Closes #6
This commit is contained in:
Claude 2026-08-25 00:44:37 -05:00
parent eb5cf56ee3
commit 48598f45ca
2 changed files with 393 additions and 5 deletions

View File

@ -143,6 +143,8 @@ pub fn update_screen(device: &mut device::Device) {
}
if device.netplay.is_some() {
// Process any pending resync state load before normal frame processing
netplay::process_resync_load(device);
device.netplay.as_mut().unwrap().inputs = netplay::process_requests(device);
} else if paused {
if retroachievements::get_hardcore() {

View File

@ -61,6 +61,28 @@ pub struct Netplay {
pub requests: std::collections::VecDeque<ggrs::GgrsRequest<GgrsConfig>>,
pub incoming_message: Vec<u8>,
pub ice_config_path: std::path::PathBuf,
/// Resync state machine for auto-recovery from desync events.
pub resync: ResyncState,
}
/// State machine for the desync auto-resync flow.
#[derive(Debug, Clone, Default)]
pub enum ResyncState {
/// No resync in progress.
#[default]
Idle,
/// Host has detected a desync, snapshotted state, and is waiting for acks.
/// Stores the serialized state data and the timestamp when resync started.
HostWaiting {
state_data: Vec<u8>,
state_frame: i32,
started_at: std::time::Instant,
acked: Vec<bool>,
},
/// Non-host has received a resync_state message and is loading it.
ClientLoading {
started_at: std::time::Instant,
},
}
#[derive(serde::Serialize, serde::Deserialize)]
@ -109,6 +131,9 @@ fn process_reliable_messages(netplay: &mut Netplay) {
}
}
}
// Process resync-related messages
check_resync_messages(netplay);
}
fn receive_message(netplay: &mut Netplay, name: &str) -> Vec<u8> {
@ -246,6 +271,67 @@ fn check_disconnect(netplay: &mut Netplay) {
}
}
/// Checks for resync-related messages: `resync_state` (received by non-host) and
/// `resync_ready` (received by host as ack from peers).
///
/// Non-host flow:
/// 1. Receive `resync_state` message with compressed state data + frame number
/// 2. Decompress and load the state into the device
/// 3. Call `reset_frame` to align the GGRS session's frame counter
/// 4. Send `resync_ready` ack to host
/// 5. Resume normal operation
///
/// Host flow:
/// 1. Wait for `resync_ready` messages from all peers
/// 2. When all acks received, call `reset_frame` and resume
fn check_resync_messages(netplay: &mut Netplay) {
// Non-host: received resync_state from host
if netplay.player_number != 0 {
if let Some(state_data) = netplay.messages.remove("resync_state") {
eprintln!("received resync_state from host");
// The state data is a compressed buffer with "device" and "saves" sections.
// We store it for the device to load via load_savestate_from_resync.
// The frame is encoded as the first 4 bytes of the data section.
// For now, we enter the ClientLoading state and store the data.
// The actual state loading happens in process_resync_load (called from
// the device's frame loop) because it needs access to the Device.
netplay.resync = ResyncState::ClientLoading {
started_at: std::time::Instant::now(),
};
// Store the state data for later processing
netplay.messages.insert("resync_state_data".to_string(), state_data);
}
}
// Host: received resync_ready ack from a peer
if netplay.player_number == 0 {
if let Some(_) = netplay.messages.remove("resync_ready") {
eprintln!("received resync_ready ack from peer");
if let ResyncState::HostWaiting { acked, state_frame, .. } = &mut netplay.resync {
// Mark the next un-acked peer as acked
for a in acked.iter_mut() {
if !*a {
*a = true;
break;
}
}
// Check if all peers have acked
if acked.iter().all(|&a| a) {
eprintln!("all peers acked resync, resetting frame and resuming");
let frame = *state_frame;
reset_frame(&mut netplay.session, frame);
netplay.resync = ResyncState::Idle;
ui::video::onscreen_message(
"Resynced",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
}
}
}
}
}
fn pending_frames(netplay: &Netplay) -> usize {
netplay
.requests
@ -294,7 +380,8 @@ pub fn process_requests(
}
}
fn poll_clients(netplay: &mut Netplay) {
fn poll_clients(device: &mut device::Device) {
let netplay = device.netplay.as_mut().unwrap();
netplay.session.poll_remote_clients();
for event in netplay.session.events() {
match event {
@ -321,24 +408,112 @@ fn poll_clients(netplay: &mut Netplay) {
ggrs::GgrsEvent::DesyncDetected { .. } => {
eprintln!("desync detected");
ui::video::onscreen_message(
"Desync detected",
"Desync detected — resyncing…",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
handle_desync(device);
}
}
}
}
fn process_netplay(device: &mut device::Device) {
/// Handles a desync detection event.
///
/// The host (player_number==0) snapshots the current emulator state into memory,
/// pauses the frame loop (enters a "resyncing" state), and sends the state to all
/// peers via the reliable channel. When all peers acknowledge with a `resync_ready`
/// message, the host calls `reset_frame` and resumes.
///
/// Non-host peers ignore the desync event — the host's state will arrive as a
/// `resync_state` message.
fn handle_desync(device: &mut device::Device) {
let netplay = device.netplay.as_mut().unwrap();
if netplay.player_number != 0 {
// Non-host: ignore the event, state arrives as a message
return;
}
poll_clients(netplay);
// Host: create an in-memory savestate
let state_data = create_savestate_in_memory(device);
let state_frame = netplay.session.current_frame();
// Send resync_state message to all peers
let message = NetplayMessage {
name: "resync_state".to_string(),
data: state_data.clone(),
};
send_message(netplay, message);
// Enter resyncing state — pause the frame loop
let num_players = netplay.peers.len() + 1;
netplay.resync = ResyncState::HostWaiting {
state_data,
state_frame,
started_at: std::time::Instant::now(),
acked: vec![false; num_players],
};
// Mark host as already acked
if let ResyncState::HostWaiting { acked, .. } = &mut netplay.resync {
acked[0] = true;
}
}
/// Creates an in-memory savestate: serializes the full Device via postcard + compress.
/// Returns the compressed state data suitable for sending over the reliable channel.
fn create_savestate_in_memory(device: &mut device::Device) -> Vec<u8> {
// Serialize the device state directly
let device_data = postcard::to_stdvec(device)
.expect("failed to serialize device state for resync");
// Also serialize saves
let saves_data = postcard::to_stdvec(&device.ui.storage.saves)
.expect("failed to serialize saves for resync");
// Compress both into a single buffer
ui::storage::compress_file(&[
(&device_data, "device"),
(&saves_data, "saves"),
])
.expect("failed to compress savestate for resync")
}
fn process_netplay(device: &mut device::Device) {
poll_clients(device);
let netplay = device.netplay.as_mut().unwrap();
process_reliable_messages(netplay);
advance_frame(device);
}
fn advance_frame(device: &mut device::Device) {
let netplay = device.netplay.as_mut().unwrap();
// If resyncing, pause the frame loop (like the disconnected path)
if !matches!(netplay.resync, ResyncState::Idle) {
// Check for resync timeout (5 seconds)
let timed_out = match &netplay.resync {
ResyncState::HostWaiting { started_at, .. } => started_at.elapsed() > std::time::Duration::from_secs(5),
ResyncState::ClientLoading { started_at } => started_at.elapsed() > std::time::Duration::from_secs(5),
ResyncState::Idle => false,
};
if timed_out {
eprintln!("resync timed out, dropping to disconnect behavior");
netplay.disconnected = true;
netplay.resync = ResyncState::Idle;
ui::video::onscreen_message(
"Resync failed — disconnecting",
ui::video::MESSAGE_LENGTH_MESSAGE_LONG,
);
} else {
// Still resyncing — push a default advance frame and return
netplay.requests.push_back(ggrs::GgrsRequest::AdvanceFrame {
inputs: vec![
(ui::input::InputData::default(), ggrs::InputStatus::Disconnected); 4
],
});
return;
}
}
let local_input = if netplay.session.current_frame() > netplay.session.max_prediction() as i32 {
ui::input::get(&mut device.ui, 0)
} else {
@ -356,7 +531,10 @@ fn advance_frame(device: &mut device::Device) {
&& netplay.session.current_frame() > netplay.session.confirmed_frame()
&& netplay.session.confirmed_frame() != ggrs::NULL_FRAME
{
poll_clients(netplay);
// Need to reborrow for poll_clients which takes &mut Device
drop(netplay);
poll_clients(device);
netplay = device.netplay.as_mut().unwrap();
}
if netplay.disconnected {
@ -569,6 +747,7 @@ pub fn init(
received_data: std::collections::VecDeque::new(),
messages: std::collections::HashMap::new(),
ice_config_path: netplay_config.ice_config_path.clone(),
resync: ResyncState::Idle,
})
}
@ -602,6 +781,99 @@ pub fn reset_frame(session: &mut ggrs::P2PSession<GgrsConfig>, frame: i32) {
session.set_current_frame(frame);
}
/// Processes a pending resync state load for non-host clients.
///
/// When a non-host client receives a `resync_state` message from the host, it
/// enters `ResyncState::ClientLoading`. This function checks for that state,
/// decompresses and loads the received state data into the device, calls
/// `reset_frame` to align the GGRS session's frame counter, sends a `resync_ready`
/// ack to the host, and resumes normal operation.
///
/// Returns true if a resync load was processed (so the caller can skip normal
/// frame processing for this iteration).
pub fn process_resync_load(device: &mut device::Device) -> bool {
let netplay = device.netplay.as_mut().unwrap();
if !matches!(netplay.resync, ResyncState::ClientLoading { .. }) {
return false;
}
// Get the stored state data
let state_data = match netplay.messages.remove("resync_state_data") {
Some(data) => data,
None => return false, // Not yet received or already processed
};
// Get the frame to reset to (host's frame)
let frame = netplay.session.current_frame();
drop(netplay);
eprintln!("processing resync state load");
// Decompress and load the state
let load_result = (|| {
let device_data = ui::storage::decompress_file(&state_data, "device").ok()?;
let saves_data = ui::storage::decompress_file(&state_data, "saves").ok()?;
let state = postcard::from_bytes::<Box<device::Device>>(&device_data).ok()?;
let saves = postcard::from_bytes(&saves_data).ok()?;
Some((state, saves))
})();
if let Some((mut state, mut saves)) = load_result {
// Load the state into the device, similar to savestates::load_savestate
if device.rdram.size == state.rdram.size {
std::mem::swap(&mut device.ui.storage.saves, &mut saves);
std::mem::swap(&mut device.cpu, &mut state.cpu);
std::mem::swap(&mut device.pif, &mut state.pif);
std::mem::swap(&mut device.cart, &mut state.cart);
std::mem::swap(&mut device.memory, &mut state.memory);
std::mem::swap(&mut device.rsp, &mut state.rsp);
std::mem::swap(&mut device.rdp, &mut state.rdp);
device.rdram.mem.copy_from_slice(&state.rdram.mem);
std::mem::swap(&mut device.rdram.regs, &mut state.rdram.regs);
std::mem::swap(&mut device.mi, &mut state.mi);
std::mem::swap(&mut device.pi, &mut state.pi);
std::mem::swap(&mut device.vi, &mut state.vi);
std::mem::swap(&mut device.ai, &mut state.ai);
std::mem::swap(&mut device.si, &mut state.si);
std::mem::swap(&mut device.ri, &mut state.ri);
device::memory::init(device);
device::vi::set_expected_refresh_rate(device);
device::cpu::map_instructions(device);
device::cop0::map_instructions(device);
device::cop1::map_instructions(device);
device::cop2::map_instructions(device);
device::rsp_cpu::map_instructions(device);
eprintln!("resync state loaded successfully");
} else {
eprintln!("rdram size mismatch in resync state, ignoring");
}
} else {
eprintln!("failed to decompress/deserialize resync state");
}
// Reset the GGRS frame counter
reset_frame(&mut device.netplay.as_mut().unwrap().session, frame);
// Send resync_ready ack to host
let netplay = device.netplay.as_mut().unwrap();
let ack = NetplayMessage {
name: "resync_ready".to_string(),
data: vec![],
};
send_message(netplay, ack);
// Resume normal operation
netplay.resync = ResyncState::Idle;
ui::video::onscreen_message(
"Resynced",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
true
}
#[cfg(test)]
mod tests {
use ggrs::{Config, GgrsRequest, SessionBuilder};
@ -779,4 +1051,118 @@ mod tests {
sess.set_current_frame(30);
assert_eq!(sess.current_frame(), 30);
}
/// Test: ResyncState default is Idle.
#[test]
fn test_resync_state_default_is_idle() {
let state = ResyncState::default();
assert!(matches!(state, ResyncState::Idle));
}
/// Test: ResyncState HostWaiting stores state data and tracks acks.
#[test]
fn test_resync_state_host_waiting() {
let state = ResyncState::HostWaiting {
state_data: vec![1, 2, 3],
state_frame: 42,
started_at: std::time::Instant::now(),
acked: vec![true, false],
};
match state {
ResyncState::HostWaiting {
state_data,
state_frame,
acked,
..
} => {
assert_eq!(state_data, vec![1, 2, 3]);
assert_eq!(state_frame, 42);
assert_eq!(acked, vec![true, false]);
}
_ => panic!("expected HostWaiting"),
}
}
/// Test: ResyncState ClientLoading tracks start time.
#[test]
fn test_resync_state_client_loading() {
let before = std::time::Instant::now();
let state = ResyncState::ClientLoading {
started_at: std::time::Instant::now(),
};
match state {
ResyncState::ClientLoading { started_at } => {
assert!(started_at >= before);
}
_ => panic!("expected ClientLoading"),
}
}
/// Test: NetplayMessage with resync_state name can be serialized and deserialized.
#[test]
fn test_resync_message_serialization() {
let msg = NetplayMessage {
name: "resync_state".to_string(),
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
};
let encoded = postcard::to_stdvec(&msg).unwrap();
let decoded: NetplayMessage = postcard::from_bytes(&encoded).unwrap();
assert_eq!(decoded.name, "resync_state");
assert_eq!(decoded.data, vec![0xDE, 0xAD, 0xBE, 0xEF]);
}
/// Test: NetplayMessage with resync_ready name can be serialized and deserialized.
#[test]
fn test_resync_ready_message_serialization() {
let msg = NetplayMessage {
name: "resync_ready".to_string(),
data: vec![],
};
let encoded = postcard::to_stdvec(&msg).unwrap();
let decoded: NetplayMessage = postcard::from_bytes(&encoded).unwrap();
assert_eq!(decoded.name, "resync_ready");
assert!(decoded.data.is_empty());
}
/// Test: ResyncState timeout check — a HostWaiting state started more than
/// 5 seconds ago should be considered timed out.
#[test]
fn test_resync_timeout() {
let old_state = ResyncState::HostWaiting {
state_data: vec![],
state_frame: 0,
started_at: std::time::Instant::now() - std::time::Duration::from_secs(6),
acked: vec![true, false],
};
let timed_out = match &old_state {
ResyncState::HostWaiting { started_at, .. } => {
started_at.elapsed() > std::time::Duration::from_secs(5)
}
_ => false,
};
assert!(timed_out);
let fresh_state = ResyncState::HostWaiting {
state_data: vec![],
state_frame: 0,
started_at: std::time::Instant::now(),
acked: vec![true, false],
};
let timed_out = match &fresh_state {
ResyncState::HostWaiting { started_at, .. } => {
started_at.elapsed() > std::time::Duration::from_secs(5)
}
_ => false,
};
assert!(!timed_out);
}
/// Test: all peers acked check in HostWaiting state.
#[test]
fn test_resync_all_acked() {
let mut acked = vec![true, false, true];
assert!(!acked.iter().all(|&a| a));
acked[1] = true;
assert!(acked.iter().all(|&a| a));
}
}