Migration Guide: ggrs → fortress-rollback¶
Fortress Rollback is the correctness-first, verified fork of the original ggrs crate. This guide explains how to migrate existing projects.
Upgrading from 0.13¶
Spectator startup now has Result-returning APIs that preserve the exact
failure cause. Existing start_spectator_session and
start_spectator_session_multi calls still compile and return Option, but new
code should use the try_ forms:
// Before: every startup failure became None.
let spectator = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.start_spectator_session(host_addr, socket)
.ok_or(FortressError::NotSynchronized)?;
// After: configuration, serialization, protocol, and allocation errors remain distinct.
let spectator = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.try_start_spectator_session(host_addr, socket)?;
Failover spectator host addresses must now be nonempty and unique. Duplicate addresses used to construct later endpoints that could never receive packets because routing stopped at the first address match. Startup now rejects the list before endpoint construction:
let spectator = match SessionBuilder::<GameConfig>::new()
.try_start_spectator_session_multi(&hosts, socket)
{
Err(FortressError::InvalidRequestStructured {
kind:
InvalidRequestKind::DuplicateSpectatorHost {
first_index,
duplicate_index,
},
}) => {
eprintln!(
"host {duplicate_index} duplicates the address at {first_index}"
);
},
Err(error) => return Err(error),
Ok(spectator) => spectator,
};
InvalidRequestKind remains exhaustive. Add match arms for
NoSpectatorHosts and DuplicateSpectatorHost { .. } when upgrading exhaustive
matches. Remove repeated addresses instead of relying on the former first-match
routing behavior.
The public-surface census also removed two unused aliases from the explicitly
unstable __internal namespace. Replace them with the supported module paths:
// Before
use fortress_rollback::__internal::{rle_decode, rle_encode};
// After
use fortress_rollback::rle::{decode, encode};
The functions and their wire-compatible bytes are unchanged; only the duplicate callable paths were removed.
The development-only z3-verification feature now uses the z3 0.21 bindings,
which require Z3 4.13.3 or newer and target Z3 5.x enum definitions. Contributors
who run the formal-verification suite should install Z3 5.1.0 or use the existing
z3-verification-bundled feature. The bundled feature name is unchanged, but it
now selects upstream's supported vendored build path:
# Before: older system Z3 installations could link against z3 0.20.
cargo test --features z3-verification -- --nocapture
# After: use a matching vendored solver when Z3 5.1.0 is unavailable.
cargo test --features z3-verification-bundled -- --nocapture
Upgrading from 0.11¶
The Macroquad-based ex_game_p2p, ex_game_spectator, and ex_game_synctest binaries are removed.
Their dependency has no patched release for
RUSTSEC-2025-0035, which reports soundness
defects reachable from safe code. Remove graphical-examples from your Cargo feature lists; its
name remains temporarily as a deprecated no-op for manifest compatibility and enables no code or
dependency. Use the retained headless examples for Fortress integration patterns, then connect the
session to your renderer and input stack in application code.
# Before (0.11)
cargo run --features graphical-examples --example ex_game_p2p
# After
cargo run --example configuration
The removed binaries have no direct graphical replacement. Start with the configuration,
custom_socket, or sync_test example, then drive your renderer from the resulting session
requests and state in the application crate.
TL;DR¶
- Update your dependency to
fortress-rollbackand change Rust imports tofortress_rollback. - Ensure your
Config::Addresstype implementsOrd+PartialOrd(in addition toClone + Eq + Hash). - Rename types:
GgrsError→FortressError,GgrsEvent→FortressEvent,GgrsRequest→FortressRequest. - All examples/tests now import
fortress_rollback; mirror that pattern in your code. - Browser clock migration in 0.10: callbacks passed to
ChaosSocket::with_clock()must returnweb_time::Instantinstead ofstd::time::Instant; see Browser ChaosSocket Clock Callbacks. - 0.10 synchronization default:
SyncConfig::default()now emits aSyncTimeoutevent after 20 seconds; setsync_timeout: Noneexplicitly to retain the previous unlimited-wait behavior. - 0.10 wire protocol: all peers in a session must upgrade together; protocol v1 intentionally rejects unversioned 0.9 packets.
- Current wire protocol: canonical hot-join membership generations require protocol v2; v1/v2 peers intentionally reject one another, so upgrade every participant together.
- Spectator startup after 0.13: prefer the
try_start_spectator_session*methods, and supply a nonempty list of unique failover hosts. - Public-surface cleanup after 0.13: replace
__internal::{rle_encode,rle_decode}withrle::{encode,decode}. - New in 0.10: runtime input-delay adjustment (
set_input_delay/input_delay), opt-in graceful peer drop (DisconnectBehavior::ContinueWithout,with_disconnect_behavior), explicit graceful removal (remove_player), and fail-closed redundant spectator divergence; exhaustive matches onFortressEvent,FortressError,InvalidRequestKind,InternalErrorKind,SerializationErrorKind,RleDecodeReason, andDeltaDecodeReasonneed new arms — see 0.10 section.
Dependency Changes¶
# Before
[dependencies]
ggrs = "0.11"
# After
[dependencies]
fortress-rollback = "0.14" # current version
If you were using a git/path dependency, point it to the new repository:
fortress-rollback = { git = "https://github.com/wallstop/fortress-rollback", branch = "main" }
# or
fortress-rollback = { path = "../fortress-rollback" }
0.9 → 0.10 Wire Protocol¶
Version 0.10 replaces the unversioned six-byte packet prefix with a protocol-v1 prefix containing sentinel bytes, an exact version, reserved flags, and a 32-bit connection ID. Mixed 0.9/0.10 sessions cannot synchronize and must be upgraded as one deployment unit.
| Receiver | Sender | Observation |
|---|---|---|
| 0.10 | 0.9 | The packet is rejected as suspected legacy traffic and a rate-limited NetworkProtocol warning is reported. The default 20-second SyncTimeout event still fires while synchronization retries continue. |
| 0.9 | 0.10 | The v1 prefix appears as an unknown legacy body discriminant, so the packet is discarded and the old default waits indefinitely unless the application configured its own timeout. |
The NonBlockingSocket typed-message API is unchanged. Custom transports that
receive raw bytes should decode them with network::codec::decode_message and
use classify_wire_bytes for rate-limited diagnostics. Packet recordings,
byte-preserving relays, and replay fixtures must be re-recorded for protocol v1.
Protocol v1 → v2¶
Protocol v2 gives N-player hot-join snapshot status epochs a canonical membership-generation meaning. That semantic boundary prevents a replacement session from deriving a different later graceful-drop certificate after retry-local connection transitions. The packet layout is otherwise unchanged, but mixed v1/v2 sessions are rejected during raw packet decoding because accepting a v1 snapshot would lose the canonical history required for correctness. Upgrade all participants, spectators, byte-preserving relays, and recorded packet fixtures as one deployment unit.
Import Path Changes¶
- use ggrs::{SessionBuilder, P2PSession};
+ use fortress_rollback::{SessionBuilder, P2PSession};
Type Renames (Breaking Change)¶
All Ggrs* types have been renamed to Fortress* for consistency:
// Before
use ggrs::{GgrsError, GgrsEvent, GgrsRequest};
// After
use fortress_rollback::{FortressError, FortressEvent, FortressRequest};
| Old Name | New Name |
|---|---|
GgrsError |
FortressError |
GgrsEvent<T> |
FortressEvent<T> |
GgrsRequest<T> |
FortressRequest<T> |
Update your pattern matching accordingly:
// Before
match request {
GgrsRequest::SaveGameState { cell, frame } => { ... }
GgrsRequest::LoadGameState { cell, frame } => { ... }
GgrsRequest::AdvanceFrame { inputs } => { ... }
}
// After
match request {
FortressRequest::SaveGameState { cell, frame } => { ... }
FortressRequest::LoadGameState { cell, frame } => { ... }
FortressRequest::AdvanceFrame { inputs } => { ... }
}
Result Type Alias Rename¶
The Result type alias has been renamed to FortressResult to avoid shadowing
the standard library's Result when using glob imports:
// Before
use fortress_rollback::Result;
fn my_function() -> Result<()> { ... }
// After (option 1: use the new name directly)
use fortress_rollback::FortressResult;
fn my_function() -> FortressResult<()> { ... }
// After (option 2: local alias if you prefer short names)
use fortress_rollback::FortressResult as Result;
fn my_function() -> Result<()> { ... }
Input Vector Type Change (Breaking Change)¶
The inputs field in FortressRequest::AdvanceFrame now uses InputVec<T::Input> (a SmallVec)
instead of Vec<(T::Input, InputStatus)>. This avoids heap allocations for games with 1-4 players.
Most code will work unchanged since InputVec implements Deref<Target = [(T::Input, InputStatus)]>:
// These all work unchanged:
for (input, status) in inputs.iter() { ... }
let first_input = inputs[0];
let len = inputs.len();
If you explicitly typed the inputs as Vec, update the signature:
// Before
fn process_inputs(inputs: Vec<(MyInput, InputStatus)>) { ... }
// After (two options)
use fortress_rollback::InputVec;
// Option 1: Use InputVec directly
fn process_inputs(inputs: InputVec<MyInput>) { ... }
// Option 2: Accept any slice-like type (most flexible)
fn process_inputs(inputs: &[(MyInput, InputStatus)]) { ... }
// Option 3: Convert to Vec if needed (allocates)
fn process_inputs(inputs: impl Into<Vec<(MyInput, InputStatus)>>) {
let inputs = inputs.into_iter().collect::<Vec<_>>();
...
}
The InputVec type alias is re-exported for convenience:
Address Trait Bounds (Breaking Change)¶
Config::Address now requires Ord + PartialOrd so deterministic collections can be used internally.
Most standard address types already satisfy this. For custom types, derive the traits:
Input Trait Bounds (Breaking Change)¶
Config::Input now requires Eq in addition to PartialEq. This ensures reflexive
equality for deterministic rollback; non-reflexive types (e.g., f32, f64) would cause
phantom prediction misses because NaN != NaN can make the engine treat identical inputs
as different, triggering unnecessary rollbacks.
Most custom input types only need an extra derive:
// Before
#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize)]
struct MyInput {
buttons: u8,
stick_x: i8,
}
// After
#[derive(Copy, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
struct MyInput {
buttons: u8,
stick_x: i8,
}
Note: All primitive integer types (
u8,i8,u16,i16,u32,i32,u64,i64,u128,i128,usize,isize) andboolalready implementEq, so input structs composed entirely of these types only need the added derive.
Features¶
The sync-send feature flag remains compatible. Fortress Rollback adds several new features:
| Feature | Description | New in Fortress |
|---|---|---|
sync-send |
Multi-threaded trait bounds | ❌ (existing) |
tokio |
Async Tokio UDP socket adapter | ✅ |
json |
JSON serialization for telemetry types | ✅ |
paranoid |
Runtime invariant checking | ✅ |
loom |
No-op compatibility name; actual tests use cfg(loom) |
✅ |
z3-verification |
Formal verification tests | ✅ |
trace-validation |
Bounded internal protocol trace recording for verification | ✅ |
graphical-examples |
Deprecated no-op compatibility flag | ✅ (enables nothing) |
hot-join |
Peers can join/rejoin a running session via a state snapshot (requires Config::State: Serialize + DeserializeOwned) |
✅ |
z3-verification-bundled |
z3-verification with a bundled Z3 build (no system Z3 needed) |
✅ |
Note: The
jsonfeature enables Result-returningtry_to_json()andtry_to_json_pretty()methods on telemetry and metrics types. The legacyto_json()andto_json_pretty()methods remainOption<String>compatibility wrappers that erase theJsonSerializationError; migrate to thetry_forms when failure details matter. Without this feature, theserde_jsondependency is not included, reducing the default dependency count.
For detailed feature documentation, see the User Guide.
What Stayed the Same¶
- Request-driven API shape (Save/Load/Advance requests)
- Session types (
P2PSession,SpectatorSession,SyncTestSession) - Safe Rust guarantee (
#![forbid(unsafe_code)])
What Improved¶
- Deterministic maps (no
HashMapiteration order issues) - Correctness-first positioning with ongoing formal verification work
- Documentation and branding aligned with the new name
- Consistent naming with
Fortress*prefix on all public types
New Configuration APIs¶
Fortress Rollback introduces structured configuration structs that replace scattered builder methods:
Network Configuration Structs¶
use fortress_rollback::{SyncConfig, ProtocolConfig, TimeSyncConfig, SpectatorConfig, InputQueueConfig};
// Before: Limited configuration options
let builder = SessionBuilder::<MyConfig>::new()
.with_fps(60)?
.with_input_delay(2);
// After: Rich, preset-based configuration
let builder = SessionBuilder::<MyConfig>::new()
.with_fps(60)?
.with_input_delay(2)?
.with_sync_config(SyncConfig::high_latency())
.with_protocol_config(ProtocolConfig::competitive())
.with_time_sync_config(TimeSyncConfig::responsive())
.with_spectator_config(SpectatorConfig::fast_paced())
.with_input_queue_config(InputQueueConfig::high_latency());
SaveMode Enum¶
use fortress_rollback::SaveMode;
// Before (deprecated)
builder.with_sparse_saving_mode(true);
// After (preferred)
builder.with_save_mode(SaveMode::Sparse);
Violation Observer¶
Monitor internal specification violations:
use fortress_rollback::telemetry::CollectingObserver;
use std::sync::Arc;
let observer = Arc::new(CollectingObserver::new());
let builder = SessionBuilder::<MyConfig>::new()
.with_violation_observer(observer.clone());
// After operations, check for violations
if !observer.is_empty() {
for v in observer.violations() {
eprintln!("Violation: {}", v);
}
}
See the User Guide - Complete Configuration Reference for full documentation.
New Desync Detection APIs¶
Fortress Rollback adds new APIs for detecting and monitoring desynchronization:
SyncHealth API¶
The new SyncHealth enum and associated methods provide proper synchronization status checking:
use fortress_rollback::SyncHealth;
// Check sync status with a specific peer
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => println!("Synchronized"),
Some(SyncHealth::Pending) => println!("Waiting for checksum data"),
Some(SyncHealth::DesyncDetected { frame, .. }) => {
// Handle desync according to your application's needs
eprintln!("ERROR: Desync detected at frame {frame} — investigation required");
// Application-specific response: could restart session, alert user, etc.
}
None => {} // Not a remote player
}
// Check all peers at once
if session.is_synchronized() {
println!("All peers in sync");
}
// Get the highest verified frame
if let Some(frame) = session.last_verified_frame() {
println!("Verified sync up to frame {}", frame);
}
NetworkStats Checksum Fields¶
NetworkStats now includes desync detection fields:
let stats = session.network_stats(peer_handle)?;
println!("Last compared: {:?}", stats.last_compared_frame);
println!("Checksums match: {:?}", stats.checksums_match);
Important Behavioral Differences¶
Session Termination Pattern¶
⚠️ Warning for GGRS users: If you were using confirmed_frame() or last_confirmed_frame() to determine when to terminate a session, this pattern is incorrect and can lead to subtle bugs.
// ⚠️ WRONG: This was a common GGRS pattern that doesn't work correctly
if session.confirmed_frame() >= target_frames {
break; // Dangerous! Peers may be at different frames!
}
The correct pattern uses the new SyncHealth API:
// ✓ CORRECT: Use sync_health() to verify peer synchronization
if session.confirmed_frame() >= target_frames {
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => break, // Safe to exit
Some(SyncHealth::DesyncDetected { frame, .. }) => {
eprintln!("Desync detected at frame {frame:?}");
break; // Exit with error state for application to handle
}
_ => continue, // Keep polling until verified
}
}
See the Session Termination Anti-Pattern section in the User Guide for comprehensive examples, edge cases, and solutions.
Desync Detection Default¶
⚠️ Breaking Change: Desync detection is now enabled by default with DesyncDetection::On { interval: 60 } (once per second at 60fps).
This is a deliberate departure from GGRS, which defaulted to Off. Fortress Rollback enables detection by default because:
- Silent desync is a correctness bug that's extremely difficult to debug
- The overhead is minimal (one checksum comparison per second)
- Early detection prevents subtle multiplayer issues from reaching production
- This aligns with our correctness-first philosophy
If you need to disable desync detection (e.g., for performance benchmarking), explicitly opt out:
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::Off) // Explicit opt-out
// ...
.start_p2p_session(socket)?;
For tighter detection (e.g., competitive games with anti-cheat needs), reduce the interval:
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::On { interval: 10 }) // 6 checks/sec at 60fps
// ...
.start_p2p_session(socket)?;
Session Trait (New)¶
Fortress Rollback now provides a unified Session<T> trait implemented by all session types (P2PSession, SpectatorSession, SyncTestSession). This lets you write generic code that works with any session.
This is entirely additive — no migration is required. Existing code using concrete session types continues to work unchanged.
Adopting the Session Trait¶
If you have session-specific game loop code, you can optionally generalize it:
// Before: tied to P2PSession
fn run_frame(session: &mut P2PSession<MyConfig>, input: MyInput) -> FortressResult<()> {
let player = session.local_player_handles()[0];
session.add_local_input(player, input)?;
let requests = session.advance_frame()?;
// handle requests...
Ok(())
}
// After: works with any session type
use fortress_rollback::prelude::*;
fn run_frame<T: Config>(
session: &mut impl Session<T>,
input: T::Input,
) -> FortressResult<()> {
let player = session.local_player_handle_required()?;
session.add_local_input(player, input)?;
let requests = session.advance_frame()?;
// handle requests...
Ok(())
}
Key differences when using the trait:
- Use
session.local_player_handle_required()(returnsResult) instead of indexing intolocal_player_handles() - Use
session.events()to drain events (returns anEventDrainiterator) poll_remote_clients()andcurrent_state()work on all session types (with sensible defaults forSyncTestSession)network_stats()is not on the trait — use it directly onP2PSessionorSpectatorSession
The trait is available in the prelude: use fortress_rollback::prelude::*;
For comprehensive examples including a generic game loop, see the User Guide — Using the Session Trait.
0.10: Browser ChaosSocket Clock Callbacks¶
ChaosSocket::with_clock() now accepts callbacks that return
web_time::Instant. The default clock needs no migration. Browser
wasm32-unknown-unknown callers with an injected clock must replace an explicit
std::time::Instant import and add web-time as a direct dependency:
Before / After: browser custom clock¶
// Before: this callback returns std::time::Instant.
use std::{sync::Arc, time::Instant};
let base = Instant::now();
let socket = ChaosSocket::new(inner_socket, chaos_config)
.with_clock(Arc::new(move || base));
// After: this callback returns web_time::Instant on browser WASM.
use std::sync::Arc;
use web_time::Instant;
let base = Instant::now();
let socket = ChaosSocket::new(inner_socket, chaos_config)
.with_clock(Arc::new(move || base));
Native and wasm32-unknown-emscripten callers remain source-compatible because
web_time re-exports std::time::Instant on those targets. Using the
cross-platform import everywhere keeps one clock implementation portable across
native, browser, and Godot Web builds.
0.10: Protocol-v1 Session Compatibility Handshake¶
Protocol-v1 peers now exchange deterministic session settings during the sync
handshake. Mixed 0.9/v1 traffic is rejected by framing before synchronization;
two v1 peers whose compatibility floor, player count, serialized input width,
FPS, maximum prediction, desync interval, compiled protocol features, or
canonical digest differ emit
FortressEvent::IncompatibleSession and remain synchronizing without further
retries or a later timeout.
Applications that exhaustively match FortressEvent must add an arm:
FortressEvent::IncompatibleSession { addr, reason } => {
eprintln!("cannot synchronize with {addr}: {reason}");
// Tear down this session and rebuild it with matching settings.
}
Applications that exhaustively match metrics categories must likewise add
EventKind::IncompatibleSession; its stable string is
"incompatible_session".
The reason's ours and theirs values are oriented to the endpoint emitting
the event. DisconnectBehavior is intentionally not compared because it is a
local response policy. Custom socket APIs are unchanged, but byte recorders,
relays, and fixtures must use the new fixed-width sync request/reply bodies.
Network startup now rejects values that cannot be represented by the handshake
(u16 player count/input width/prediction window and u32 FPS/checksum
interval), plus DesyncDetection::On { interval: 0 }. Use
DesyncDetection::Off to disable checksum comparison.
0.10: Runtime Input Delay, Disconnect Behavior, Graceful Peer Removal, and Spectator Divergence¶
The forthcoming release introduces fail-closed redundant spectator divergence plus three P2PSession capabilities: runtime input-delay adjustment, configurable disconnect behavior, and explicit graceful peer removal. The P2PSession behavior is additive or compatibility-preserving for configurations whose prediction window plus input delay fits the input queue; previously unsafe larger combinations now fail construction as described below. Sessions default to DisconnectBehavior::Halt, and the legacy disconnect_player continues to work unchanged. The spectator divergence behavior affects only failover spectators connected to redundant hosts that disagree. The breaking-change implications are limited to exhaustive matches on the public enums listed below.
Backwards compatibility at a glance¶
SessionBuilder::with_disconnect_behaviordefaults toDisconnectBehavior::Halt, which preserves the legacy GGRS-style halt-on-drop semantics. Code that does not callwith_disconnect_behaviorkeeps its current behavior.P2PSession::disconnect_playeris unchanged. The newremove_playeris added alongside it; you only need to migrate toremove_playerif you want graceful drop.P2PSession::set_input_delayis a new method. Existing safe construction-time delays continue to work; session construction and runtime increases now requiremax_prediction + input_delay < input_queue_config.queue_length. Configurations outside that bound previously risked overwriting rollback history during a full recovery batch and now returnInvalidRequestKind::ConfigValueOutOfRangewith field"max_prediction + input_delay". Increase the input queue or reduce prediction/delay to migrate an unsafe combination.
Breaking-change implications for exhaustive matches¶
FortressEvent, FortressError, InvalidRequestKind, InternalErrorKind, and SerializationErrorKind are not #[non_exhaustive]. Code that exhaustively matches on these enums must add arms for the new variants:
FortressEvent — new variants¶
// Before
match event {
FortressEvent::Synchronizing { .. } => { /* ... */ },
FortressEvent::Synchronized { .. } => { /* ... */ },
FortressEvent::Disconnected { .. } => { /* ... */ },
FortressEvent::NetworkInterrupted { .. } => { /* ... */ },
FortressEvent::NetworkResumed { .. } => { /* ... */ },
FortressEvent::WaitRecommendation { .. } => { /* ... */ },
FortressEvent::DesyncDetected { .. } => { /* ... */ },
FortressEvent::SyncTimeout { .. } => { /* ... */ },
FortressEvent::ReplayDesync { .. } => { /* ... */ },
}
// After
match event {
FortressEvent::Synchronizing { .. } => { /* ... */ },
FortressEvent::Synchronized { .. } => { /* ... */ },
FortressEvent::Disconnected { .. } => { /* ... */ },
FortressEvent::NetworkInterrupted { .. } => { /* ... */ },
FortressEvent::NetworkResumed { .. } => { /* ... */ },
FortressEvent::WaitRecommendation { .. } => { /* ... */ },
FortressEvent::DesyncDetected { .. } => { /* ... */ },
FortressEvent::SyncTimeout { .. } => { /* ... */ },
FortressEvent::ReplayDesync { .. } => { /* ... */ },
// NEW: emitted on graceful drop. Always paired with `Disconnected` in the
// same batch; see User Guide → Disconnect Behavior and Graceful Peer Drop.
FortressEvent::PeerDropped { handle, addr } => {
// Mark the peer as AI-controlled, show "left the game" UI, etc.
let _ = (handle, addr);
},
// NEW: reserved for application-level heuristics. No built-in emitter
// currently produces this event; you may bind-and-ignore
// (`InputDelayRecommendation { .. } => {}`) if you do not consume it.
// Using `_ => {}` would defeat the exhaustive-match check that prompted
// this migration step.
FortressEvent::InputDelayRecommendation {
player_handle,
current_delay,
suggested_delay,
} => {
let _ = (player_handle, current_delay, suggested_delay);
},
// NEW: emitted by failover spectators when connected redundant hosts
// disagree on the input for the same player/frame. Treat this as a
// terminal spectator integrity failure and reconnect or abort spectating.
FortressEvent::SpectatorDivergence {
frame,
player,
primary_addr,
conflicting_addr,
} => {
let _ = (frame, player, primary_addr, conflicting_addr);
},
}
FortressError — new variant¶
// After
match err {
// ... existing variants ...
FortressError::SpectatorDivergence { frame, player } => {
eprintln!(
"Redundant spectator hosts disagreed for player {player} at frame {frame}"
);
// Fail closed: do not keep advancing this spectator session.
},
}
Failover spectators created with start_spectator_session_multi no longer use
first-arrival wins for unresolved frames. The canonical source is the
highest-priority currently connected host by the order supplied to
start_spectator_session_multi; lower-priority host data is provisional while a
higher-priority host remains connected. If the canonical host disconnects before
a frame resolves, the next surviving host is promoted for unresolved frames only.
Connection status is copied from the chosen host's whole-frame snapshot rather
than merged across hosts. Connected hosts that provide conflicting input for the
same player/frame emit FortressEvent::SpectatorDivergence, record a
frame-sync violation, and make future advance_frame calls return
FortressError::SpectatorDivergence.
InvalidRequestKind — new variants¶
// After
match err_kind {
// ... existing variants ...
InvalidRequestKind::InputDelayDecreaseUnsupported { current, requested } => {
eprintln!(
"Cannot lower input delay from {current} to {requested} mid-session"
);
},
InvalidRequestKind::InputDelayMidSessionMultiLocalUnsupported { local_players } => {
eprintln!(
"Mid-session input-delay increase is not supported with {local_players} local players"
);
},
InvalidRequestKind::InputDelayMidSessionPendingOutputFull { delta, capacity } => {
eprintln!(
"Pending-output buffer full: needed {delta} slots, {capacity} available"
);
},
InvalidRequestKind::PlayerAlreadyRemoved { handle } => {
eprintln!("Peer {handle} was already removed; ignoring duplicate request");
},
}
InternalErrorKind — new variants¶
// After
match internal_kind {
// ... existing variants ...
InternalErrorKind::DeltaEncodeEmptyReference => {
eprintln!("internal: tried to delta-encode an empty reference frame");
},
InternalErrorKind::DeltaEncodeInputLengthMismatch { input_len, reference_len } => {
eprintln!(
"internal: input frame width {input_len} did not match reference width {reference_len}"
);
},
InternalErrorKind::InputEncodeLengthMismatch {
player,
input_len,
expected_len,
} => {
eprintln!(
"internal: player {player} input encoded to {input_len} bytes, expected {expected_len}"
);
},
InternalErrorKind::InputQueueGapFillFailed { frame } => {
// Library invariant violation while replicating gap-fill bytes during
// a mid-session input-delay increase. Treat as a bug and report.
eprintln!("internal: input-queue gap-fill failed at frame {frame}");
},
}
If you currently use _ => wildcard arms, no changes are required — but consider replacing the wildcard with explicit arms so future additions are caught at compile time.
SerializationErrorKind — new variants¶
Network sessions now require Config::Input::default() to serialize to at
least one byte. Zero-byte input types cannot be represented by the input delta
stream because the receiver splits decoded bytes into fixed-width frames. Start
methods return SerializationErrorKind::InputSerializedSizeZero instead of
constructing an endpoint that can never send or receive input frames.
They also reject local or remote aggregate input frames larger than
fortress_rollback::rle::DEFAULT_MAX_DECODED_LEN, returning
SerializationErrorKind::InputSerializedFrameTooLarge.
// After
match serialization_kind {
// ... existing variants ...
SerializationErrorKind::InputSerializedSizeZero => {
eprintln!("Config::Input must serialize to at least one byte");
},
SerializationErrorKind::InputSerializedFrameTooLarge { frame_len, max } => {
eprintln!(
"Config::Input aggregate frame is {frame_len} bytes, above receive cap {max}"
);
},
}
Config::Input values used in network sessions should also serialize to the
same byte length for every player and every value. Prefer structs of fixed-width
numeric and boolean fields. Avoid variable-length enums, strings, vectors, maps,
and other payloads whose encoded size can change per frame.
Replay::from_bytes validation and bounds¶
Replay::from_bytes() now uses a replay-specific checked decoder instead of generic bincode container decoding. It requires I: Copy, matching the Config::Input contract, validates the decoded replay before returning, and rejects trailing bytes. It also applies a hard limit of 1,048,576 cumulative zero-sized inputs because those values consume CPU work without consuming payload bytes. Use Replay::from_bytes_with_config(bytes, ReplayDecodeConfig::new().max_bytes(limit)) if your application wants to enforce its own replay file-size policy.
RleDecodeReason — new variants¶
RleDecodeReason (reported via FortressError::InternalErrorStructured / CompressionError::RleDecode) gains MalformedVarint, DecodedLengthExceedsMaximum, and AllocationFailed variants. MalformedVarint is returned when an encoded run-length prefix cannot be decoded as a valid varint. DecodedLengthExceedsMaximum is returned when received-input decompression rejects a malformed packet that declares a decoded length above the configured/default limit, instead of attempting an unbounded allocation. AllocationFailed is returned when reserving decoded output fails. Exhaustive matches must add arms:
// After
match reason {
// ... existing variants ...
RleDecodeReason::MalformedVarint { offset } => {
eprintln!("malformed RLE varint at offset {offset}");
},
RleDecodeReason::DecodedLengthExceedsMaximum { decoded_len, max } => {
// A peer sent a decompression bomb: the declared decoded length
// exceeds the configured/default limit. The packet was dropped; no allocation
// was attempted. Usually indicates corruption or a malicious peer.
eprintln!("rejected oversized decode: {decoded_len} > {max}");
},
RleDecodeReason::AllocationFailed { requested_len } => {
eprintln!("could not reserve decoded output: {requested_len}");
},
}
DeltaDecodeReason — new variants¶
DeltaDecodeReason (reported via FortressError::InternalErrorStructured / CompressionError::DeltaDecode) gains DecodedFrameCountExceedsMaximum and AllocationFailed variants. DecodedFrameCountExceedsMaximum is returned when delta decoding would split a decoded byte stream into too many per-frame buffers. AllocationFailed is returned when reserving decoded delta output fails. Exhaustive matches must add arms:
// After
match reason {
// ... existing variants ...
DeltaDecodeReason::DecodedFrameCountExceedsMaximum { frame_count, max } => {
eprintln!("decoded too many frames: {frame_count} > {max}");
},
DeltaDecodeReason::AllocationFailed { context, requested_elements } => {
eprintln!("could not reserve {requested_elements} elements for {context}");
},
}
If you currently use _ => wildcard arms, no changes are required — but explicit arms catch future additions at compile time.
ProtocolConfig::pending_output_limit — maximum value¶
ProtocolConfig::pending_output_limit now has a hard maximum:
ProtocolConfig::MAX_PENDING_OUTPUT_LIMIT. Larger values return
InvalidRequestKind::ConfigValueOutOfRange during configuration validation.
The limit keeps valid send batches aligned with the compression decoder's
per-frame output cap.
Before / After: dynamically adjusting input delay¶
Previously, the only way to change a session's input delay was at construction time, by branching on measured network conditions and choosing a value before calling start_p2p_session. Mid-match adjustments required tearing down and rebuilding the session.
// Before: input delay is fixed for the lifetime of the session.
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(addr), PlayerHandle::new(1))?
.with_input_delay(2)?
.start_p2p_session(socket)?;
// To change the delay, you would have to drop the session and rebuild it.
// After: read the current delay and increase it mid-session in response to
// network conditions. Decreases mid-session are not supported.
const MAX_INPUT_DELAY: usize = 8;
let local = PlayerHandle::new(0);
let stats = session.network_stats(remote_handle)?;
let current = session.input_delay(local)?;
if stats.ping > 120 && current < MAX_INPUT_DELAY {
match session.set_input_delay(local, current.saturating_add(1).min(MAX_INPUT_DELAY)) {
Ok(()) => { /* applied */ },
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::InputDelayMidSessionPendingOutputFull { .. },
}) => {
// Try again next tick after acknowledgements catch up.
},
Err(other) => return Err(other),
}
}
See the User Guide — Adjusting Input Delay at Runtime for the full constraint list and a complete example.
Before / After: handling a peer disconnect gracefully¶
Previously, the only way to react to a peer disconnect was to observe FortressEvent::Disconnected and tear down the session — P2PSession::disconnect_player did not freeze the input queue, so under default Halt semantics the session simply stopped advancing.
// Before: a disconnect halts the session. There were two ways to react:
//
// 1. Observe `FortressEvent::Disconnected` from a network-driven drop and
// tear down the match.
// 2. Call `disconnect_player(handle)` explicitly when the application
// decided to drop a peer (kick, surrender, etc.). Under default `Halt`
// semantics this also halts the session because `confirmed_frame()`
// stops progressing once the peer is marked disconnected; the input
// queue is **not** frozen and `FortressEvent::PeerDropped` is **not**
// emitted.
session.disconnect_player(handle)?;
for event in session.events() {
if let FortressEvent::Disconnected { addr } = event {
eprintln!("Disconnected from {addr}; tearing down match");
return Ok(());
}
}
// After (option 1): opt in to automatic graceful drop on timeout.
let mut session = SessionBuilder::<GameConfig>::new()
.with_num_players(3)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(a1), PlayerHandle::new(1))?
.add_player(PlayerType::Remote(a2), PlayerHandle::new(2))?
.with_disconnect_behavior(DisconnectBehavior::ContinueWithout)
.start_p2p_session(socket)?;
for event in session.events() {
match event {
FortressEvent::PeerDropped { handle, addr } => {
eprintln!("Peer {handle} ({addr}) left; continuing with remaining peers");
},
FortressEvent::Disconnected { .. } => { /* paired event; legacy consumers */ },
_ => {},
}
}
// After (option 2): propose a coordinated drop (kick / surrender / leave).
match session.remove_player(conceding_remote) {
// Intent accepted. Keep polling until PeerDropped proves commit.
Ok(()) => {},
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::PlayerAlreadyRemoved { .. },
}) => {
// Already removed (e.g., a timeout fired first). Treat as a no-op.
},
Err(other) => return Err(other),
}
The legacy disconnect_player is preserved for back-compat. New code should prefer remove_player for graceful drops, but must account for its asynchronous certificate: keep calling poll_remote_clients, treat PeerDropped as commit evidence, and treat a return to Synchronizing without that event as fail-closed. See User Guide — Choosing Between disconnect_player and remove_player for the full distinction.
Before / After: handles_by_address now takes &T::Address¶
PlayerRegistry::handles_by_address, PlayerRegistry::handles_by_address_iter, and the P2PSession forwarders now borrow the address rather than taking ownership. Pass &addr instead of addr at every call site.
// Before: address taken by value (cloned at call site for owned variables).
let handles = session.handles_by_address(peer_addr);
for handle in session.handles_by_address_iter(peer_addr.clone()) {
println!("{handle}");
}
// After: address borrowed; no clone required.
let handles = session.handles_by_address(&peer_addr);
for handle in session.handles_by_address_iter(&peer_addr) {
println!("{handle}");
}
This change is mechanical: add a leading & to every call. There are no behavioral changes.
More Information¶
For a complete comparison of features, bug fixes, and improvements, see Fortress vs GGRS.
Reporting Issues¶
Please file new issues on the Fortress Rollback repo: https://github.com/wallstop/fortress-rollback/issues