Fortress Rollback API Contracts¶
Version: 1.6 Date: August 27, 2026 Status: Maintained high-impact subset
This document specifies preconditions, postconditions, and invariants for selected high-impact public APIs. Rustdoc remains the authoritative reference for the complete public surface. This document complements formal-spec.md with behavioral contracts that benefit from a consolidated view.
Table of Contents¶
- Contract Notation
- Public API Audit Ledger
- Frame
- SessionBuilder
- P2PSession
- SpectatorSession
- SyncTestSession
- GameStateCell
- Request Handling
- Error Catalog
- Event Catalog
- Network Stream Framing
- Fallible JSON and Compression
- Cross-Cutting Invariants
- Revision History
Contract Notation¶
Each API is documented with:
- Signature: The function signature
- Pre: Preconditions that must hold before calling
- Post: Postconditions guaranteed after successful return
- Errors: Conditions that cause specific errors
- Panics: Should always be "Never" for public APIs
- Invariants: Properties preserved across the call
Public API Audit Ledger¶
This ledger records dispositioned rows from the active public API and integration audit. It grows with that audit; absence from this maintained high-impact subset is not an implicit approval.
The exact one-to-one semver inventory lives in the public API census. Its generated snapshot includes callable hidden items and aliases that rendered rustdoc omits; this table remains the behavioral-contract subset.
| Owner / surface | Features / platforms | Usage evidence | Risk hypothesis | Disposition / evidence |
|---|---|---|---|---|
SessionBuilder::{try_,}start_spectator_session{,_multi} |
Core; native and WASM with a compatible socket | Spectator session tests, sync-send, hot-join, browser compile |
Option erased startup errors; empty or duplicate failover hosts created ambiguous/unreachable sessions |
Keep legacy Option wrappers; add exact Result APIs and pre-endpoint host validation. Issue #310 regressions cover exact errors, stable duplicate indices, and unchanged host order. |
rle::{try_,}encode |
Core; all targets | Protocol compression, unit/property/fuzz tests | Allocation refusal collapsed to the same [] as valid empty input |
Keep try_encode as the authoritative API; retain encode only as a documented violation-reporting compatibility fallback. Injected allocation refusal is distinct from Ok([]); golden RLE bytes are unchanged. |
network::compression::{try_,}{delta_,}encode |
Doc-hidden core surface; all targets | Protocol send path and compression unit/property tests | Public empty-vector wrappers hid invalid widths or allocation refusal | Expose the existing structured Result paths; retain explicitly documented wrappers. Protocol send paths already use the fallible forms; wire bytes are unchanged. |
{SessionMetrics,PeerMetrics,HotJoinMetrics,SpecViolation,InvariantViolation}::{try_,}to_json{,_pretty} |
json; all targets supported by serde_json |
Metrics/telemetry tests and operator documentation | Option<String> erased serializer/allocation causes; direct serde_json::to_string could not make output reservation fallible |
Add shared exact-count, fallible-reserve Result APIs with source-preserving JsonSerializationError; keep Option wrappers as explicit .ok() compatibility behavior. Failure injection and byte-identity regressions cover both formats. |
TokioUdpSocket after P2PSession ownership |
tokio; native Linux, macOS, and Windows |
Adapter units and compile matrix previously; real loopback session matrix now | Socket helpers worked before ownership, but no runtime oracle proved the synchronous session driver after the move | A bounded ten-second test moves two ephemeral adapters into sessions, synchronizes without sleeps as an oracle, confirms four frames, checks peer traffic, and asserts deterministic convergence. Issue #312 CI runs it on all three native operating systems and retains failure logs. |
Raw-byte browser NonBlockingSocket adapter |
Browser wasm32-unknown-unknown only; excluded from Emscripten |
Custom-socket example and browser compile previously; browser runner now | Compile-only coverage could miss clock/runtime, malformed decode, unbounded draining, or session-handshake failures | A browser-run bounded raw channel uses codec::{encode,decode_message}, rejects injected malformed packets, limits each receive call to eight attempts, synchronizes two sessions, and confirms two convergent frames. Target-specific dependencies remain browser-only. |
| Complete callable public path census | No-default and complete production-API feature profiles | Pinned rustdoc JSON, source reachability graph, checked snapshot, parser fixtures, and cargo-semver-checks | Rendered rustdoc omitted callable hidden modules, associated items, and aliases; accidental path removal could evade review | Keep 2,709 current symbol rows (2,703 textual paths), including associated paths beneath aliases, under explicit owner, availability, usage, risk, and disposition rows. Retain compatibility and verification paths unless a reviewed migration records their replacement; issue #313 removes only two unused __internal RLE aliases at the 0.14 boundary. |
Frame¶
Arithmetic operators and From<usize>¶
Pre: None
Post:
Frame + i32,Frame + Frame,Frame += i32,Frame - i32,Frame - Frame, andFrame -= i32saturate at thei32numeric bounds.Frame % i32returns the primitive remainder when defined and0for a zero divisor ori32::MIN % -1.Frame::from(usize)saturates atFrame::new(i32::MAX); callers that need to detect overflow useFrame::from_usizeorFrame::try_from_usize.
Errors: None. The operator traits and From conversion cannot return errors.
Panics: Never
SessionBuilder¶
SessionBuilder::new() -> Self¶
Pre: None
Post:
num_players = 2max_prediction = 8fps = 60input_delay = 0save_mode = SaveMode::EveryFramedesync_detection = On { interval: 60 }disconnect_timeout = 2000msdisconnect_notify_start = 500ms
Errors: None
Panics: Never
with_num_players(self, n: usize) -> Result<Self, FortressError>¶
Pre: n > 0
Post: self.num_players = n
Errors:
InvalidRequestStructured { kind: ZeroPlayers }- ifn = 0
Panics: Never
add_player(self, player_type: PlayerType, handle: PlayerHandle) -> Result<Self, FortressError>¶
Pre:
handlenot already registered- For
LocalorRemote:handle.0 < num_players - For
Spectator:handle.0 >= num_players
Post:
- Player registered with given type
- For
Local:local_players += 1
Errors:
InvalidRequestStructured { kind: PlayerHandleInUse { handle } }- handle duplicateInvalidRequestStructured { kind: InvalidLocalPlayerHandle { handle, num_players } }/InvalidRequestStructured { kind: InvalidRemotePlayerHandle { handle, num_players } }- invalid Local/Remote handle (handle.0 >= num_players)InvalidRequestStructured { kind: InvalidSpectatorHandle { handle, num_players } }- invalid spectator handle (handle.0 < num_players)
Panics: Never
with_max_prediction_window(self, window: usize) -> Self¶
Pre: None
Post:
self.max_prediction = windowwindow = 0→ session operates in lockstep (no rollbacks)
Errors: None
Panics: Never
with_input_delay(self, delay: usize) -> Result<Self, FortressError>¶
Pre: delay <= queue_length - 1 (default max: 127)
Post: self.input_delay = delay
Errors:
InvalidRequestStructured { kind: FrameDelayTooLarge { delay, max_delay } }- ifdelayexceedsinput_queue_config.max_frame_delay()
Panics: Never
with_fps(self, fps: usize) -> Result<Self, FortressError>¶
Pre: fps > 0
Post: self.fps = fps
Errors:
InvalidRequestStructured { kind: ZeroFps }- iffps = 0
Panics: Never
with_desync_detection_mode(self, mode: DesyncDetection) -> Self¶
Pre: None
Post: self.desync_detection = mode
Errors: None
Panics: Never
with_sparse_saving_mode(self, sparse_saving: bool) -> Self (deprecated)¶
Deprecated since 0.2.0: Use
with_save_mode(SaveMode::Sparse)instead.
Pre: None
Post:
sparse_saving = true→self.save_mode = SaveMode::Sparsesparse_saving = false→self.save_mode = SaveMode::EveryFrame
Errors: None
Panics: Never
with_disconnect_timeout(self, timeout: Duration) -> Self¶
Pre: None
Post: self.disconnect_timeout = timeout
Errors: None
Panics: Never
Notes: Network session startup returns a structured configuration error if the timeout is shorter than the disconnect notification delay.
with_disconnect_notify_delay(self, notify_delay: Duration) -> Self¶
Pre: None
Post: self.disconnect_notify_start = notify_delay
Errors: None
Panics: Never
Notes: Network session startup returns a structured configuration error if the notification delay is greater than the disconnect timeout.
with_disconnect_behavior(self, behavior: DisconnectBehavior) -> Self¶
Pre: None
Post: self.disconnect_behavior = behavior
Errors: None
Panics: Never
Notes:
- Default is
DisconnectBehavior::Halt, preserving the legacy GGRS-style halt-on-drop semantics. DisconnectBehavior::ContinueWithoutenables coordinated graceful peer drop on the automatic disconnect-timeout path. Survivors hold confirmation until the same certificate described forremove_playercommits; only then arePeerDroppedandDisconnectedemitted and remaining peers continue advancing.- The setting governs only the automatic-timeout path. The explicit
P2PSession::remove_playeralways performs a graceful drop regardless of this setting; the legacyP2PSession::disconnect_playerretains its non-graceful semantics regardless of this setting.
start_p2p_session(self, socket: impl NonBlockingSocket<T::Address> + 'static) -> Result<P2PSession<T>, FortressError>¶
Pre:
- All player handles
0..num_playershave been registered viaadd_player - At least one local player
Post:
- Session created in
Synchronizingstate - All remote endpoints begin synchronization
- Socket ownership transferred to session
Errors:
InvalidRequestStructured { kind: NotEnoughPlayers { expected, actual } }- not all player handles0..num_playershave been registeredInvalidRequestStructured { kind: ConfigValueOutOfRange { .. } }- a player count, serialized input width, FPS, prediction window, or desync interval cannot be represented by the fixed protocol-v1 handshake
Panics: Never
Invariants Established:
- INV-4: Queue length bounds
- INV-5: Queue index validity
- INV-11: No panics guarantee
start_spectator_session(self, host_addr: T::Address, socket: impl NonBlockingSocket<T::Address> + 'static) -> Option<SpectatorSession<T>>¶
Pre: None (no player registration required)
Post:
- Returns
Some(session)with session created inSynchronizingstate - Host endpoint begins synchronization
- Returns
Noneiftry_start_spectator_session(self, host_addr, socket)would returnErr
Spectator configuration validation:
SpectatorConfig::buffer_sizemust be greater than0SpectatorConfig::stream_delaymust be smaller thanbuffer_sizeSpectatorConfig::catchup_speed == 0is accepted; if catch-up mode is reached with zero speed, no frame is attempted andadvance_framereturnsOk(<empty>)
Errors: None (returns Option, not Result)
Panics: Never
try_start_spectator_session(self, host_addr: T::Address, socket: impl NonBlockingSocket<T::Address> + 'static) -> FortressResult<SpectatorSession<T>>¶
Pre: None (no player registration required)
Post:
- Returns a session in
Synchronizingstate - The host endpoint begins synchronization
- Construction sends no socket message before the session is returned
Errors:
- The exact structured protocol or spectator configuration error
SerializationErrorStructured { .. }when the input wire schema cannot be initializedInvalidRequestStructured { kind: AllocationFailed { .. } }when endpoint or session storage cannot be reserved
Panics: Never
try_start_spectator_session_multi(self, host_addrs: &[T::Address], socket: impl NonBlockingSocket<T::Address> + 'static) -> FortressResult<SpectatorSession<T>>¶
Pre: None
Post:
- Host-list validation completes before configuration validation or endpoint construction
- Every accepted address is unique and retains its supplied failover priority
- One synchronized endpoint is created per supplied address
- Construction sends no socket message before the session is returned
Errors:
InvalidRequestStructured { kind: NoSpectatorHosts }- the address list is emptyInvalidRequestStructured { kind: DuplicateSpectatorHost { first_index, duplicate_index } }- an address repeats; indices identify the earliest repeated occurrence in caller order- The same configuration, serialization, protocol, and allocation errors as
try_start_spectator_session
Panics: Never
The compatibility start_spectator_session_multi method maps each error above
to None.
start_synctest_session(self) -> Result<SyncTestSession, FortressError>¶
Pre: check_distance < max_prediction
Post:
- Session created (no network, immediate Running state equivalent)
- Rollback simulation enabled
Errors:
InvalidRequestStructured { kind: CheckDistanceTooLarge { check_dist, max_prediction } }- ifcheck_distance >= max_prediction
Panics: Never
P2PSession¶
current_state(&self) -> SessionState¶
Pre: None
Post: Returns Synchronizing or Running
Errors: None
Panics: Never
local_player_handles(&self) -> HandleVec¶
Pre: None
Post: Returns HandleVec of handles where player_type = Local
Errors: None
Panics: Never
poll_remote_clients(&mut self)¶
Pre: None
Post:
- All pending messages from socket processed
- Input queues updated with remote inputs
- Protocol state machines advanced
- Events queued for retrieval
Errors: None (errors converted to events)
Panics: Never
Side Effects:
- May trigger state transitions (Synchronizing → Running)
- May queue
Synchronized,Disconnected,NetworkInterruptedevents
add_local_input(&mut self, handle: PlayerHandle, input: T::Input) -> Result<(), FortressError>¶
Pre:
handleis a local playercurrent_state() = RunningOR input is being buffered- Not exceeding prediction threshold
Post:
- Input stored in
local_inputsmap - Input will be transmitted to remotes on next
advance_frame
Errors:
InvalidRequestStructured { kind: NotLocalPlayer { handle } }- handle is not a registered local player
Panics: Never
advance_frame(&mut self) -> FortressResult<RequestVec<T>>¶
Pre:
current_state() = Running- All local players have provided input via
add_local_input
Post:
- Returns sequence of requests to be processed in order
current_frameincremented (after processing requests)- If rollback needed:
LoadGameStatefollowed bySaveGameState/AdvanceFramepairs - If no rollback:
SaveGameState(unless sparse) thenAdvanceFrame
Errors:
NotSynchronized- ifcurrent_state() != RunningInvalidRequestStructured { kind: MissingLocalInput }- not all local players provided input
Panics: Never
Request Sequence (no rollback, full saving):
Request Sequence (with rollback):
[LoadGameState { frame: K },
SaveGameState { frame: K }, AdvanceFrame { inputs_K },
SaveGameState { frame: K+1 }, AdvanceFrame { inputs_K+1 },
...
SaveGameState { frame: N }, AdvanceFrame { inputs_N }]
Invariants Preserved:
- INV-1: Frame monotonicity (within rollback bounds)
- INV-2: Rollback boundedness
- INV-7: Confirmed frame consistency
- INV-8: Saved frame consistency
events(&mut self) -> EventDrain<'_, T>¶
Pre: None
Post:
- Returns iterator over pending events
- Event queue emptied
Errors: None
Panics: Never
frames_ahead(&self) -> i32¶
Pre: None
Post: Returns the maximum estimate across connected remote endpoints. A positive value means
the local session is ahead and should slow or skip bounded simulation opportunities; a negative
value means the local session is behind. Zero means no nonzero signed aggregate is currently
visible at integer-frame precision; zero does not prove peer alignment. The value is advisory:
remote-frame aging assumes symmetric one-way delay (RTT/2), so asymmetric paths can bias it.
Errors: None
Panics: Never
network_stats(&self, handle: PlayerHandle) -> Result<NetworkStats, FortressError>¶
Pre: handle is a remote player or spectator
Post: Returns stats (ping, send queue length, UDP-equivalent offered demand, etc.)
Errors:
InvalidRequestStructured { kind: NotRemotePlayerOrSpectator { handle } }- handle is neither a remote player nor a spectatorNotSynchronized- stats not yet available
Panics: Never
disconnect_player(&mut self, handle: PlayerHandle) -> Result<(), FortressError>¶
Pre: None — all caller-side conditions are validated and returned via the Errors section below.
Post:
- Every player handle owned by the dropped endpoint is marked as disconnected on the local connection-status table (multi-handle endpoints — multiple handles sharing a single address — are wound down in full)
- The corresponding network endpoint is disconnected
- Future inputs for any disconnected handle use the default value (the input queue is not frozen — see
remove_playerfor coordinated graceful drop, which freezes at the certified cut)
Errors:
InvalidRequestStructured { kind: DisconnectInvalidHandle { handle } }- handle not registeredInvalidRequestStructured { kind: DisconnectLocalPlayer { handle } }- handle refers to a local playerInvalidRequestStructured { kind: AlreadyDisconnected { handle } }- handle was already disconnectedInternalErrorStructured { kind: DisconnectStatusNotFound { handle } }- internal-invariant violation (a registered remote handle has no corresponding connection-status entry); should not occur in correct code, treat as a library bug
Panics: Never
Notes:
- Does not freeze the player's input queue and does not emit
FortressEvent::PeerDropped. - Always preserves halt-on-drop semantics regardless of the configured
DisconnectBehavior: remaining peers no longer produce confirmed inputs from the dropped peer's endpoint, soadvance_framecannot make progress past that peer's last confirmed frame. - For an explicit graceful drop, prefer
remove_player. - When
player_handleis Remote, operates on the Remote endpoint at the address only — aSpectatorendpoint registered at the sameT::Addressis independent and is not affected, remaining running until it disconnects on its own. Whenplayer_handleis Spectator, only that specific spectator endpoint is disconnected; any Remote endpoint at the same address is left running. Co-locating aRemoteand aSpectatorat the same address is unusual; this note documents the behavior for that edge case.
remove_player(&mut self, player_handle: PlayerHandle) -> Result<(), FortressError>¶
/// Remove a remote player from the session and continue with the remaining
/// peers (graceful drop), regardless of the configured DisconnectBehavior.
Pre: None — all caller-side conditions are validated and returned via the Errors section below.
Accepted-call post:
- The local intent is started, joined to an equivalent operation, or queued behind an active operation.
Ok(())does not mean the drop has committed. - While active, the confirmed frontier is held at least at every frame previously exposed as confirmed.
Certified-commit post:
- Every declared survivor reported matching inventory/ready evidence for one non-retracting cut; retained-history gaps were backfilled before readiness.
- Every non-spectator player handle owned by the dropped endpoint is atomically frozen and marked disconnected at that cut.
- The corresponding network endpoint is disconnected.
- One
FortressEvent::PeerDropped { handle, addr }per non-spectator handle at the dropped address is queued, followed by exactly oneFortressEvent::Disconnected { addr }in the same batch. confirmed_frame()can continue to advance for remaining peers.
Protocol failure post: Participant loss, unavailable/conflicting retained history, resource refusal, or a two-second timeout returns the session to Synchronizing, preserves the held confirmed prefix, and emits no PeerDropped.
Errors:
InvalidRequestStructured { kind: DisconnectInvalidHandle { handle } }- handle not registered, or refers to a spectatorInvalidRequestStructured { kind: DisconnectLocalPlayer { handle } }- handle refers to a local playerInvalidRequestStructured { kind: PlayerAlreadyRemoved { handle } }- handle is already marked disconnected (either via a priorremove_playercall, via auto-removal underDisconnectBehavior::ContinueWithout, or via a previous explicitdisconnect_playercall)InternalErrorStructured { kind: DisconnectStatusNotFound { handle } | IndexOutOfBounds(..) }- internal-invariant violation (a registered handle has no corresponding input queue or connection-status entry); should not occur in correct code, treat as a library bug
Panics: Never
Notes:
- Always opts in to graceful-drop semantics regardless of the session's
DisconnectBehavior. The configuredDisconnectBehavioronly governs the automatic disconnect-timeout path. poll_remote_clients()drives prepare, inventory, backfill, ready, commit, retransmission, relay, and timeout processing. A one-survivor certificate may commit insideremove_player; larger certificates are asynchronous. Concurrent removals serialize deterministically.- The
PeerDroppedevent coexists with the legacyDisconnectedevent; new code should match onPeerDroppedfor graceful-drop-aware handling. - Operates on the Remote endpoint at the targeted address only. A
Spectatorendpoint registered at the sameT::Addressis an independent endpoint and is not affected — it remains running until it disconnects on its own. Co-locating aRemoteand aSpectatorat the same address is unusual; this note documents the behavior for that edge case.
disconnect_behavior(&self) -> DisconnectBehavior¶
Pre: None
Post: Returns the DisconnectBehavior set via SessionBuilder::with_disconnect_behavior (default Halt).
Errors: None
Panics: Never
set_input_delay(&mut self, player_handle: PlayerHandle, delay: usize) -> Result<(), FortressError>¶
Pre: delay is within the configured max_frame_delay() of the input queue (set via InputQueueConfig::max_frame_delay; defaults to queue_length - 1). All other caller-side conditions are validated and returned via the Errors section below.
Post:
- The local player's frame-delay is set to
delay - No-op case (
delay == current_delay): no further side effects - Initial-setup case (no inputs added yet): the new delay applies cleanly with no gap-fill replication. Decreases are also permitted in this case.
- Mid-session increase case (
delay > current_delayafter inputs have been added on a peer with exactly one local player): - The input queue replicates the most recently added input across
delta = delay - current_delaynew gap frames - The same replicated frames are pushed onto every remote endpoint's pending-output buffer and flushed
- The local connection-status
last_frameis advanced to match the queue's newlast_added_frame - Remote peers' input sequences remain strictly monotonic
Errors:
InvalidRequestStructured { kind: NotLocalPlayer { handle } }- handle is not a local playerInvalidRequestStructured { kind: FrameDelayTooLarge { delay, max_delay } }-delayexceedsqueue_length - 1InvalidRequestStructured { kind: InputDelayDecreaseUnsupported { current, requested } }-requested < currentand inputs have already been addedInvalidRequestStructured { kind: InputDelayMidSessionMultiLocalUnsupported { local_players } }- mid-session increase attempted with more than one local player on this peerInvalidRequestStructured { kind: InputDelayMidSessionPendingOutputFull { delta, capacity } }- mid-session increase would push more gap-fill frames into a remote's pending-output buffer than the configuredpending_output_limitallowsInternalErrorStructured { kind: InputQueueGapFillFailed { frame } }- internal invariant violation while replicating gap-fill bytes (should be reported as a bug)
Panics: Never
Invariants Preserved:
- INV-3 (Input Immutability): confirmed inputs are not modified by gap-fill replication
- INV-4 (Queue Bounds): the queue length is unchanged
input_delay(&self, player_handle: PlayerHandle) -> Result<usize, FortressError>¶
Pre: None — all caller-side conditions are validated and returned via the Errors section below.
Post: Returns the current frame-delay for player_handle
Errors:
InvalidRequestStructured { kind: NotLocalPlayer { handle } }- handle is not a local player
Panics: Never
SpectatorSession¶
advance_frame(&mut self) -> FortressResult<RequestVec<T>>¶
Pre: current_state() = Running
Post:
- Returns one
AdvanceFramerequest per advanced frame. - If
SpectatorConfig::enable_rewindis enabled, each advanced frame is preceded by aSaveGameStaterequest for the same frame label. - May return multiple frames if catching up.
- A failover spectator with no remaining hosts may still advance through
already-buffered frames. After the buffered viewable frames drain,
PredictionThresholdis returned. - For redundant hosts, unresolved frames use the highest-priority currently
connected host by
start_spectator_session_multiorder as the canonical source. Lower-priority host snapshots are provisional while a higher-priority host remains connected; if the higher-priority host disconnects before a frame resolves, the next surviving host is promoted only for unresolved frames. - Host connection status is copied from the selected canonical host's whole-frame snapshot and is never synthesized player-by-player across hosts.
- Connected redundant hosts that disagree on the same player/frame emit
FortressEvent::SpectatorDivergence, record a frame-sync violation, and latch a terminalFortressError::SpectatorDivergencefor futureadvance_framecalls. Already advanced frames are not rewritten. - Failover host addresses are unique because
try_start_spectator_session_multirejects duplicates before construction.
Errors:
NotSynchronized- not yet synchronized with hostPredictionThreshold- no viewable frame is available yet, or all cleanly disconnected hosts have drained their buffered viewable framesSpectatorDivergence { frame, player }- connected redundant hosts disagreed on the same player/frame and the spectator has failed closed
Panics: Never
SyncTestSession¶
advance_frame(&mut self) -> FortressResult<RequestVec<T>>¶
Pre: All local inputs provided
Post:
- Simulates rollback of
check_distanceframes - Compares checksums for mismatch detection
- Returns requests including save/load/advance
Errors:
MismatchedChecksum { current_frame, mismatched_frames }on checksum mismatch (desync detected during resimulation)InvalidRequestStructured { kind: MissingLocalInput }- not all players provided input
Panics: Never
GameStateCell¶
save(&self, frame: Frame, state: Option<T::State>, checksum: Option<u128>) -> bool¶
Pre:
- Called in response to
SaveGameStaterequest framematches request frame
Post:
- Returns
trueif the save succeeded - Returns
falseifframeisFrame::NULL(save rejected) - State stored and retrievable via
load() - Checksum stored for desync detection (if provided)
Errors: None
Panics: Never
load(&self) -> Option<T::State>¶
Pre: save() was previously called
Post: Returns cloned state
Errors: None (returns None if empty)
Panics: Never
Request Handling¶
Processing Order Contract¶
CRITICAL: Requests from advance_frame() MUST be processed in the exact order returned.
// CORRECT
for request in session.advance_frame()? {
match request {
FortressRequest::LoadGameState { cell, .. } => { /* load */ }
FortressRequest::SaveGameState { cell, frame } => { /* save */ }
FortressRequest::AdvanceFrame { inputs } => { /* advance */ }
}
}
// INCORRECT - DO NOT reorder or skip requests
SaveGameState Contract¶
Pre: game_state.frame == frame (your state matches requested frame)
Post (after handling):
cell.save(frame, Some(state), checksum)called- State is now loadable for rollback
User Responsibility:
- Clone entire game state
- Compute checksum if desync detection enabled
- Call
cell.save()before processing next request
LoadGameState Contract¶
Pre: State was previously saved at frame
Post (after handling):
if let Some(state) = cell.load() { game_state = state; }- Game state restored to frame
frame
User Responsibility:
- Replace entire game state with loaded state
- Subsequent
AdvanceFramerequests will resimulate
AdvanceFrame Contract¶
Pre: Game state is at the correct frame
Post (after handling):
- Game state advanced by one frame
game_state.frame += 1(or equivalent)
User Responsibility:
- Apply all inputs to game state deterministically
- Handle
InputStatus::Disconnectedappropriately - Increment frame counter
Error Catalog¶
Legacy Variants¶
| Error | Cause | Recovery |
|---|---|---|
InvalidRequest { info } |
Invalid operation/parameter (legacy) | Check info message, fix call |
InvalidPlayerHandle { handle, max_handle } |
Handle out of range or wrong type | Use valid handle |
InvalidFrame { frame, reason } |
Frame out of valid range (legacy) | Check frame bounds |
NotSynchronized |
Operation requires Running state | Wait for sync or call poll |
MissingInput { player_handle, frame } |
Confirmed input not available | Internal error, report bug |
PredictionThreshold |
Prediction window exceeded | Wait before adding more input |
Structured Variants (Preferred)¶
| Error | Cause | Recovery |
|---|---|---|
InvalidRequestStructured { kind } |
Invalid operation with structured reason | Match on InvalidRequestKind variants |
InvalidFrameStructured { frame, reason } |
Frame invalid with structured reason | Match on InvalidFrameReason variants |
InternalErrorStructured { kind } |
Library bug with structured context | Report bug with error details |
SerializationErrorStructured { kind } |
Serialization failure | Check input data format |
FrameArithmeticOverflow { frame, operand, operation } |
Frame arithmetic overflow | Check frame bounds |
Selected InvalidRequestKind Variants — Runtime Input Delay and Peer Removal¶
| Variant | Source API | Cause | Recovery |
|---|---|---|---|
InputDelayDecreaseUnsupported { current, requested } |
P2PSession::set_input_delay |
requested < current after inputs have been added |
Mid-session decreases are not supported; carry the lower delay over to the next session |
InputDelayMidSessionMultiLocalUnsupported { local_players } |
P2PSession::set_input_delay |
Mid-session increase attempted with more than one local player on this peer | Set the delay before adding inputs (typically via SessionBuilder::with_input_delay) when running multi-local |
InputDelayMidSessionPendingOutputFull { delta, capacity } |
P2PSession::set_input_delay |
Mid-session increase would enqueue delta gap-fill frames, exceeding remote pending_output_limit capacity |
Apply the change in smaller increments, or wait for the remote to acknowledge outstanding inputs and retry |
PlayerAlreadyRemoved { handle } |
P2PSession::remove_player |
remove_player called when the handle is already marked disconnected — either by a previous remove_player call, by auto-removal via ContinueWithout, or by a previous explicit disconnect_player call |
Treat as a no-op; the peer is already in the graceful-drop terminal state |
NotLocalPlayer { handle } (pre-existing variant) |
P2PSession::set_input_delay / P2PSession::input_delay |
handle is not registered as a local player (it may be a remote player, spectator, or unregistered) |
Pass a registered local player handle (use SessionBuilder::add_player(PlayerType::Local, ..) to register one) |
Selected InternalErrorKind Variants — Runtime Input Delay¶
| Variant | Source API | Cause | Recovery |
|---|---|---|---|
InputQueueGapFillFailed { frame } |
P2PSession::set_input_delay |
Mid-session gap-fill replication failed an internal invariant at frame |
Report as a library bug with the failing frame and the call's parameters |
Event Catalog¶
FortressEvent<T> is not #[non_exhaustive]. Adding new variants is a breaking change for exhaustive matches; recent additions are listed below.
IncompatibleSession¶
IncompatibleSession { addr, reason } is emitted exactly once when a remote
endpoint advertises a different deterministic session configuration during
synchronization. The reason reports the first field in stable protocol order
and orients ours/theirs to the emitting endpoint. That endpoint remains
Synchronizing, never emits Synchronized or a later SyncTimeout, stops sync
retries, and continues answering remote sync requests with its local block.
Applications should treat the event as terminal, destroy the session, and
rebuild it after correcting the reported configuration. DisconnectBehavior
is local policy and is not compared.
Selected FortressEvent Variants — Disconnect, Graceful Drop, and Input Delay¶
| Variant | When emitted | Coexisting events |
|---|---|---|
PeerDropped { handle, addr } |
The coordinated survivor certificate initiated by an automatic ContinueWithout timeout or explicit remove_player commits |
One event per non-spectator handle at the dropped address; followed by exactly one Disconnected { addr } after all PeerDropped for the same address in the same batch |
Disconnected { addr } |
Always emitted on peer drop (legacy event); under Halt it appears alone, under graceful drop it appears once per address after that address's PeerDropped events |
Optionally preceded by one or more PeerDropped { handle, addr } (graceful drop, one per handle at the dropped address) |
InputDelayRecommendation { player_handle, current_delay, suggested_delay } |
Reserved for application-level heuristics or future automatic emitters. No built-in emitter currently produces this event. | None |
SpectatorDivergence { frame, player, primary_addr, conflicting_addr } |
A failover spectator received conflicting same-frame input for player from two connected redundant hosts |
Followed by terminal FortressError::SpectatorDivergence on future advance_frame calls |
Network Stream Framing¶
codec::encode_framed(message) -> CodecResult<Vec<u8>>¶
Pre: message is a valid Fortress network message.
Post: Returns u32::to_le_bytes(payload.len()) || payload, where payload is byte-for-byte
equal to codec::encode(message) and does not exceed DEFAULT_MAX_FRAME_LEN.
Errors: Returns CodecError::EncodeError on length overflow, an over-limit payload, encoding
failure, or failed fallible reservation.
Panics: Never
FrameDecoder::push(input) -> CodecResult<(Option<Message>, usize)>¶
Pre: After any prior error, the underlying stream has been discarded and reset has been
called before bytes from a new stream are supplied.
Post: Consumes no more than input.len(), buffers only one incomplete bounded frame, and yields
at most one exactly decoded Message. A suffix after that frame remains unconsumed for the caller.
Errors: Rejects zero-length or over-limit declarations before payload allocation, failed
fallible reservation, malformed/trailing message bytes, poisoned state, and incomplete state at
finish(). Every decode error poisons the decoder until reset().
Panics: Never
Invariant: Stream framing is a transport envelope only. It does not change protocol-v2
datagram bytes, rollback determinism, or the Message body format.
NonBlockingSocket::send_to(message, address)¶
Post: Best-effort submission only. The adapter may drop or delay the message locally, including inside a congestion-controlled QUIC sender stack. Fortress Rollback's redundant unacknowledged-input window repairs ordinary omissions through later messages; this call is not a delivery guarantee. One session update may invoke this method multiple times. Asynchronous adapters return promptly and either admit a bounded burst or apply an explicit freshness-preserving batch/drop policy; they do not wait for a socket-wide outbound buffer to empty after each message.
Fallible JSON and Compression¶
try_to_json / try_to_json_pretty¶
Pre: The json feature is enabled.
Post: Returns the same compact or pretty UTF-8 bytes as serde_json. A counting pass without
an output buffer determines the exact length; the output is reserved fallibly before the writing
pass.
Errors: JsonSerializationError::Serialization preserves the serde_json::Error;
Allocation preserves TryReserveError and the requested byte count; InvalidUtf8 preserves the
conversion error if the serializer violates its UTF-8 contract.
Compatibility: to_json / to_json_pretty return .ok() from the corresponding fallible
method. Their None value intentionally erases the cause and is not recommended for incident data.
Panics: Never
rle::try_encode and network::compression::try_encode / try_delta_encode¶
Post: Successful bytes are identical to the legacy wrappers and the frozen protocol format.
Valid empty input returns Ok(vec![]).
Errors: Structured input-width/reference errors and output allocation refusal remain observable.
Compatibility: encode / delta_encode report a violation and return vec![] on error. Since
valid empty input has the same vector shape, callers that need to tell those states apart use the
fallible API.
Panics: Never
Cross-Cutting Invariants¶
Relevant session and transport APIs preserve these invariants:
- INV-3 (Input Immutability): Confirmed inputs never change
- INV-4 (Queue Bounds):
0 ≤ queue.length ≤ configured queue capacity - INV-5 (Index Validity): Every occupied queue index stays within its configured capacity
- INV-11 (No Panics): Caller-controlled input follows documented error or fallback semantics
Revision History¶
| Version | Date | Changes |
|---|---|---|
| 1.6 | 2026-08-27 | Linked the exact pinned rustdoc-JSON census, retained hidden/alias dispositions, and reviewed removal ledger from issue #313. |
| 1.5 | 2026-08-27 | Added Tokio-owned session and browser raw-transport runtime dispositions from issue #312, including timeout, malformed-packet, bounded-polling, and native/browser/Emscripten target evidence. |
| 1.4 | 2026-08-27 | Added the dispositioned public API audit ledger and fallible JSON/compression contracts, including compatibility fallback and byte-stability guarantees. |
| 1.3 | 2026-08-26 | Scoped the ledger to its maintained high-impact subset; added total Frame arithmetic and conversion contracts; corrected configurable queue invariants and protocol-v2 framing terminology. |
| 1.2 | 2026-07-12 | Added bounded TCP/byte-stream framing contracts for codec::encode_framed and FrameDecoder. |
| 1.1 | 2026-05-07 | Added contracts for runtime input delay (P2PSession::set_input_delay, P2PSession::input_delay), configurable disconnect behavior (SessionBuilder::with_disconnect_behavior, P2PSession::disconnect_behavior), and explicit graceful peer removal (P2PSession::remove_player). Documented new InvalidRequestKind/InternalErrorKind variants and the new FortressEvent::PeerDropped and FortressEvent::InputDelayRecommendation events. Added Event Catalog. |
| 1.0 | 2025-12-06 | Complete API contracts |
| 0.1 | 2025-12-06 | Initial draft |