From adeb97d15f9e9ff0525555ab9eb3394638793c59 Mon Sep 17 00:00:00 2001 From: Georg Schuppe Date: Wed, 24 Jun 2026 16:29:47 +0200 Subject: [PATCH] Restore lockstep frame semantics --- CHANGELOG.md | 3 +- docs/main-loop.md | 16 ++- docs/sessions.md | 4 +- examples/ex_game/ex_game_p2p.rs | 11 +- src/input_queue.rs | 4 - src/sessions/builder.rs | 24 ++-- src/sessions/p2p_session.rs | 191 +++++++++++++++++--------------- src/sync_layer.rs | 11 -- tests/test_p2p_session.rs | 57 ++++++---- 9 files changed, 177 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd60424..ad598fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ In this document, all notable changes are listed, including bug fixes, breaking - breaking: `NetworkStats::kbps_sent` has been removed; `network_stats()` now reports queue length, RTT, and frame-advantage data only ### Bug fixes -- fix: lockstep mode (`max_prediction = 0`) now requires only one-way latency worth of input delay instead of round-trip; `advance_frame` now unconditionally advances the input queue (keeping packets flowing) while the game frame only steps forward when all players have confirmed inputs, so a single network one-way trip is sufficient to confirm and advance each game frame (fixes [#116](https://github.com/gschup/ggrs/issues/116)) +- fix: lockstep mode (`max_prediction = 0`) now keeps `current_frame()` aligned with emitted game frames and no longer advances an internal input queue on stalls; `advance_frame` remains conservative and may require enough input delay to cover polling/scheduling jitter, while the new `P2PSession::advance_frame_with_wait` helper can poll briefly before returning an empty request list (follow-up for [#116](https://github.com/gschup/ggrs/issues/116)) - fix: desync detection no longer panics with sparse saving when the configured checksum interval frame was not saved exactly (fixes [#107](https://github.com/gschup/ggrs/issues/107)) - fix: malformed input packets are now discarded with warnings instead of panicking when connection status lengths, start frames, compressed payloads, or decoded player input shapes are invalid - fix: increasing input delay mid-session now sends generated gap-fill inputs to remote peers, and the first outgoing packet from a peer using input delay is no longer incorrectly rejected; both issues would freeze the session @@ -20,6 +20,7 @@ In this document, all notable changes are listed, including bug fixes, breaking ### Improvements - feat: `P2PSession::set_input_delay()` allows changing the input delay for a local player mid-session (closes [#106](https://github.com/gschup/ggrs/issues/106)) +- feat: `P2PSession::advance_frame_with_wait()` and `advance_frame_with_wait_timeout()` provide an explicit bounded wait for lockstep stalls, reducing the need for users to hand-roll a busy polling loop ### Documentation - docs: refreshed session setup and runtime guidance for builder validation, runtime input-delay changes, spectator catch-up settings, network stats warmup errors, and checksum-based desync detection diff --git a/docs/main-loop.md b/docs/main-loop.md index 6a963d3..3a335f4 100644 --- a/docs/main-loop.md +++ b/docs/main-loop.md @@ -60,21 +60,24 @@ time_since_last_frame += last_tick.elapsed(); last_tick = Instant::now(); while time_since_last_frame >= fps_delta { - time_since_last_frame -= fps_delta; - // add input once for each local player handle session.add_local_input(local_handle, current_input)?; match session.advance_frame() { Ok(requests) => { + if requests.is_empty() && session.in_lockstep_mode() { + // lockstep stall: no game frame was emitted, so keep the + // accumulated time and retry after more network polling + break; + } + + time_since_last_frame -= fps_delta; for request in requests { handle_ggrs_request(request); } - // In lockstep mode, requests may be empty — remote inputs have not - // arrived yet. This is not an error; the session will advance on the - // next tick once poll_remote_clients delivers the missing inputs. } Err(GgrsError::PredictionThreshold) => { + time_since_last_frame -= fps_delta; // remote peer is too far behind; skip this frame (rollback mode only) } Err(e) => return Err(e), @@ -82,6 +85,9 @@ while time_since_last_frame >= fps_delta { } ``` +For lockstep mode, `P2PSession::advance_frame_with_wait()` can reduce poll-phase stalls by polling +for a bounded amount of time before returning an empty request list. + ## Time Synchronization When your client runs ahead of remote peers, you accumulate one-sided rollbacks. GGRS provides two tools to compensate: diff --git a/docs/sessions.md b/docs/sessions.md index 15e4925..4ce994a 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -69,8 +69,8 @@ let mut session = SessionBuilder::::new() |---|---|---| | `with_num_players(n)` | 2 | Total number of players (not counting spectators). Must be at least 1. Revalidates already-added handles. | | `with_fps(fps)` | 60 | Expected update frequency. Used for frame synchronization heuristics. | -| `with_input_delay(n)` | 0 | Frames of artificial delay applied to local input. Reduces rollbacks in rollback mode. In lockstep mode, set this to at least `ceil(one_way_latency_in_frames)` so remote inputs arrive before they are needed. | -| `with_max_prediction_window(n)` | 8 | Maximum frames GGRS will predict ahead. Set to `0` for lockstep mode: no prediction, no rollbacks, game stalls until all remote inputs are confirmed. | +| `with_input_delay(n)` | 0 | Frames of artificial delay applied to local input. Reduces rollbacks in rollback mode. In lockstep mode, this schedules local input ahead while the public session frame remains the game frame. | +| `with_max_prediction_window(n)` | 8 | Maximum frames GGRS will predict ahead. Set to `0` for conservative lockstep mode: no prediction, no rollbacks, game stalls until all remote inputs are confirmed. Use `P2PSession::advance_frame_with_wait` for a bounded wait that can reduce poll-phase stalls. | | `with_sparse_saving_mode(bool)` | false | Only save state at the last confirmed frame. See [Sparse Saving](sparse-saving.md). | | `with_desync_detection_mode(mode)` | Off | Enable checksum-based desync detection. `DesyncDetection::On` requires an interval higher than 0. See [`DesyncDetection`](https://docs.rs/ggrs/latest/ggrs/enum.DesyncDetection.html). | | `with_disconnect_timeout(duration)` | 2s | How long without packets before a remote peer is disconnected. | diff --git a/examples/ex_game/ex_game_p2p.rs b/examples/ex_game/ex_game_p2p.rs index 0aead03..61aea8e 100644 --- a/examples/ex_game/ex_game_p2p.rs +++ b/examples/ex_game/ex_game_p2p.rs @@ -119,7 +119,8 @@ async fn main() -> Result<(), Box> { // if enough time is accumulated, we run a frame while accumulator.as_secs_f64() > fps_delta { // decrease accumulator - accumulator = accumulator.saturating_sub(Duration::from_secs_f64(fps_delta)); + let frame_duration = Duration::from_secs_f64(fps_delta); + accumulator = accumulator.saturating_sub(frame_duration); // frames are only happening if the sessions are synchronized if sess.current_state() == SessionState::Running { @@ -129,7 +130,13 @@ async fn main() -> Result<(), Box> { } match sess.advance_frame() { - Ok(requests) => game.handle_requests(requests), + Ok(requests) => { + if requests.is_empty() && sess.in_lockstep_mode() { + accumulator = accumulator.saturating_add(frame_duration); + break; + } + game.handle_requests(requests); + } Err(e) => return Err(Box::new(e)), } } diff --git a/src/input_queue.rs b/src/input_queue.rs index 92e839f..2aa6ef4 100644 --- a/src/input_queue.rs +++ b/src/input_queue.rs @@ -68,10 +68,6 @@ impl InputQueue { self.first_incorrect_frame } - pub(crate) fn frame_delay(&self) -> usize { - self.frame_delay - } - /// Changes the frame delay and returns any fill inputs that were implicitly added to bridge the /// gap. The caller is responsible for sending these to remote peers so they see consecutive /// frame numbers. diff --git a/src/sessions/builder.rs b/src/sessions/builder.rs index 5cd29f1..8b80c3d 100644 --- a/src/sessions/builder.rs +++ b/src/sessions/builder.rs @@ -148,10 +148,13 @@ impl SessionBuilder { /// - `AdvanceFrame` is only emitted once all remote inputs for the current frame are confirmed. /// - `SaveGameState` and `LoadGameState` are never emitted — no rollback occurs. /// - /// In lockstep mode the game stalls until remote inputs arrive, so smooth playback requires - /// enough input delay to cover the one-way network trip. Set - /// `with_input_delay(ceil(one_way_latency_in_frames))` — roughly half the round-trip time. - /// The semantics match rollback mode: input pressed on frame `F` takes effect on frame `F + D`. + /// Plain lockstep mode is conservative: the game stalls until remote inputs are observed by + /// the next call to [`P2PSession::advance_frame`]. Smooth playback may therefore require input + /// delay that covers network latency plus polling/scheduling jitter. For a bounded wait that + /// polls during the current tick, use [`P2PSession::advance_frame_with_wait`]. + /// + /// Input-delay semantics match rollback mode: input pressed on frame `F` takes effect on frame + /// `F + D`. pub fn with_max_prediction_window(mut self, window: usize) -> Self { self.max_prediction = window; self @@ -162,14 +165,14 @@ impl SessionBuilder { /// In rollback mode, input delay (typically 2–4 frames) reduces rollbacks by letting remote /// inputs arrive before they are needed, at the cost of constant perceived latency. /// - /// In lockstep mode, input delay determines how far ahead the input queue runs relative to - /// the confirmed game frame. Set this to at least `ceil(one_way_latency_in_frames)` — - /// roughly half the round-trip time — so remote inputs arrive before the game needs them - /// and the session can advance without stalling. + /// In lockstep mode, input delay determines how far ahead local input is scheduled, but the + /// public session frame remains the game frame. Without [`P2PSession::advance_frame_with_wait`], + /// smooth playback may require enough delay to cover network latency plus polling/scheduling + /// jitter. /// /// There is no enforced upper bound, but values above ~8 frames will produce noticeable - /// input lag. Setting this higher than `max_prediction_window` is not recommended — - /// inputs delayed beyond the prediction window will stall the session. + /// input lag. In rollback mode, setting this higher than `max_prediction_window` is not + /// recommended because inputs delayed beyond the prediction window will stall the session. pub fn with_input_delay(mut self, delay: usize) -> Self { self.input_delay = delay; self @@ -372,6 +375,7 @@ impl SessionBuilder { self.sparse_saving, self.desync_detection, self.input_delay, + self.fps, )) } diff --git a/src/sessions/p2p_session.rs b/src/sessions/p2p_session.rs index db34654..6de8c97 100644 --- a/src/sessions/p2p_session.rs +++ b/src/sessions/p2p_session.rs @@ -12,6 +12,7 @@ use crate::{ }; use tracing::{debug, trace, warn}; +use instant::{Duration, Instant}; use std::collections::vec_deque::Drain; use std::collections::VecDeque; use std::collections::{BTreeMap, HashMap}; @@ -124,13 +125,10 @@ where /// If we receive a disconnect from another client, we have to rollback from that frame on in order to prevent wrong predictions disconnect_frame: Frame, - /// In lockstep mode, the game frame that still needs to be confirmed and emitted. - /// Starts at 0 and increments each time an AdvanceFrame request is pushed to the user. - /// Separate from sync_layer.current_frame(), which advances unconditionally every call. - lockstep_game_frame: Frame, - /// Internal State of the Session. state: SessionState, + /// Expected update frequency. Used to bound the optional lockstep wait helper. + fps: usize, /// The [`P2PSession`] uses this socket to send and receive all messages for remote players. socket: Box>, @@ -166,6 +164,7 @@ where impl P2PSession { /// Creates a new [`P2PSession`] for players who participate on the game input. After creating the session, add local and remote players, /// set input delay for local players and then start the session. The session will use the provided socket. + #[allow(clippy::too_many_arguments)] pub(crate) fn new( num_players: usize, max_prediction: usize, @@ -174,6 +173,7 @@ impl P2PSession { sparse_saving: bool, desync_detection: DesyncDetection, input_delay: usize, + fps: usize, ) -> Self { // local connection status let mut local_connect_status = Vec::new(); @@ -213,6 +213,7 @@ impl P2PSession { state, num_players, max_prediction, + fps, sparse_saving, socket, local_connect_status, @@ -221,7 +222,6 @@ impl P2PSession { frames_ahead: 0, sync_layer, disconnect_frame: NULL_FRAME, - lockstep_game_frame: 0, player_reg: players, event_queue: VecDeque::new(), pending_local_inputs: HashMap::new(), @@ -270,9 +270,10 @@ impl P2PSession { /// call it separately on the same tick. /// /// In **lockstep mode** (`max_prediction = 0`), the returned vec may contain no - /// `AdvanceFrame` request if remote inputs have not yet arrived — the session stalls until - /// all peers confirm the current frame. Call [`poll_remote_clients`] and retry on the next - /// tick. Unlike rollback mode this is not an error; it is the normal wait behaviour. + /// `AdvanceFrame` request if remote inputs have not yet arrived — the session stalls without + /// consuming a game frame. Call [`poll_remote_clients`] and retry on the next tick, or use + /// [`advance_frame_with_wait`] to poll for a bounded amount of time before returning. Unlike + /// rollback mode this is not an error; it is the normal wait behaviour. /// /// # Errors /// - Returns [`InvalidRequest`] if a local input is missing for any registered local player. @@ -280,6 +281,7 @@ impl P2PSession { /// - Returns [`PredictionThreshold`] (rollback mode only) if the remote peer is too far behind. /// /// [`poll_remote_clients`]: Self::poll_remote_clients + /// [`advance_frame_with_wait`]: Self::advance_frame_with_wait /// [`Vec`]: GgrsRequest /// [`InvalidRequest`]: GgrsError::InvalidRequest /// [`NotSynchronized`]: GgrsError::NotSynchronized @@ -287,7 +289,56 @@ impl P2PSession { pub fn advance_frame(&mut self) -> Result>, GgrsError> { // receive info from remote players, trigger events and send messages self.poll_remote_clients(); + self.advance_frame_after_poll() + } + + /// Advance the session by one frame, waiting briefly for lockstep confirmation before + /// returning an empty request list. + /// + /// This is only different from [`advance_frame`] in lockstep mode (`max_prediction = 0`). + /// If the first advance attempt stalls because the current game frame is not confirmed yet, + /// GGRS keeps polling the non-blocking socket until either the frame becomes confirmed or one + /// frame duration (derived from the session's configured FPS) has elapsed. + /// + /// In rollback mode this behaves exactly like [`advance_frame`]. + /// + /// [`advance_frame`]: Self::advance_frame + pub fn advance_frame_with_wait(&mut self) -> Result>, GgrsError> { + let micros = (1_000_000_u64 / self.fps as u64).max(1); + self.advance_frame_with_wait_timeout(Duration::from_micros(micros)) + } + + /// Advance the session by one frame, waiting up to `timeout` for lockstep confirmation. + /// + /// This helper makes the lockstep wait explicit. It may spin-poll for up to `timeout`, so use + /// it only when a short bounded wait is appropriate for your platform and game loop. Passing a + /// zero timeout is equivalent to [`advance_frame`]. + /// + /// [`advance_frame`]: Self::advance_frame + pub fn advance_frame_with_wait_timeout( + &mut self, + timeout: Duration, + ) -> Result>, GgrsError> { + self.poll_remote_clients(); + let requests = self.advance_frame_after_poll()?; + + if !self.in_lockstep_mode() || !requests.is_empty() || timeout == Duration::from_micros(0) { + return Ok(requests); + } + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + self.poll_remote_clients(); + if self.lockstep_current_frame_confirmed() { + return self.advance_frame_after_poll(); + } + Self::yield_lockstep_wait(); + } + + Ok(requests) + } + fn advance_frame_after_poll(&mut self) -> Result>, GgrsError> { // session is not running and synchronized if self.state != SessionState::Running { trace!("Session not synchronized; returning error"); @@ -340,25 +391,12 @@ impl P2PSession { // propagate disconnects to multiple players self.update_player_disconnects(); - // find the confirmed frame for which we received all inputs - let confirmed_frame = self.confirmed_frame(); - - // check game consistency and roll back, if necessary - if !lockstep { - self.handle_rollback_and_save(confirmed_frame, &mut requests); + if lockstep { + self.advance_lockstep_frame(&mut requests); + } else { + self.advance_rollback_frame(&mut requests); } - /* - * SEND OFF AND THROW AWAY INPUTS BEFORE THE CONFIRMED FRAME - */ - - // send confirmed inputs to spectators before throwing them away - self.send_confirmed_inputs_to_spectators(confirmed_frame); - - // set the last confirmed frame and discard all saved inputs before that frame - self.sync_layer - .set_last_confirmed_frame(confirmed_frame, self.sparse_saving); - /* * WAIT RECOMMENDATION */ @@ -366,23 +404,6 @@ impl P2PSession { // check time sync between clients and send wait recommendation, if appropriate self.check_wait_recommendation(); - /* - * INPUTS - */ - - if lockstep { - // In lockstep mode the input queue may be capped (remote unresponsive): only - // register inputs when the queue has room, so the user re-submits next call with - // the same frame number — matching rollback behaviour at the prediction threshold. - if !self.lockstep_input_queue_at_cap() { - self.register_local_inputs(); - } - self.advance_lockstep_frame(&mut requests); - } else { - self.register_local_inputs(); - self.advance_rollback_frame(&mut requests); - } - Ok(requests) } @@ -556,30 +577,13 @@ impl P2PSession { confirmed_frame } - /// Returns true if the lockstep input queue has reached its maximum depth and should not - /// accept more inputs until the remote catches up. - fn lockstep_input_queue_at_cap(&self) -> bool { - let depth = self.sync_layer.current_frame() - self.lockstep_game_frame; - let local_handles = self.player_reg.local_player_handles(); - depth > self.sync_layer.min_local_input_delay(&local_handles) as i32 + fn lockstep_current_frame_confirmed(&self) -> bool { + self.confirmed_frame() >= self.sync_layer.current_frame() } - /// Returns the highest frame confirmed by all remote players only. - /// In lockstep mode this is the gate for advancing a game frame: the local player's - /// status is excluded because local inputs are always registered unconditionally, - /// so including them in the min would cause a deadlock. - fn remote_confirmed_frame(&self) -> Frame { - let remote_handles = self.player_reg.remote_player_handles(); - if remote_handles.is_empty() { - // all-local session: every frame is trivially confirmed - return self.sync_layer.current_frame(); - } - remote_handles - .iter() - .filter(|&&h| !self.local_connect_status[h].disconnected) - .map(|&h| self.local_connect_status[h].last_frame) - .min() - .unwrap_or(NULL_FRAME) + fn yield_lockstep_wait() { + #[cfg(not(target_arch = "wasm32"))] + std::thread::yield_now(); } /// Returns the current frame of a session. @@ -596,7 +600,7 @@ impl P2PSession { /// /// In lockstep mode, a session will only advance if the current frame has inputs confirmed from /// all other players. - pub fn in_lockstep_mode(&mut self) -> bool { + pub fn in_lockstep_mode(&self) -> bool { self.max_prediction == 0 } @@ -803,30 +807,14 @@ impl P2PSession { self.state = SessionState::Running; } - /// Lockstep advance: the input queue always moves forward so packets keep flowing, but - /// the game frame only steps when all remote players have confirmed it. - /// - /// `sync_layer.current_frame` (the input queue frame) advances unconditionally every call. - /// This breaks the bootstrap deadlock: with input_delay D ≥ one-way latency L, side A - /// commits frame D on its first call; the packet reaches side B after L frames, so both - /// sides can confirm game frame 0 after a single one-way trip. `confirmed_inputs` is used - /// instead of `synchronized_inputs` so no prediction ever occurs and no rollback is needed. + /// Lockstep advance: local delayed input is sent, but the game frame only steps when all + /// players have confirmed the current game frame. No prediction occurs, and the sync frame + /// remains the public game frame. fn advance_lockstep_frame(&mut self, requests: &mut Vec>) { - if self.lockstep_input_queue_at_cap() { - let queue_depth = self.sync_layer.current_frame() - self.lockstep_game_frame; - warn!( - "Lockstep input queue cap reached: remote has not confirmed frame {} \ - after {} queued frames. Remote may be unresponsive.", - self.lockstep_game_frame, queue_depth, - ); - } else { - self.sync_layer.advance_frame(); - self.pending_local_inputs.clear(); - } + self.register_local_inputs(); - let game_frame = self.lockstep_game_frame; - let remote_confirmed = self.remote_confirmed_frame(); - if remote_confirmed >= game_frame { + let game_frame = self.sync_layer.current_frame(); + if self.lockstep_current_frame_confirmed() { let inputs = self .sync_layer .confirmed_inputs(game_frame, &self.local_connect_status) @@ -847,14 +835,22 @@ impl P2PSession { } }) .collect(); - self.lockstep_game_frame += 1; + self.sync_layer.advance_frame(); + self.pending_local_inputs.clear(); requests.push(GgrsRequest::AdvanceFrame { inputs }); } else { debug!( "Lockstep stall: waiting for confirmation of frame {} (confirmed up to {})", - game_frame, remote_confirmed, + game_frame, + self.confirmed_frame(), ); } + + let consumed_frame = self.sync_layer.current_frame() - 1; + let bookkeeping_frame = std::cmp::min(self.confirmed_frame(), consumed_frame); + self.send_confirmed_inputs_to_spectators(bookkeeping_frame); + self.sync_layer + .set_last_confirmed_frame(bookkeeping_frame, self.sparse_saving); } /// Rollback advance: inputs are predicted and corrected on mismatch. The session may run @@ -862,6 +858,21 @@ impl P2PSession { /// NULL_FRAME for `last_confirmed_frame` is treated as 0 frames ahead so prediction can /// start immediately from frame 0 without any prior confirmation. fn advance_rollback_frame(&mut self, requests: &mut Vec>) { + // find the confirmed frame for which we received all inputs + let confirmed_frame = self.confirmed_frame(); + + // check game consistency and roll back, if necessary + self.handle_rollback_and_save(confirmed_frame, requests); + + // send confirmed inputs to spectators before throwing them away + self.send_confirmed_inputs_to_spectators(confirmed_frame); + + // set the last confirmed frame and discard all saved inputs before that frame + self.sync_layer + .set_last_confirmed_frame(confirmed_frame, self.sparse_saving); + + self.register_local_inputs(); + let frames_ahead = if self.sync_layer.last_confirmed_frame() == NULL_FRAME { self.sync_layer.current_frame() } else { diff --git a/src/sync_layer.rs b/src/sync_layer.rs index 4360f84..77afd00 100644 --- a/src/sync_layer.rs +++ b/src/sync_layer.rs @@ -219,17 +219,6 @@ impl SyncLayer { self.input_queues[player_handle].set_frame_delay(delay) } - /// Returns the minimum input delay across the given local player handles. - /// Remote queues always have delay 0 and must be excluded; only local queues - /// have a meaningful delay configured by the user. - pub(crate) fn min_local_input_delay(&self, local_handles: &[PlayerHandle]) -> usize { - local_handles - .iter() - .map(|&h| self.input_queues[h].frame_delay()) - .min() - .unwrap_or(0) - } - pub(crate) fn reset_prediction(&mut self) { for i in 0..self.num_players { self.input_queues[i].reset_prediction(); diff --git a/tests/test_p2p_session.rs b/tests/test_p2p_session.rs index 6452af5..b071fb9 100644 --- a/tests/test_p2p_session.rs +++ b/tests/test_p2p_session.rs @@ -4,6 +4,7 @@ use ggrs::{ DesyncDetection, GgrsError, GgrsEvent, PlayerType, SessionBuilder, SessionState, UdpNonBlockingSocket, }; +use instant::Duration; use serial_test::serial; use stubs::{StubConfig, StubInput}; @@ -787,6 +788,30 @@ fn test_lockstep_stalls_without_remote_input() -> Result<(), GgrsError> { Ok(()) } +#[test] +#[serial] +fn test_lockstep_wait_helper_stalls_without_remote_input() -> Result<(), GgrsError> { + let (mut sess1, mut sess2) = stubs::make_lockstep_sessions(7743, 7744, 1); + stubs::sync_p2p_sessions(&mut sess1, &mut sess2); + + sess1.add_local_input(0, StubInput { inp: 0 })?; + let requests = sess1.advance_frame_with_wait_timeout(Duration::from_millis(1))?; + + assert!( + requests.is_empty(), + "bounded wait helper should still stall when no remote input arrives" + ); + assert_eq!( + sess1.current_frame(), + 0, + "bounded wait helper must not consume a game frame on lockstep stalls" + ); + + let _ = sess2; + + Ok(()) +} + // Lockstep advances every frame with zero latency and zero input delay. // With input_delay=0, each session must have the other's frame confirmed before it // can advance. advance_frame sends the local input packet unconditionally before @@ -845,9 +870,9 @@ fn test_lockstep_advances_with_zero_latency_zero_delay() -> Result<(), GgrsError Ok(()) } -// Lockstep with input_delay=1 advances without stalling after the first exchange. -// With input_delay D, each side queues D frames ahead on its first call, so a single -// one-way trip is enough for both sides to confirm and advance every game frame up to D. +// Lockstep with input_delay=1 advances once both peers have exchanged their delayed inputs. +// The session frame remains the game frame; input delay affects the effective input frame, not an +// internal game-frame lead. #[test] #[serial] fn test_lockstep_advances_with_input_delay() -> Result<(), GgrsError> { @@ -896,8 +921,7 @@ fn test_lockstep_advances_with_input_delay() -> Result<(), GgrsError> { // With input_delay >= 1 and a silent remote, two invariants must hold: // a) No AdvanceFrame is ever emitted — the game must not advance without confirmed inputs. -// b) The input queue must not overflow — it is capped at input_delay frames ahead of the -// game frame, so the 128-slot queue is never exhausted. +// b) The public current frame remains the game frame and does not advance on stalls. #[test] #[serial] fn test_lockstep_stalls_with_input_delay_and_no_remote_input() -> Result<(), GgrsError> { @@ -906,8 +930,7 @@ fn test_lockstep_stalls_with_input_delay_and_no_remote_input() -> Result<(), Ggr stubs::sync_p2p_sessions(&mut sess1, &mut sess2); // Drive sess1 well past 128 iterations (the input queue length) without ever - // polling for sess2's packets. The input queue cap must prevent an overflow, - // and the gate must prevent any game advancement. + // polling for sess2's packets. Duplicate stalled attempts must not consume game frames. let mut game_frames_advanced = 0usize; for _ in 0..200 { sess1.add_local_input(0, StubInput { inp: 0 })?; @@ -923,15 +946,13 @@ fn test_lockstep_stalls_with_input_delay_and_no_remote_input() -> Result<(), Ggr assert_eq!( game_frames_advanced, 0, "sess1 should not advance any game frames without remote input, \ - even though the input queue is running ahead with input_delay={input_delay}" + even with input_delay={input_delay}" ); - // The input queue should have advanced exactly input_delay+1 frames before hitting the - // cap (game_frame=0 is unconfirmed, so the queue stops at depth input_delay+1). assert_eq!( sess1.current_frame(), - input_delay as i32 + 1, - "input queue should advance to input_delay+1 frames before capping, \ + 0, + "lockstep current_frame should remain the game frame on stalls, \ got current_frame={} with input_delay={input_delay}", sess1.current_frame(), ); @@ -939,18 +960,16 @@ fn test_lockstep_stalls_with_input_delay_and_no_remote_input() -> Result<(), Ggr Ok(()) } -// After the input queue cap is hit, the session recovers correctly once the remote -// starts confirming frames again. Both sessions spin for many frames with no -// cross-poll (cap reached, no AdvanceFrame), then cross-poll and both should be able -// to advance the game frame normally. +// After repeated lockstep stalls, the session recovers correctly once the remote starts confirming +// frames again. #[test] #[serial] -fn test_lockstep_recovers_after_input_queue_cap() -> Result<(), GgrsError> { +fn test_lockstep_recovers_after_repeated_stalls() -> Result<(), GgrsError> { let input_delay = 2; let (mut sess1, mut sess2) = stubs::make_lockstep_sessions(7741, 7742, input_delay); stubs::sync_p2p_sessions(&mut sess1, &mut sess2); - // Spin both sessions well past the cap without any cross-poll. + // Spin both sessions for many attempts without any cross-poll. for _ in 0..input_delay + 10 { sess1.add_local_input(0, StubInput { inp: 0 })?; sess2.add_local_input(1, StubInput { inp: 0 })?; @@ -987,7 +1006,7 @@ fn test_lockstep_recovers_after_input_queue_cap() -> Result<(), GgrsError> { assert!( std::time::Instant::now() < deadline, - "session did not recover after input queue cap was hit" + "session did not recover after repeated lockstep stalls" ); }