Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Foldback

Foldback finds exactly where and why your lockstep/rollback simulation desynced — one hook per tick, a bisection engine that narrows a mismatch down to the field, and a UI that turns a wall of hash logs into a timeline you can point at.

Not yet published to crates.io — see How Foldback Works for the full pieces breakdown.

The gap

Every serious lockstep/rollback project ends up building a version of this itself: RimWorld Multiplayer has one, O3DE has one, GGRS ships a SyncTestSession that does a narrow slice of it. Each is locked to one project or engine. Foldback is the reusable version — a small Rust core with a stable C ABI, so the same hashing/bisection engine works from Bevy, Unity, Godot, or Unreal instead of every team reinventing it.

60-second quickstart

The entire integration cost for Level-1 (tick-only) divergence detection: one call per tick.

#![allow(unused)]
fn main() {
use foldback_core::session::Session;

let mut session = Session::builder()
    .tick_rate_hz(60)
    .peer_count(2)
    .build()?;

// each simulation tick:
let state_bytes = bincode::serialize(&world.deterministic_state())?;
session.hash_tick(tick, &state_bytes)?;

// exchange session.take_pending_hashes() with peers over your existing
// netcode channel — a handful of bytes per tick.
}

If a peer’s hash for a tick doesn’t match, session.check_divergence() returns Some(DivergenceTick(tick)) the moment enough peers have reported that tick. See the Cookbook for the full recipe set.

Pick your engine

EngineWhat you get
Rust / GGRS / BevyCore API, a GGRS bridge, explicit + reflective hashing, an in-editor bevy_egui dock
UnityExplicit + reflective hashing, an in-editor EditorWindow dock, IL2CPP AOT support
GodotExplicit + reflective hashing, an in-editor dock plugin
Unreal EngineExplicit binding, reflective hashing, and a Mass Entity integration

Further down

  • Architecture — how the core, CLI, UI, and bindings fit together.
  • Protocol & File Format Spec — the .foldback session file format and live-mode transport, both versioned from day one.
  • vs. hand-rolling your own sync-checksum system: Foldback gives you the bisection engine (tick → entity → field) and a UI for free, instead of a yes/no checksum match you have to build tooling around yourself.

Quickstart

This walks through the smallest real integration: hashing your simulation’s state once per tick and detecting when two runs disagree. It matches examples/minimal-rust in the repo — run that if you want working code to poke at rather than read.

Not yet published to crates.io (pre-1.0). Until then, depend on it as a git or path dependency.

[dependencies]
foldback-core = { git = "https://github.com/FelixMiddelhoff/foldback", package = "foldback-core" }

1. Start a session

#![allow(unused)]
fn main() {
use foldback_core::session::Session;

let mut session = Session::builder()
    .tick_rate_hz(60)
    .peer_count(2)   // how many peers will report hashes for comparison
    .build()?;
}

2. Hash each tick

Call this once per simulation tick, with whatever bytes your determinism depends on — Foldback never serializes for you, it only hashes what you give it.

#![allow(unused)]
fn main() {
let state_bytes = your_serialization_fn(&world);
session.hash_tick(tick, &state_bytes)?;
}

3. Exchange hashes with peers

#![allow(unused)]
fn main() {
for pending in session.take_pending_hashes() {
    // send pending.tick, pending.hash to your peers over your existing
    // netcode channel — a handful of bytes, piggyback on an existing packet.
}

// as hashes arrive from peers:
session.record_peer_hash(tick, peer_id, hash)?;
}

4. Check for divergence

#![allow(unused)]
fn main() {
if let Some(divergence) = session.check_divergence() {
    eprintln!("desync at tick {}", divergence.0);
}
}

That’s the whole Level 1 (per-tick) integration. See the Cookbook for the CI-gate pattern (single-process double-run testing, no netcode needed), recording a .foldback file, and what Level 2/3 (per-entity, per-field) bisection will look like once they ship.

Inspecting a recorded session

cargo run -p foldback-cli -- analyze session.foldback

See the CLI Reference for the full command set.

How Foldback Works

The pieces

foldback-core   hashing, session file format, bisection engine (Rust)
foldback-sys    C ABI over foldback-core, generated foldback.h (cbindgen)
foldback-rs     idiomatic Rust wrapper + GGRS/Bevy helpers
foldback-cli    the `foldback` binary: analyze, ci-check, lint, schema-diff
foldback-ui     Tauri app — timeline, peer comparison, bisection drill-down
bindings/       Unity, Unreal (foldback-sys's C ABI), Godot (direct gdext)

foldback-core is the only place the actual logic lives. Every other surface — the CLI, the UI, every engine binding — is a thin wrapper around it, so a bug fix or a bisection-engine improvement lands everywhere at once instead of needing to be ported four times. Godot’s binding calls foldback-core directly through gdext rather than through foldback-sys’s C ABI, since gdext generates its own GDExtension registration — Unity and Unreal go through the C ABI because C#/C++ have no GDExtension-equivalent of their own.

What’s included

  • foldback-core: xxHash3 hashing, the .foldback file format (read + write), a bounded retention ring buffer, zstd snapshot compression, Level 1/2/3 (per-tick/entity/field) bisection, live-mode transport, and the #[derive(FoldbackHash)] opt-in field-hashing macro.
  • foldback-cli: analyze, ci-check, lint, and schema-diff subcommands.
  • foldback-ui: the Tauri app — offline timeline/peer-comparison/bisection drill-down, plus live mode (connect to a running game over a local WebSocket).
  • foldback-rs: GGRS bridge (checksum/record_desync), reflective hashing for Bevy, and a bevy_egui in-editor visibility dock (examples/bevy-editor-demo).
  • foldback-sys + bindings/unity: a full C ABI (Level 1/2/3), reflective hashing, a Unity UPM package with an in-editor EditorWindow dock, and IL2CPP AOT compatibility.
  • bindings/godot: a GDExtension addon (gdext), reflective hashing, and an in-editor dock plugin.
  • bindings/unreal: UFoldbackSubsystem + Blueprint wrappers over the C ABI, UPROPERTY(meta=(FoldbackHash)) reflective hashing (Editor/Development-Editor builds only — see bindings/unreal/README.md), and a Mass Entity integration path (UFoldbackHashProcessor).
  • examples/minimal-rust, examples/ggrs-demo, examples/live-demo, examples/bevy-editor-demo, examples/unity-demo, examples/godot-demo, examples/unreal-demo, examples/unreal-mass-demo: real, runnable integrations for each of the above, not just compiled code.

See Auto/Reflective Hashing for how the opt-in reflective walker, visibility tooling, and schema-drift detection (foldback schema-diff) work per engine.

The bisection model, briefly

Once a divergence tick is known (peers’ hashes disagree), Foldback doesn’t need to search which tick — peers report every tick, so the first mismatch is found in one pass. The actual search is over what part of the state diverged within that already-known tick: re-hash at finer granularity (per-entity, then per-field) between the last known-good tick and the first bad one. It’s git bisect’s algorithm applied to a hash tree instead of commits.

This tiered design means Level 1 alone (one hash per tick, the whole current integration cost) already tells you which tick — Level 2/3 integration (more work, structured hashing) buys which entity/field on top of that.

See Hashing Levels for the integration-cost tradeoff in more depth, and RFC-0005 for a real limitation of this model worth knowing about up front (snapshot-restore capability).

Core Concepts

Divergence, not just “desync happened”

A lockstep/rollback simulation is deterministic by construction: given the same inputs, every peer’s simulation should produce bit-identical state. When it doesn’t, that’s a divergence — and the actual bug is almost never visible from the symptom (a player sees their game snap or disconnect). Foldback’s whole job is turning “something diverged” into “tick 4,213, entity 87’s velocity.x field: 3.14159 on peer A vs. 3.14158 on peer B.”

Sessions

A Session is the one object your integration talks to. It accumulates tick hashes, tracks what each peer has reported, and answers “has anything diverged yet.” See Sessions, Files, and Live Mode for the different ways a session’s data can flow: in-process comparison, peer-to-peer exchange over your netcode, or written to a .foldback file for later analysis.

Hashing, not diffing

Foldback never transmits or stores your actual game state on the hot path — only a hash of it (xxHash3, non-cryptographic, chosen for speed since this is integrity-checking, not security). A hash mismatch tells you that two peers’ states differ, not how, at Level 1. Getting to “how” is what the bisection engine and Level 2/3 hashing exist for — see Hashing Levels.

Bisection

Once a divergence tick is known, Foldback doesn’t re-simulate from scratch — it narrows using whatever granularity of hash data you recorded. See How Foldback Works for the short version, and RFC-0005 for the real limitation (snapshot-restore capability) worth knowing about before you rely on it.

Opt-in, not opt-out

Every structured-hashing mechanism in Foldback (the field-hashing derive macro, per-engine equivalents) is opt-in by default: nothing is hashed until you explicitly mark it. This is a deliberate choice — see RFC-0003 for why the opposite default (hash everything, exclude what you don’t want) was rejected.

Hashing Levels (Tick / Entity / Field)

Foldback’s integration cost is tiered on purpose — you get useful results from the cheapest level, and each level above it costs more integration work in exchange for finer bisection.

Level 1 — per-tick

One hash per tick, covering however much state you decide determinism depends on.

#![allow(unused)]
fn main() {
let state_bytes = bincode::serialize(&world.deterministic_state())?;
session.hash_tick(tick, &state_bytes)?;
}

What it buys you: exactly which tick diverged. Integration cost: one call per tick, one serialization function you already control. This is the entire Level 1 integration — nothing else required.

Level 2 — per-entity

Instead of one hash for the whole tick, one hash per entity — Session::hash_entity(tick, entity_id, state_bytes).

#![allow(unused)]
fn main() {
for (entity_id, component_bytes) in world.iter_entities_serialized() {
    session.hash_entity(tick, entity_id, &component_bytes)?;
}
}

What it buys you: which entity diverged, not just which tick. Integration cost: your game needs to expose per-entity state independently, not just a single serialized blob. See examples/ggrs-demo for a real recording using this.

Level 3 — per-field

Structured field-level hashing via #[derive(FoldbackHash)] (Rust, feature derive) — every other binding has its own equivalent attribute: [FoldbackHash] in Unity, the foldback_ naming convention in Godot (GDScript has no custom-attribute mechanism GDExtension can hook into — see Auto/Reflective Hashing for why), UPROPERTY(meta=(FoldbackHash)) in Unreal. Opt-in per field in every case — see RFC-0003 for why opt-in and not opt-out.

#![allow(unused)]
fn main() {
#[derive(foldback_core::FoldbackHash)]
struct PlayerState {
    #[foldback(hash)]
    velocity: [f32; 3],
    debug_name: String, // unmarked — named in PlayerState::UNTRACKED_FIELDS
}
session.hash_fields(tick, entity_id, &player_state)?;
}

What it buys you: the actual diffed value at the leaf — velocity.x: 3.14159 vs 3.14158, copy-pasteable into a bug report. Integration cost: annotate the fields you want tracked with #[foldback(hash)]; an unmarked field is invisible to bisection but named in the generated UNTRACKED_FIELDS constant rather than the gap staying silent. foldback-cli lint --engine bevy <path> does the equivalent scan across a whole source tree, printing every tagged type’s tracked/untracked split at once — see CLI Reference.

Choosing a level

Start at Level 1 — it’s nearly free and already tells you which tick to look at by hand. Add Level 2/3 only for the parts of your simulation you’re actively debugging a real desync in; there’s no requirement to instrument everything up front.

Sessions, Files, and Live Mode

A Session is one in-process object; how its data reaches other peers or a later analysis is up to you.

In-process (CI-gate pattern)

Run your simulation twice (or replay a fixed input log twice) in the same process, hash both, compare:

#![allow(unused)]
fn main() {
let mut a = Session::builder().tick_rate_hz(60).peer_count(1).build()?;
let mut b = Session::builder().tick_rate_hz(60).peer_count(1).build()?;

for tick in 0..NUM_TICKS {
    run_one_tick(&mut world_a, tick);
    run_one_tick(&mut world_b, tick);
    a.hash_tick(tick, &serialize(&world_a))?;
    b.hash_tick(tick, &serialize(&world_b))?;
}

assert_eq!(a.finish(), b.finish());
}

finish() is a single combined hash over every tick a session has locally hashed — this is GGRS::SyncTestSession’s trick, generalized. See CI Integration for wiring this into an actual CI job.

Peer-to-peer (real multiplayer)

Exchange take_pending_hashes()/record_peer_hash() over your existing netcode channel — a handful of bytes per tick, meant to piggyback on a packet you’re already sending, not open a new connection. See the Quickstart.

Recording to a .foldback file

#![allow(unused)]
fn main() {
let mut session = Session::builder()
    .tick_rate_hz(60)
    .peer_count(2)
    .record_to("session.foldback")
    .build()?;

// ... hash_tick / record_peer_hash as normal ...

session.finish_recording()?; // writes the closing frame
}

The file format is append-only and streaming-writable — frames are written as they happen, not buffered and flushed at the end — and tolerant of truncation: a crash mid-recording still leaves a file that parses cleanly up to the last complete frame. Full byte layout: Protocol & File Format Spec.

Live mode

A running game streams directly over a local WebSocket, using the same frame format as a .foldback file — so a reader has one parser for both live and offline analysis. Both sides are built: the game-embeddable server (foldback_core::live::LiveServer, feature live) binds a loopback socket and sends frames as they happen without blocking the game’s own thread; foldback-ui’s “Connect live…” button connects to it and renders the same timeline/peer-comparison/drill-down views live mode and offline mode share. See Cookbook recipe 7 for the API and UI Guide for what connecting looks like. Full wire format: RFC-0004.

Determinism Pitfalls

Foldback tells you that and where your simulation diverged — it doesn’t fix non-determinism in your own code. These are the bug classes that most commonly cause the “divergence” Foldback will report, worth knowing before you go looking.

Floating point

The single most common source of cross-platform/cross-compiler divergence:

  • FMA (fused multiply-add) contraction: the compiler may fuse a * b + c into a single instruction with different rounding than separate multiply-then-add, and whether it does depends on target CPU, optimization level, and compiler flags — identical source, different binary output, different result.
  • Denormal handling: some platforms flush denormals to zero by default, others don’t; a value that should be a tiny denormal can silently become exactly 0.0 on one platform and not another.
  • Transcendental functions (sin, cos, sqrt, etc.): not guaranteed bit-identical across libm implementations, even for the same input.

This isn’t hypothetical — see blog-bitexact-fma-hunt.md in the Poncelet project (Foldback’s sibling determinism-focused project) for a real investigation into exactly this bug class.

Hash map/set iteration order

Iterating a HashMap/HashSet (or equivalents in other languages) is not guaranteed to visit entries in a consistent order across runs, processes, or platforms — if your serialization walks one of these directly, two otherwise-identical states can serialize to different byte sequences and hash differently. Use an ordered structure (BTreeMap, a sorted Vec) for anything that feeds a hash.

Uninitialized memory / padding bytes

Struct padding bytes are not guaranteed zeroed, and hashing a #[repr(C)] struct’s raw bytes (rather than its logical fields) can pick up garbage that varies run to run without any logical state actually differing. Serialize logical fields explicitly rather than hashing raw memory when this matters.

Timestamps, random IDs, debug-only fields

The exact failure mode RFC-0003 is designed around: a field that’s genuinely non-deterministic by design (a wall-clock timestamp, a debug label) gets included in what’s hashed, and now every run “diverges” for a reason that has nothing to do with your actual simulation bug. Foldback’s opt-in field hashing exists specifically so this can’t happen silently — but if you’re hashing a whole serialized blob at Level 1, the same discipline is on you.

Compiler/optimization-level differences

Two builds of the same source at different optimization levels (or on different compilers) can produce different floating-point codegen even with identical semantics on paper — this is exactly what the project’s own determinism CI job checks for (running the same test at -O0 and -O3 and asserting identical hashes), and worth doing in your own CI too if float determinism matters to your simulation.

Cookbook

Short, copy-pasteable recipes matching the real, shipped API — every sample below compiles against main.

Each Rust recipe leaves out error handling you’d keep in real code (Result unwraps stand in for real handling).


1. Minimal integration

The entire integration cost for Level-1 (tick-only) divergence detection: one call per tick, one serialization of “whatever state determinism depends on.”

#![allow(unused)]
fn main() {
let mut session = Session::builder()
    .tick_rate_hz(60)
    .peer_count(2)
    .build()?;

// each simulation tick:
let state_bytes = bincode::serialize(&world.deterministic_state())?;
session.hash_tick(tick, &state_bytes)?;

// exchange session.take_pending_hashes() with peers over your existing
// netcode channel (a handful of bytes per tick — piggyback on an existing
// packet, don't open a new one just for this).
}

If a peer’s hash for a tick doesn’t match, session.check_divergence() returns Some(DivergenceTick(tick)) the moment enough peers have reported that tick — you don’t need to wait for the whole match to end to know something’s wrong.


2. CI gate

Single-process determinism testing: run the same simulation twice (or replay a fixed input log twice), assert identical hashes — this is GGRS::SyncTestSession’s trick, generalized and made engine-agnostic.

#![allow(unused)]
fn main() {
let mut a = Session::builder().tick_rate_hz(60).peer_count(1).build()?;
let mut b = Session::builder().tick_rate_hz(60).peer_count(1).build()?;

for tick in 0..NUM_TICKS {
    run_one_tick(&mut world_a, tick);
    run_one_tick(&mut world_b, tick); // fresh instance, same recorded inputs

    a.hash_tick(tick, &serialize(&world_a))?;
    b.hash_tick(tick, &serialize(&world_b))?;
}

assert_eq!(a.finish(), b.finish(), "non-determinism detected — see bisection report below");
}

Run foldback ci-check <file> against an already-recorded .foldback file for the CI exit-code contract (0 clean, non-zero on divergence) — see CI Integration.


3. GGRS/Bevy integration

GGRS has no hookable “checksum callback” to wrap — a game computes its own checksum and hands it to GGRS via GameStateCell::save(frame, state, checksum), and for a real networked session GGRS emits GgrsEvent::DesyncDetected { local_checksum, remote_checksum, .. } when two peers’ checksums for a frame disagree. foldback_rs::ggrs (feature ggrs) is the real integration point — two small helpers, not a session-wrapping extension trait:

#![allow(unused)]
fn main() {
use foldback_rs::ggrs::{checksum, record_desync};

// At the point your game already computes a checksum for GameStateCell::save:
let cs = checksum(&state_bytes); // Foldback's hash, widened to GGRS's u128 checksum type
cell.save(frame, Some(state), Some(cs));

// When GGRS's own event loop reports a desync it already detected over the network:
if let GgrsEvent::DesyncDetected { frame, local_checksum, remote_checksum, .. } = event {
    record_desync(&mut foldback_session, frame as u64, local_peer_id, remote_peer_id,
                   local_checksum, remote_checksum)?;
}
}

SyncTestSession (single-process — see examples/ggrs-demo) doesn’t need this bridge: it catches a checksum mismatch internally, no cross-peer network exchange to bridge.


4. Per-entity hashing

Upgrades a divergence report from “tick 4821” to “tick 4821, entity Player#3” — worth the extra integration cost once Level 1 has actually found a bug and you need to narrow it down.

#![allow(unused)]
fn main() {
for (entity_id, component_bytes) in world.iter_entities_serialized() {
    session.hash_entity(tick, entity_id, &component_bytes)?;
}
}

Can be added incrementally alongside hash_tick — Level 1 and Level 2 aren’t mutually exclusive; the bisection engine uses whichever levels are present in a given session and reports “enable per-entity hashing for a finer result” when only Level 1 data exists. See examples/ggrs-demo for a real recording using this.


5. Per-field hashing

#![allow(unused)]
fn main() {
use foldback_core::FoldbackHash;

#[derive(FoldbackHash)]
struct PlayerState {
    #[foldback(hash)]
    position: [f32; 3],
    #[foldback(hash)]
    velocity: [f32; 3],
    debug_name: String, // unmarked — not hashed, named in PlayerState::UNTRACKED_FIELDS
}

session.hash_fields(tick, entity_id, &player_state)?; // macro-generated per-field hashing + value capture
}

Opt-in by default — see RFC-0003 for why. #[derive(FoldbackHash)] hashes nothing until a field is explicitly marked; an unmarked field’s name lands in the generated UNTRACKED_FIELDS constant rather than silently disappearing either way. Two ways to mark a field, both landing in TRACKED_FIELDS:

  • #[foldback(hash)] — hashed here, by the generated write_hashed_fields above, via foldback_core::hashable::FieldBytes (built for the common fixed-size numeric primitives and fixed-size arrays of them; implement it yourself for a custom vector/quaternion type).
  • #[foldback(reflect)] — tracked, but left for a reflective walker (e.g. Bevy’s foldback_rs::bevy::hash_reflected, see Auto/Reflective Hashing) to hash instead. No FieldBytes bound, so a compound field (a nested struct, a Vec, a HashMap) can opt in without needing a FieldBytes impl it doesn’t otherwise need.

#[foldback(skip)] marks a field as deliberately not hashed (distinct from leaving it unmarked, which still shows up in UNTRACKED_FIELDS for visibility).


6. Reading a .foldback file programmatically

For building your own tooling on top (a custom dashboard, a Slack bot that posts a divergence summary) without going through the CLI or UI.

#![allow(unused)]
fn main() {
use foldback_core::format::{FrameReader, Frame};
use std::fs::File;

let file = File::open("match_4821.foldback")?;
for frame in FrameReader::new(file) {
    match frame? {
        Frame::TickHash { tick, peer_id, hash } => { /* ... */ }
        Frame::Snapshot { tick, peer_id, compressed } => { /* ... */ }
        _ => {}
    }
}
}

Feed collected TickHashRecords into foldback_core::bisect::find_first_divergence directly — the same function foldback analyze itself calls internally — to find the first divergence tick from your own tooling.


7. Live mode

A separate LiveServer type used alongside Session, not a SessionBuilder option — a WebSocket connection has its own hello/reconnect lifecycle (protocol spec §2), not a plain byte sink the way a recording file is.

#![allow(unused)]
fn main() {
use foldback_core::live::LiveServer;
use foldback_core::format::Frame;

// Once, at startup — binds immediately (a port-in-use error surfaces
// synchronously), a background thread owns accept/handshake/reconnect.
let live = LiveServer::bind("127.0.0.1:9871", 60, 2, build_id)?;

let mut session = Session::builder().tick_rate_hz(60).peer_count(2).build()?;

// each tick:
session.hash_tick(tick, &state_bytes)?;
for pending in session.take_pending_hashes() {
    live.send_frame(Frame::TickHash { tick: pending.tick, peer_id: 0, hash: pending.hash });
}
}

send_frame never blocks the caller (an unbounded channel send) and silently drops frames if no UI is connected yet. In the UI: drag-and-drop a .foldback file for offline analysis, or click “Connect live…” and give it ws://127.0.0.1:9871 — same timeline/peer-comparison/drill-down views either way. See examples/live-demo for a real end-to-end proof (a toy sim streaming a real injected divergence, caught live in the actual UI).


8. Unity integration

Shipped — Level 1 (per-tick), Level 2/3 (per-entity/per-field, HashEntity/HashField and their peer-recording counterparts), and reflective hashing, all exposed across the FFI boundary and verified under real IL2CPP AOT compilation in CI. See docs/integrations/unity.md for the full status.

using Foldback;

var session = new FoldbackSession(new FoldbackConfig { TickRateHz = 60, PeerCount = 2 });

// each FixedUpdate:
byte[] state = SerializeDeterministicState();
session.HashTick(tick, state);

[FoldbackHash] field attribute mirrors the Rust derive macro (recipe 5) via Unity’s own reflection, at higher per-tick cost than the explicit-serialization path — fine for editor/dev builds, measure before shipping it in a release build’s hot path.


9. Godot integration

extends Node

var session := FoldbackSession.new()

func _ready():
    if not session.configure({"tick_rate_hz": 60, "peer_count": 2}):
        push_error(session.get_last_error())

func _physics_process(_delta):
    var state := serialize_deterministic_state()
    session.hash_tick(Engine.get_physics_frames(), state)

GDExtension binding (bindings/godot) exposes the same core through a GDScript-native FoldbackSession class rather than raw FFI calls — built directly on gdext (godot-rust) rather than through foldback-sys’s C ABI, since gdext generates the GDExtension registration itself.

A GDExtension class’s .new() calls its zero-argument _init, so there’s no supported way to route constructor arguments through it directly — hence the two-step .new() then .configure(dict) -> bool shape, returning false and setting get_last_error() on failure rather than throwing. u64 hashes cross into GDScript as i64 via exact bit-reinterpretation (GDScript’s only integer type) — a hash may print as negative, which is expected and harmless for equality-based divergence checks.

Rust / GGRS / Bevy

Who this is for

Any Rust game using a lockstep or rollback netcode library — GGRS specifically has a first-class integration, since Foldback can ride the checksum a GGRS game already computes instead of adding a second simulation hook.

Overview

The core foldback-core API (Level 1 hashing, the Session type) works from any Rust project with no GGRS dependency — see the Quickstart. foldback-rs’s ggrs feature adds two helpers, checksum and record_desync — see below. examples/ggrs-demo proves a real GGRS SyncTestSession end to end, recording a real two-peer .foldback file the UI can open.

Install

[dependencies]
foldback-core = { git = "https://github.com/FelixMiddelhoff/foldback", package = "foldback-core" }
foldback-rs = { git = "https://github.com/FelixMiddelhoff/foldback", package = "foldback-rs", features = ["ggrs"] }

Minimal example

See Cookbook recipe 1 — works today with any Rust simulation, GGRS or not.

GGRS-specific integration

See Cookbook recipe 3 — GGRS has no hookable “checksum callback” to wrap (a game computes its own checksum and hands it to GameStateCell::save), so the real integration is two small helpers rather than a session-wrapping extension trait: checksum(state_bytes) so a game’s own GGRS checksum is Foldback’s hash, and record_desync(..) to feed GGRS’s own DesyncDetected event (from a real networked P2pSession) into a Foldback Session for recording/bisection. SyncTestSession (single-process — see examples/ggrs-demo) doesn’t need the bridge; it catches mismatches internally.

Reflective hashing

Landed: foldback-rs’s bevy feature walks bevy_reflect component fields marked #[foldback(hash)]/#[foldback(reflect)] and hashes them without hand-written hash_entity/hash_field calls, plus a bevy-debug-panel feature for an in-egui live view of what got captured. See Auto/Reflective Hashing for the full picture, including what’s a deliberate non-goal rather than unfinished.

CI integration

foldback ci-check <file> works today for a file-based check. See CI Integration.

Troubleshooting

Nothing specific yet — file an issue if you hit something integrating against the current API; this section grows from real reports rather than speculation.

Unity

Overview

foldback-sys has a real C ABI covering Level 1 (per-tick), Level 2 (per-entity), and Level 3 (per-field) hashing — status-code-based per RFC-0002, with a cbindgen-generated foldback.h. A real C# UPM package (bindings/unity/) wraps it — FoldbackSession/FoldbackConfig matching cookbook recipe 8 exactly.

Verified against the real, built native library (not just compiled): a standalone .NET console harness (bindings/unity/Tests~/FoldbackSys.Tests, a Tests~ folder — Unity’s own convention for “ignore during asset import”) P/Invokes the actual foldback_sys binary and asserts real behavior — struct marshaling, divergence detection, finish() agreement/disagreement across two sessions, file recording, and error handling all pass against real native code.

IL2CPP AOT — verified both locally and in CI. examples/unity-demo is a real Unity project (Assets/Il2cppVerify.cs) referencing com.foldback.unity as a real package dependency, built as a Standalone Windows IL2CPP player and actually run — not just compiled. With a real Unity 6000.6.0f1 + Windows Build Support (IL2CPP) install, BuildScript.BuildIl2Cpp produced a working IL2CPP player, and running it printed:

ok   - hash_tick produces exactly one pending hash
ok   - mismatched peer hashes detected as divergence at the correct tick
ok   - two identical runs' finish() values agree
ok   - a diverging run's finish() value disagrees
PASS

— the P/Invoke surface’s conservative design (simple [DllImport] signatures, no automatic string/array marshaling attributes on the config struct — a hand-managed native UTF-8 buffer instead, see Runtime/FoldbackNative.cs’s comments) genuinely survives real IL2CPP AOT compilation, not just in theory.

The CI job bindings-unity-il2cpp (.github/workflows/ci.yml) codifies this exact sequence in GitHub Actions on windows-latest, with UNITY_EMAIL/UNITY_PASSWORD repository secrets for a Unity Personal license — not just locally. The Unity Hub/Editor/IL2CPP download is cached between CI runs (a cache miss — first run ever, or after 7 days of disuse — is still slow, but every run after that should be fast).

Who this is for

Unity projects using a lockstep or rollback netcode solution, including ones targeting IL2CPP (AOT compilation).

Usage

using Foldback;

var session = new FoldbackSession(new FoldbackConfig { TickRateHz = 60, PeerCount = 2 });

// each FixedUpdate:
byte[] state = SerializeDeterministicState();
session.HashTick(tick, state);

// send this peer's own hashes to the others over your existing netcode:
foreach (var pending in session.TakePendingHashes())
{
    SendToPeers(pending.Tick, pending.Hash);
}

// on receiving a peer's hash over the same netcode:
session.RecordPeerHash(peerTick, peerId, peerHash);

if (session.CheckDivergence(out var divergedTick))
{
    Debug.LogWarning($"Foldback: peers disagree at tick {divergedTick}");
}

session.Dispose() (or a using block) frees the native session — required, since it owns unmanaged memory.

Reflective hashing

Landed: [FoldbackHash] on a field or property (Runtime/FoldbackHashAttribute.cs), walked by FoldbackReflection.HashReflected(session, tick, entityId, prefix, root) (Runtime/FoldbackReflection.cs) — no hand-written HashField calls needed. Enforces opt-in: only root’s own [FoldbackHash]-tagged members are visible; once one is reached, everything beneath it is walked without needing its own type separately tagged (same model as the Bevy and Unreal bindings). Sorts IDictionary/ISet entries by key before hashing (§3’s shared determinism rule), catches genuine reference cycles via an identity-based visited-set (sound here — unlike the Bevy walker, C# reference types can form real cycles), and enforces a depth guard (default 8, FoldbackReflectionException). FoldbackReflection.ListTracked(Type) is the visibility-tooling data source (§4), and bindings/unity/Editor/FoldbackReflectionWindow.cs (Window > Foldback > Reflection Inspector) is a real EditorWindow built on top of it — a Live tab subscribing to a new FoldbackReflection.Recorded event for a history of observed HashReflected calls with drill-down into captured fields, and an Inspect tab running ListTracked against any dragged-in object with no Play Mode needed. Its own .asmdef restricts it to includePlatforms: ["Editor"] so it never ships in a player build. Per-type member access is cached via compiled System.Linq.Expressions getters (§2.2’s stated perf mitigation), built once per type.

Verified against the real, built native library the same way the rest of this page is: bindings/unity/Tests~/FoldbackSys.Tests exercises the walker, the sorted-container rule, cycle detection, the depth guard, and ListTracked — all pass, including a CI-gated explicit-vs-reflective timing comparison, ~3.5-3.9x overhead for the reflective path at 1,000 entities, consistent with the Bevy walker’s own measured ~3.7x.

Confirmed under real IL2CPP AOT. Expression.Compile() needs JIT/codegen on most .NET runtimes, and IL2CPP has no DynamicMethod/Reflection.Emit — the concern was whether this would throw under AOT or silently fail. A check for exactly this (Il2cppVerify.cs’s reflective-hashing block) rides the same bindings-unity-il2cpp CI leg described above, and a real run on windows-latest printed:

ok   - reflective hashing (Expression.Compile-based accessors) records tagged fields under IL2CPP
ok   - reflective hashing's untagged field stays invisible under IL2CPP

Expression.Compile() does work under real IL2CPP — falls back to the BCL’s expression interpreter as expected, not a throw.

See Auto/Reflective Hashing for the full cross-engine picture.

Godot

Overview

bindings/godot is a GDExtension addon built directly on gdext (godot-rust’s Rust bindings for Godot 4) — no separate C ABI layer, unlike the Unity binding, since gdext generates the GDExtension registration itself. Exposes a GDScript-native FoldbackSession class. Verified end to end against a real, headless Godot 4.7.2 engine in CI (Level 1 divergence detection, Level 2/3 hashing round-tripped through a real .foldback file, finish() agreement/disagreement, and the unconfigured-session error path).

Using it

See bindings/godot/README.md for setup steps, and Cookbook recipe 9 for the API shape and its one deviation from the original sketch (config goes through .configure(dict), not .new(dict) — a real GDExtension constructor constraint, not a design choice).

Reflective hashing

Landed: FoldbackSession.hash_reflected(tick, entity_id, prefix, target) walks every foldback_-prefixed property reachable from target — no hand-written hash_field calls per field. FoldbackSession.list_tracked(target) is the visibility-tooling data source.

Two things worth knowing, both verified against gdext 0.5’s actual source rather than assumed:

  • The marker is a naming convention (foldback_ prefix), not a custom @export_foldback hint — the plan flagged this as an open question needing a spike; checked directly against PropertyHint (a fixed engine enum) and GDExtension’s registration surface, and there’s no way to add a new hint value the editor/@export would recognize from a GDExtension addon. That would need an engine-side (C++) change. The naming convention isn’t a fallback here, it’s the only mechanism GDExtension can actually build.
  • The opt-in check re-applies at every nested Object, not just the root — a deliberate difference from the Bevy/Unity walkers (which filter once at the root and then walk everything beneath a tagged field unconditionally). Doing that here would mean tagging one property on a Node pulls in that whole node’s entire engine property surface (scripts, editor metadata, internal state) — too noisy for Godot’s heavier object model. So a foldback_-prefixed property one level down still needs its own foldback_ prefix.

Also verified directly from godot-core’s own source (not assumed): Godot’s Dictionary is explicitly insertion-ordered and Array is an ordered vector by construction, so — unlike the Bevy/Unity walkers — this one doesn’t sort container entries before hashing; there’s no unordered-iteration hazard to guard against for either of Godot’s built-in compound Variant types.

Depth-guarded (default 8) and cycle-guarded via Gd<T>::instance_id() (a real identity check, same soundness reasoning as the Unity walker — Godot Objects are reference types that can form genuine cycles).

Verified end to end against the same real, headless Godot 4.7.2 engine as the rest of this page — examples/godot-demo/test.gd exercises the walker, list_tracked, cycle detection, the depth guard, and an explicit-vs-reflective timing comparison, CI-gated against a ratio ceiling. Reflective hashing runs roughly 13-19x explicit’s cost for 1,000 entities — noticeably higher overhead than Bevy’s ~3.7x or Unity’s ~3.5x, most likely get_property_list()’s full property-array-of-dictionaries construction per call.

See Auto/Reflective Hashing for the full cross-engine picture.

Who this is for

Godot projects (GDScript or C#) using a lockstep or rollback netcode approach.

Unreal Engine

Overview

bindings/unreal wraps foldback-sys’s C ABI: UFoldbackSubsystem (a UGameInstanceSubsystem, Blueprint-callable) delegates to FFoldbackSession, a plain C++ RAII wrapper covering the full Level 1/2/3 API. Verified against a real, locally-built Unreal Engine 5.8.2 — examples/unreal-demo’s headless Automation Test (Foldback.Verify) passes divergence detection, Level 2/3 recording (round-tripped through a real .foldback file and independently re-verified with foldback-cli), finish() agreement/disagreement, and the unconfigured-session error path.

Reflective hashing (UPROPERTY(meta=(FoldbackHash))): FFoldbackReflectiveHasher walks tagged top-level fields via TFieldIterator<FProperty>, cached per UStruct, and records each as one FFoldbackSession::HashField call. Handles numeric/bool/enum fields, FVector/FRotator/FQuat (special-cased), TArray (order preserved), TMap/TSet (sorted by key bytes per the shared unordered-container rule), nested USTRUCTs (recursed, depth-guarded), and UObject* (hashed via an opt-in IFoldbackIdentifiable interface, never a raw pointer). A real, load-bearing limitation found by checking the engine’s own macro rather than assuming: UPROPERTY(meta=(...)) tags are compiled out entirely when WITH_METADATA (== WITH_EDITORONLY_DATA) is 0 — i.e. in any Shipping/cooked-without-editor-data build. This binding’s reflective path therefore only works in Editor/Development-Editor builds (exactly where this project’s own headless Automation-Test verification already runs); it fails loudly with a logged error outside that config rather than silently hashing nothing. A Shipping game that needs field-level bisection should call HashField explicitly instead — reflective hashing stays additive tooling, never a required path (see Auto/Reflective Hashing §1’s own framing). Conformance-tested end to end (Foldback.ReflectionVerify, 7 checks: opt-in filtering, map/set order-independence, array order sensitivity, nested-struct recursion, the depth guard, and both UObject* branches).

Mass Entity integration: examples/unreal-mass-demo’s UFoldbackHashProcessor (FMassProcessor) queries entities via FMassEntityQuery/ForEachEntityChunk and hashes each one’s fragment data as Level 2 — the natural per-entity hook for a Mass-based project, parallel to UTickFunction for a custom fixed-tick loop. Verified headless (Foldback.MassVerify) against a real FMassEntityManager with 5 real entities across 20 ticks, recorded to a real .foldback file with an injected per-entity, per-tick divergence proving the hook reads live fragment data rather than a stale snapshot.

No hosted CI leg — a real, disclosed gap, not an oversight: Unreal Engine has no scriptable, license-free install path the way Unity (Hub CLI installer) and Godot (plain binary download) both do; only the Epic Games Launcher (GUI, requires login). A CI leg would need a self-hosted runner with Unreal pre-installed and licensed. See bindings/unreal/README.md for the full story.

Who this is for

Unreal projects using a custom fixed-tick lockstep implementation or a Mass Entity-based simulation.

Auto/Reflective Hashing

Each engine binding’s reflective walker sits on top of the explicit hashing API (never a replacement for it) — tag a field, and the walker finds it via the engine’s own reflection system instead of you writing a manual hash_entity/hash_field call. The three walkers aren’t identical: the differences below are deliberate design decisions shaped by what each engine’s reflection model actually allows, not accidents or gaps.

Using it

Bevy

#![allow(unused)]
fn main() {
use foldback_rs::bevy::hash_reflected;

// `Unit` derives both `Reflect` and `FoldbackHash`. `#[foldback(hash)]`
// marks a primitive field (hashed via `FieldBytes`, same as the manual
// API); `#[foldback(reflect)]` marks a compound field (a nested struct,
// a `Vec`, a `HashMap`) for the walker to recurse into instead — it
// doesn't need `FieldBytes`, since the walker gets bytes via reflection.
#[derive(Reflect, FoldbackHash)]
struct Unit {
    #[foldback(reflect)]
    pos: Position,
    #[foldback(hash)]
    hp: i32,
    // unmarked: invisible to both the manual API and hash_reflected.
    debug_label: String,
}

let preview: Vec<(String, u64)> = hash_reflected(&mut session, tick, entity_id, "unit", &unit)?;
}

Walks Unit::TRACKED_FIELDS (pos, hp — not debug_label) and everything reachable beneath them, recording each leaf as a FieldHash frame, field-path-named ("unit.pos.x", "unit.tags[1]"). Returns the same (path, hash) pairs it recorded — feed that to foldback_rs::bevy::debug::ReflectionPreview for a live view (below).

Unity

using Foldback;

class Unit
{
    [FoldbackHash] public Position Pos;   // compound — walked fully once reached
    [FoldbackHash] public int Hp;         // primitive — hashed directly
    public string DebugLabel;             // unmarked — invisible to the walker
}

var preview = FoldbackReflection.HashReflected(session, tick, entityId, "unit", unit);

One attribute, [FoldbackHash], covers both the primitive and compound cases (no hash/reflect split like the Rust derive needs — C# reflection doesn’t require a FieldBytes-equivalent bound to walk further). Same field-path naming and return-value shape as the Bevy walker.

Godot

class Unit extends RefCounted:
    var foldback_pos: Position    # compound — walked fully once reached,
                                   # but its own fields need the prefix too
    var foldback_hp: int = 0      # primitive — hashed directly
    var debug_label: String = ""  # unmarked — invisible to the walker

var preview: Array = session.hash_reflected(tick, entity_id, "unit", unit)

No attribute system in GDScript, so the marker is a foldback_ name prefix instead — see below for why that’s the only mechanism GDExtension can actually build, not a fallback. preview is an Array of {"path": String, "hash": int} dicts, same shape in spirit as the other two walkers’ return value.

What it’s for

Get Level 2/3 (per-entity, per-field) bisection without hand-writing hash_entity/hash_field calls — the binding walks the engine’s own reflection system (bevy_reflect, C# System.Reflection, Godot’s get_property_list()) and hashes what it finds. This trades integration cost for runtime cost and a real correctness hazard class (below), so it’s layered on top of the explicit API, never a replacement for it.

The one rule that survives reflection — enforced, not just documented, but not identical per engine

Opt-in, same as the manual derive macro — reflection is never “walk every field reachable from the root,” it’s “walk fields the game has tagged as hashable” via the engine’s own attribute system ([FoldbackHash] in C#, a foldback_ name prefix in GDScript, #[derive(FoldbackHash)] in Bevy). Reflection is an alternate producer of the same FieldHash frame — not a new frame type or a separate bisection code path.

  • Bevy and Unity: opt-in is checked once, at the root. hash_reflected/HashReflected filters the root type’s own tagged fields; everything reachable beneath a tagged field is then walked unconditionally, without needing its own type separately tagged.
  • Godot deliberately does this differently: the foldback_ prefix check is re-applied at every nested Object the walk reaches, not just the root. Doing what Bevy/Unity do — tag one property, walk everything beneath it unconditionally — would mean tagging a single property on a Node pulls in that whole node’s entire engine property surface (scripts, editor metadata, internal engine state), which a plain Rust struct or C# POCO simply doesn’t carry. So a nested tracked object’s own properties need their own foldback_ prefix too. This is a real, considered difference in what “opt-in” means per engine, not an inconsistency — each choice fits the noise level of that engine’s actual object model.

The marker mechanism itself

  • Bevy: #[derive(FoldbackHash)]’s #[foldback(hash)]/#[foldback(reflect)], read at compile time.
  • Unity: [FoldbackHash], a real C# attribute, read via System.Reflection at runtime.
  • Godot: checked directly against gdext’s generated API rather than assumed: PropertyHint is a fixed engine enum, and nothing in GDExtension’s registration surface lets an addon add a new hint value the editor or @export would recognize — that needs an engine-side (C++) change, not something buildable from GDExtension. So the foldback_ naming convention isn’t a fallback here; it’s the only mechanism that’s actually available from this binding’s position in the stack.

Determinism hazards specific to reflection

The reason this needs its own care beyond the explicit API:

  • Unordered containers: a reflected HashMap/Dictionary has no guaranteed cross-platform iteration order — usually. foldback_core::hashable::FieldBytes sorts HashMap/HashSet by key before hashing (with BTreeMap/BTreeSet impls for symmetry), covered by a conformance test. The Bevy walker applies the same rule to reflected Map/Set values; the Unity walker sorts IDictionary/ISet entries the same way. Godot is the exception, verified against godot-core’s own source rather than assumed: Godot’s Dictionary is insertion-ordered by design (and Array is an ordered vector by construction) — so neither of Godot’s two built-in compound Variant types has this hazard, and the walker deliberately doesn’t sort them.
  • Cycles and shared references: a fixed max depth makes a runaway object graph fail loudly rather than hang or stack-overflow; an identity-based visited-set catches genuine reference cycles where the engine’s object model actually allows them. Handled differently per engine, deliberately:
    • Bevy: depth guard only (default 8, Error::ReflectionDepthExceeded). No separate identity-based cycle guard — a struct’s first field shares its raw address with the struct itself (zero offset), so naive pointer-identity dedup flags every struct’s first field as a false-positive cycle back to its own parent. More fundamentally, a plain bevy_reflect walk only ever sees owned value trees; Rust’s ownership model makes a value structurally unable to contain itself, so a genuine reference cycle can’t arise this way. The depth guard alone is correct and sufficient for Bevy.
    • Unity: depth guard (default 8) and an identity-based visited-set (RuntimeHelpers.GetHashCode + reference equality, skipped for value types) — sound here, unlike Bevy, because C# reference types genuinely can hold references to each other and form a real cycle.
    • Godot: depth guard (default 8) and an identity-based visited-set using Gd<T>::instance_id() — Godot’s own built-in, engine-provided object identity, even more direct than Unity’s runtime-hash-code approach. Also sound and necessary: Godot Objects are reference types that can genuinely reference each other cyclically (verified live: a two-node mutual-reference test correctly throws rather than hanging).
  • Enum/union representation: hash the discriminant and payload in a fixed encoding — never the host language’s raw memory layout, which can differ across compilers/platforms. Bevy hashes variant_name() plus each variant field. Naturally absent for Unity/C# and for Godot: a C# enum carries no payload (unlike a Rust enum variant), so the walker hashes the underlying integral value directly; GDScript doesn’t have Rust-style payload-carrying enums at all (Variant’s own type tag already IS the discriminant, handled by the walker’s normal type dispatch).
  • Schema drift: if a type’s tagged-field set changes between the build that recorded a session and the build now analyzing it, a reader needs to detect the mismatch rather than silently diffing two things that no longer mean the same thing. foldback_core::session::Session::record_schema(type_name, tracked_fields) writes a Metadata frame (foldback.schema.<type_name> → sorted tagged-field fingerprint), once per type per session — exposed to Unity/Godot as foldback_record_schema in foldback-sys’s C ABI. foldback_core::schema::detect_drift compares two such fingerprint maps, and foldback schema-diff <before.foldback> <after.foldback> exposes it from the CLI, exiting 1 on drift (same convention as ci-check). Not build_id-based — build_id is opaque game-supplied bytes with no defined internal structure to diff. Each binding’s walker supplies the type name automatically:
    • Bevy: std::any::type_name::<T>(), a real static type — always unique.
    • Unity: Type.FullName of the reflected root, also a real static type — always unique. Deduped on the C# side too (ConditionalWeakTable<FoldbackSession, HashSet<Type>>), so a game calling HashReflected every tick doesn’t re-marshal the field-name array across FFI past the first call per type per session.
    • Godot: no static type declarations exist to draw on (GDScript). Script.get_global_name() (a GDScript class_name) is used when present — the one identifier that’s both build-stable and genuinely unique per type. Without a class_name, this falls back to Object::get_class() (the native engine class, e.g. "RefCounted"), which collapses every un-named inner-class script sharing that base class onto the same schema key — a documented limitation, not a silent wrong answer. Give a tracked GDScript type a class_name for schema-drift to mean anything for it.

Visibility tooling

Because reflection makes it easy to lose track of what is actually being hashed (there’s no derive-macro call site to grep for), each binding ships a preview surface:

  • Bevy: foldback lint --engine bevy <path> statically scans .rs files (recursing into mod blocks) for #[derive(FoldbackHash)] structs and prints each one’s tracked fields alongside its untagged siblings. foldback_rs::bevy::debug::ReflectionPreview + render(&egui::Context, ...) (feature bevy-debug-panel) draws a live table a game’s own egui/bevy_egui integration calls into. examples/bevy-editor-demo (cargo run -p bevy-editor-demo) wires it into a real bevy_egui-backed AppEguiPlugin, a demo entity whose position drifts every frame, one system calling hash_reflected and one drawing the panel from the real EguiContexts a running game would use. Excluded from the default cargo test --workspace sweep (Cargo.toml’s exclude) since it pulls the full bevy + bevy_egui GPU/windowing stack, which a standard CI Linux/macOS runner isn’t guaranteed to have configured — same reasoning as the Unity/Godot/Unreal demo exclusions.
  • Unity: FoldbackReflection.ListTracked(Type) returns the same tracked/untracked-sibling split for a given type. bindings/unity/Editor/FoldbackReflectionWindow.cs (Window > Foldback > Reflection Inspector) is a real EditorWindow built on it, in its own .asmdef restricted to includePlatforms: ["Editor"]. Two tabs: Live, which subscribes to FoldbackReflection.Recorded (fired at the end of every HashReflected call) and lists observed calls with drill-down into their captured field paths/hashes; Inspect, which runs ListTracked against any dragged-in object with no Play Mode or session needed.
  • Godot: FoldbackSession.list_tracked(target) returns the same split for a live instance (not a static type — GDScript has no compile-time type declarations to scan the way Bevy’s source or Unity’s Type reflection do), as {"tracked": PackedStringArray, "untracked": PackedStringArray}. bindings/godot/addons/foldback/plugin.gd + foldback_dock.gd (enable via Project Settings → Plugins) is a real editor dock showing list_tracked’s split for whichever node is currently selected in the editor, refreshing on every selection change. Deliberately not a live-hashing feed the way the Bevy/Unity docks are — the editor and a running (F5) game are separate OS processes by default, so there’s no shared memory a dock could read a live hash_reflected call from the way Unity’s single-process Editor+Play Mode allows; the static tracked/untracked view is what’s actually buildable here.

Performance

Reflective hashing is explicitly not free, unlike the manual API (which just serializes bytes the game already produced):

  • Bevy: crates/foldback-rs/benches/reflective_vs_explicit.rs (Criterion, cargo bench -p foldback-rs --features bevy) compares explicit vs. reflective cost for 100/1,000 entities — roughly 3.7x the explicit path’s cost at both counts. crates/foldback-rs/src/bevy.rs’s reflective_hashing_stays_within_a_generous_budget_of_explicit_hashing test is a CI regression gate against a 15x ceiling.
  • Unity: bindings/unity/Tests~/FoldbackSys.Tests compares an explicit-vs-reflective timing comparison for 1,000 entities against the plain .NET (Mono-equivalent) path — roughly 3.5-3.9x, consistent with Bevy’s number, CI-gated against a 15x ceiling.
  • Godot: examples/godot-demo/test.gd runs the same comparison for 1,000 entities against a real headless Godot 4.7.2 — reflective runs roughly 13-19x explicit’s cost, noticeably higher than the other two, CI-gated against a 40x ceiling. Most likely cause: get_property_list() builds a full Array of Dictionary objects — one Variant-boxed dictionary per property, every call — where Bevy’s bevy_reflect and Unity’s cached Expression-compiled getters both avoid that per-call allocation cost.

Default posture (dev/editor-build feature, off by default in release builds) is a per-game build-configuration choice, not something any of the three crates enforces.

Engine-specific limitations

  • Unity + IL2CPP AOT: FoldbackReflection’s per-type accessor caching uses System.Linq.Expressions.Expression.Compile(), which needs JIT/codegen on most .NET runtimes; IL2CPP has no DynamicMethod/Reflection.Emit. Verified against a real IL2CPP player build (examples/unity-demo/Assets/Il2cppVerify.cs, bindings-unity-il2cpp CI job): Expression.Compile() falls back to the BCL’s expression interpreter under IL2CPP, not a throw. See Unity.
  • Godot’s overhead (13-19x explicit’s cost) is real and measured, but the cause above is inferred from get_property_list()’s known allocation pattern, not confirmed by a profiler — worth a closer look before recommending reflective hashing for a Godot project with tight per-tick budgets.

CLI Reference

The foldback binary, built from crates/foldback-cli.

cargo install --path crates/foldback-cli   # until published to crates.io

foldback analyze <file>

Prints a human-readable report of a .foldback session file: header fields, frame counts by type, whether the file ended cleanly (has an EndOfStream frame — its absence means the recording was cut short, e.g. a crash mid-recording), and the first divergence tick found, if any.

$ foldback analyze session.foldback
format_version : 1
tick_rate_hz   : 60
peer_count     : 2
build_id       : 00000000000000000000000000000000
ended cleanly  : true

tick hashes    : 20
entity hashes  : 0
field hashes   : 0
snapshots      : 0
metadata       : 0

divergence     : tick 6

Exit codes: 0 on success (a report was printed — this does not mean the session was clean, check the divergence line), 2 if the file couldn’t be opened or parsed.

foldback ci-check <file>

Same analysis, but exits with a CI-friendly contract instead of a human report:

  • Exit 0: no divergence found. Prints a one-line summary to stdout.
  • Exit 1: divergence found. Prints DIVERGENCE DETECTED at tick N and a short report to stderr.
  • Exit 2: the file couldn’t be opened or parsed.
foldback ci-check session.foldback || exit 1

This exit-code contract is tested explicitly (crates/foldback-cli/tests/golden.rs) since external CI pipelines script against it directly — see CI Integration for a full workflow example.

foldback lint --engine bevy <path>

Reflective-hashing visibility tooling (Reflective/Auto Hashing §4): statically scans a directory of .rs files (recursing into mod blocks) for #[derive(FoldbackHash)] structs, printing each one’s tracked (#[foldback(hash)]/#[foldback(reflect)]) fields alongside its untagged siblings — the “did you mean to include this one too” check, run against source text instead of a live object.

$ foldback lint --engine bevy src/
Unit (src/game/unit.rs)
  tracked   : hp, pos
  untracked : debug_label

Bevy/Rust only — Unity/Godot/Unreal don’t have their own static scanners (each has other visibility tooling instead: ListTracked/list_tracked reflective queries, and a real in-editor dock for all three engines; see the reflective-hashing page for details).

Exit codes: 0 on a successful scan (even if it finds zero tagged types), 2 if the path couldn’t be read or a file failed to parse.

foldback schema-diff <before> <after>

Schema-drift detection for reflective hashing (Reflective/Auto Hashing): compares two .foldback files’ recorded schemas — each type a reflective walker hashed, and the sorted set of #[foldback(hash)]-tagged fields it saw for that type (recorded once per type per session via Session::record_schema) — and reports any type present in both whose tagged-field set changed. Catches “someone added a field mid-development” before it’s mistaken for a real divergence.

$ foldback schema-diff old-build.foldback new-build.foldback
SCHEMA DRIFT: my_game::Unit
  before : hp,pos
  after  : hp,pos,shield

Exit codes: 0 if no drift (also the result if a file has no schema metadata at all — e.g. it only used explicit hashing, never reflective), 1 if drift is found, 2 if a file couldn’t be opened or parsed.

Recording a session file

There’s no CLI command for this — recording happens at the library level, via Session::builder().record_to(path) (see Sessions, Files, and Live Mode). examples/minimal-rust demonstrates it end to end.

UI Guide

foldback-ui is a Tauri desktop app for inspecting a .foldback session — a recorded file, or a running game connected live.

Run it from a checkout with:

cargo run -p foldback-ui

Opening a session

Three ways:

  • Drag a .foldback file onto the window — no dialog, no setup.
  • Click Open session… and pick a file.
  • Click Connect live… and give it a game’s live-mode URL (defaults to ws://127.0.0.1:9871) — see Cookbook recipe 7 for embedding the server side in your game. The same timeline/peer-comparison/drill-down views below fill in as frames arrive; the timeline auto-follows the latest tick until you scrub manually. A disconnect (the game exits, or the connection drops) is shown in the status pill with the real reason — whatever streamed in stays inspectable, nothing is discarded.

What you’re looking at

  • Tick timeline: the full recorded tick range, green where every reporting peer agreed, red from the first divergence onward. Click anywhere to scrub; the accent line marks your current tick. Accent tick marks are recorded snapshots.
  • Peers at tick N: each peer’s hash for the selected tick, with the odd-one-out highlighted red when they disagree. Below it, the last tick peers agreed on and the nearest snapshot at or before the divergence, when one exists.
  • Session: which hashing levels this file actually has data for (tick, and entity/field if recorded), whether the recording ended cleanly, and counts of metadata entries and snapshots.
  • Bisection result: entity/field-level drill-down, when the file has EntityHash/FieldHash frames for the selected tick (Session::hash_entity/hash_field, cookbook recipes 4/5 — see examples/ggrs-demo for a real recording that includes them). A session with only Level 1 (tick) data shows a plain note here instead of fabricated detail. When field data does exist, the UI shows the raw diverging values side by side and does not guess a root cause — check them against your own simulation code.

See the Foldback UI mockup for the original visual spec this was built against.

CI Integration

Check an already-recorded file

foldback ci-check session.foldback

Exit codes: 0 clean, 1 divergence found (report on stderr), 2 couldn’t read the file. See the full contract in CLI Reference.

Example GitHub Actions step

- name: Check for desync
  run: |
    cargo run --release --bin my-sim -- --replay fixtures/match.log --record out.foldback
    foldback ci-check out.foldback

The in-process pattern (no CLI needed)

If your test harness already runs the simulation twice in-process (the GGRS::SyncTestSession pattern, generalized), skip the file entirely and compare finish() directly:

#![allow(unused)]
fn main() {
let mut a = Session::builder().peer_count(1).build()?;
let mut b = Session::builder().peer_count(1).build()?;

for tick in 0..NUM_TICKS {
    run_one_tick(&mut world_a, tick);
    run_one_tick(&mut world_b, tick);
    a.hash_tick(tick, &serialize(&world_a))?;
    b.hash_tick(tick, &serialize(&world_b))?;
}

assert_eq!(a.finish(), b.finish());
}

This is a plain Rust assert_eq! — wire it into whatever test runner you already use (cargo test, a custom harness), no foldback-cli involved. See Sessions, Files, and Live Mode.

Protocol & File Format Spec

Two wire formats: the at-rest .foldback session file, and the live-mode transport between a running game and the UI. Both versioned from day one — external tools (CI scripts, third-party engine bindings) will depend on this contract, so breaking it silently is the one mistake to design against.

1. .foldback session file format

Chunked binary log, not a database — append-only, streaming-writable (frames are written as they happen, no rewrite-the-whole-file-per-tick cost), and tolerant of truncation (a crash mid-recording must still yield a parseable prefix). Implemented in foldback_core::format.

1.1 Header (fixed 32 bytes)

OffsetSizeFieldNotes
04magicASCII FBK1
42format_versionu16, starts at 1
62flagsreserved, must be 0 in v1
84tick_rate_hzu32
124peer_countu32
1616build_idopaque bytes, game-supplied (e.g. a hash of the game build) — lets a reader warn “this session was recorded against a different build than the one you’re diffing against”

1.2 Frame stream

Each frame: [u8 frame_type][u32 payload_len][payload bytes]. A reader that hits EOF mid-frame (impossible payload_len, or a short read) discards that partial frame and stops — everything before it is still valid.

TypeNamePayload
0x01TickHashu64 tick, u16 peer_id, u64 hash
0x02EntityHashu64 tick, u16 peer_id, u64 entity_id, u64 hash (Level 2 bisection data)
0x03FieldHashu64 tick, u16 peer_id, u64 entity_id, u32 field_name_len, [field_name bytes], u64 hash, u32 value_len, [value bytes] (Level 3 — carries the actual value for the diff view, not just a hash)
0x10Snapshotu64 tick, u16 peer_id, u32 compressed_len, [zstd bytes]
0x20Metadatau32 key_len, [key bytes], u32 value_len, [value bytes] — free-form annotations, not interpreted by the core
0xFFEndOfStreamempty — written on clean shutdown; its absence is how a reader knows a file is a truncated/crashed recording

1.3 Design notes

  • Field names are UTF-8, not an enum — keeps the format engine-agnostic.
  • No random-access index in v1 (pure log). A v2 addition, if a real session ever needs it, is a trailing index block written at EndOfStream time — additive, old readers just ignore trailing bytes after the frames they understand.
  • Versioning rule: format_version bumps only on a breaking change to frame layout. New frame types can be added without a version bump as long as readers skip unknown frame_type bytes by length rather than erroring — this is the actual forward-compatibility mechanism, more important than the version number itself. foldback_core::format::FrameReader implements exactly this.

Full rationale: RFC-0001.

2. Live-mode transport

foldback_core::live on the game side, foldback-ui’s “Connect live…” on the UI side, proven against each other end to end (see examples/live-demo). Local loopback WebSocket (ws://127.0.0.1:<port>), chosen over a Unix socket/named pipe so the exact same message framing works unmodified for a genuinely remote session later, at negligible cost over loopback today.

2.1 Messages

JSON for control messages (rare, human-debuggable), binary frames (same layout as §1.2) for the actual hash/snapshot stream.

Control (JSON, text WS frames):

{ "type": "hello", "protocol_version": 1, "tick_rate_hz": 60, "peer_count": 2, "build_id": "..." }
{ "type": "goodbye", "reason": "game_exited" }

Data (binary WS frames): identical byte layout to a .foldback frame — deliberately, so the UI’s parser is one code path for both live and offline mode.

2.2 Connection lifecycle

  1. Game starts, opens a WS listen socket, waits (non-blocking — must never stall the game loop waiting for a UI to connect).
  2. UI connects, receives hello.
  3. Game streams TickHash/Snapshot/etc. frames as they occur.
  4. Either side can disconnect at any time without protocol-level cleanup required — reconnect just re-sends hello.
  5. UI can optionally request the game start recording to a .foldback file simultaneously — live viewing and offline capture aren’t mutually exclusive.

2.3 Why not gRPC/protobuf

Keeps the game-side dependency footprint minimal — a lot of this audience is C/C++/C# game code that doesn’t want a protobuf toolchain pulled in for a debug hook. The binary frame format is already shared with the file format, so there’s no second format to maintain. Full rationale: RFC-0004.

3. C ABI surface (foldback-sys)

foldback-sys/include/foldback.h, generated via cbindgen from the Rust crate — never hand-edited, regenerated whenever the crate’s public extern "C" surface changes. Covers session lifecycle, Level 1/2/3 hashing, schema-drift’s foldback_record_schema, pending-hash draining, divergence checking, and error reporting; used by the Unity and Unreal bindings (Godot calls foldback-core directly through gdext instead — see Architecture). See C API for the header itself, per this project’s “link out, don’t duplicate” rule for generated references — not reproduced here.

All fallible calls return a status code, never throw/panic across the FFI boundary (a Rust panic unwinding into C/C++/C# calling code is undefined behavior) — every entry point is wrapped in catch_unwind and converts to an error code as a hard rule, not a best-effort. Full rationale: RFC-0002.

Rust API reference

Generated cargo doc output isn’t published anywhere yet (pre-crates.io). Once foldback-core is published, its docs.rs page becomes the canonical generated reference — this page stays hand-written and links out rather than duplicating it, per the project’s “link out, don’t duplicate” rule for generated references.

Architecture

Vision

A standalone, engine-agnostic desync detection and debugging toolkit for lockstep/rollback multiplayer games — the same “small embedded hook + separate visualizer app” model as Tracy Profiler or RenderDoc. Built to be the default answer to “how do I find my desync bug,” rather than every studio reinventing a SyncCoordinator/SyncTestSession in-house.

Target audience: indie to mid-size multiplayer game developers (Rust/GGRS, Unity, Godot, custom engines), plus anyone building deterministic simulations (RTS, fighting games, physics-based competitive games).

Overview

┌─────────────────────┐     hash stream / snapshots     ┌──────────────────────┐
│   Game process(es)   │ ───────────────────────────────▶│  .foldback file OR    │
│  (engine binding →   │        (local file OR            │  live WS connection   │
│   core lib, C ABI)   │         live socket)             │                        │
└─────────────────────┘                                   └──────────┬───────────┘
                                                                       │
                                                            ┌──────────▼───────────┐
                                                            │   Foldback UI (Tauri)  │
                                                            │  timeline + bisector  │
                                                            │  + diff viewer        │
                                                            └───────────────────────┘
                                                            ┌───────────────────────┐
                                                            │   foldback-cli         │
                                                            │  CI mode, text report  │
                                                            └───────────────────────┘

Three independently useful layers:

  1. Core library (foldback-core, Rust) — hashing, snapshot storage, bisection algorithm, session file format.
  2. CLI (foldback-cli) — thin binary over the core: analyze, ci-check, lint, schema-diff (see CLI Reference).
  3. UI (foldback-ui, Tauri + web frontend) — visual timeline, drill-down diff view, live and offline mode.

Engine bindings sit outside the core repo boundary conceptually but ship from the same monorepo:

  • foldback-sys — raw C ABI header + Rust FFI crate (source of truth for the header, generated via cbindgen) — Level 1/2/3 hashing, plus schema-drift’s foldback_record_schema.
  • foldback-rs — idiomatic Rust wrapper, GGRS/Bevy integration helpers, bevy/bevy-debug-panel reflective-hashing features.
  • Unity (bindings/unity), Godot (bindings/godot), and Unreal (bindings/unreal) bindings — each with reflective hashing (see Auto/Reflective Hashing) and a real in-editor visibility dock; Unreal also has a Mass Entity integration. See their respective integration pages.

Repo layout

foldback/
  crates/
    foldback-core/   # hashing, snapshot, bisection, session file format
    foldback-sys/    # C ABI surface (generates foldback.h via cbindgen)
    foldback-rs/     # idiomatic Rust wrapper + GGRS/Bevy helpers
    foldback-cli/    # the `foldback` binary
    foldback-derive/ # #[derive(FoldbackHash)] proc macro
    foldback-godot/  # the Godot GDExtension crate (calls foldback-core directly)
    foldback-ui/     # the Tauri app
  bindings/
    unity/           # UPM package, Runtime/ + Editor/
    godot/           # GDExtension addon (built from crates/foldback-godot)
    unreal/
  docs/              # this site
  examples/
    minimal-rust/       # smallest integration, no engine
    ggrs-demo/
    live-demo/          # live-mode transport end to end
    bevy-editor-demo/   # a real bevy_egui in-editor dock
    unity-demo/
    godot-demo/
    unreal-demo/        # custom fixed-tick lockstep integration
    unreal-mass-demo/   # Mass Entity integration

Why Rust for the core

  • Bit-for-bit determinism concerns already dominate this audience — Rust’s tooling culture (and this project’s own determinism CI job) matches that.
  • #[no_mangle] extern "C" gives a C ABI for Unity/Godot/anything else without a second implementation.
  • xxHash3, zstd, and the serialization ecosystem all have mature Rust crates.

Generated API reference

Not published yet (pre-crates.io) — see the Protocol Spec for the linking-out convention this site follows once it is.

C API

foldback-sys is a full C ABI over foldback-core — session lifecycle, Level 1/2/3 hashing (foldback_hash_tick/foldback_hash_entity/foldback_hash_field and their peer-recording counterparts), schema-drift’s foldback_record_schema, pending-hash draining, divergence checking, and error reporting. It’s what bindings/unity and bindings/unreal are both built on (Godot calls foldback-core directly through gdext instead — see Architecture).

The generated header is the actual reference, not this page — see crates/foldback-sys/include/foldback.h (cbindgen-generated, never hand-edited — see RFC-0002 for why generation is the source of truth), per the project’s “link out, don’t duplicate” rule for generated references. Regenerate it after any change to the crate’s #[no_mangle] extern "C" surface: cbindgen --config crates/foldback-sys/cbindgen.toml --crate foldback-sys --output crates/foldback-sys/include/foldback.h.

Wire format the C ABI’s data ultimately feeds into: Protocol Spec §1. Live-mode transport (also C-ABI-adjacent, since it streams the same frame types): Protocol Spec §2.

Testing Strategy

Every crate and binding carries real tests, not just compiled code: unit/proptest coverage in foldback-core, golden-file/integration tests in foldback-cli, FFI-boundary tests in foldback-sys (run against a real built native library), and each engine binding verified against its own real toolchain in CI — a .NET P/Invoke harness and a real IL2CPP AOT build for Unity, a real headless Godot engine for Godot, a real locally-built Unreal Engine for Unreal (no hosted CI leg — see bindings/unreal/README.md for why). All green on every push (fmt, clippy, cross-OS test, determinism, and fuzz-smoke) — exact test counts drift too fast to keep accurate here, check CI for the current numbers.

1. Core library (foldback-core)

Unit tests — hash function known-vectors, ring buffer wraparound/retention/capacity edge cases, snapshot compression round-trips (empty/tiny/large, corrupted-data handling), session file format round-trips for every frame type plus truncated-file recovery, bisection engine given a synthetic hash tree with a known injected divergence.

Property-based tests (proptest) — session file round-trip over arbitrary frame sequences, bisection engine over arbitrary hash trees with a randomly-placed single injected divergence, ring buffer retention invariant over arbitrary push sequences.

Fuzzing (cargo-fuzz) — the session file parser is the highest-value target: it’s fed real files from the wild (users attaching .foldback files to bug reports), including corrupted/truncated/adversarial ones. crates/foldback-core/fuzz/fuzz_targets/parse_session_file.rs feeds arbitrary bytes through Header::read_from + FrameReader, checking only that the format’s own contract holds (protocol-spec.md §1.2: a malformed/truncated file returns Err or yields a valid prefix, never panics or hangs) — CI’s fuzz-smoke job runs it for 60s on every push, ubuntu-latest only (cargo-fuzz/libFuzzer needs a real clang toolchain for its sanitizer-coverage runtime, which doesn’t link on MSVC). A hand-picked adversarial-byte-sequence battery in crates/foldback-core/tests/fuzz_smoke_sanity.rs covers the same code path via plain cargo test, runnable on any platform.

Cross-platform determinism tests — the one category that isn’t “does the code work” but “does the hash mean what it claims to mean.” The CI determinism job: hash::tests::known_vectors runs across Linux/Windows/macOS × x86_64/ARM64, asserting a fixed input hashes to a pinned constant on every platform, plus the same test at -O0 and -O3 to catch compiler-introduced non-determinism independent of the game’s own code.

2. CLI (foldback-cli)

Golden-file tests against checked-in fixtures (crates/foldback-cli/tests/fixtures/: clean, diverging, and truncated sessions) assert exact output. The ci-check exit-code contract (0 clean, non-zero on divergence) is tested explicitly, since external CI pipelines script against it directly. Argument-parsing edge cases (missing file, malformed file, missing argument) assert a clear error message and correct exit code, never a panic.

3. Engine bindings

Two tiers per binding: binding-level unit/FFI-marshaling tests, and an integration demo doubling as a live test — run two instances headless, inject a deliberate divergence, assert Foldback detects and bisects to the injected point. See each binding’s own integration page for what that looks like concretely (a .NET console harness for Unity, a headless Godot script run, an Unreal Automation Test).

4. UI (foldback-ui)

Component/session-loading unit tests run as part of cargo test --workspace. Visual regression tests (Playwright screenshot diffing against baseline images) and an E2E smoke test (drag a fixture file onto the window, assert the timeline renders and the drill-down panel shows the expected diff) aren’t set up yet.

5. CI pipeline shape

See CI & Releases for the full job graph.

6. What NOT to test

Not chasing 100% line coverage as a target — the property-based and fuzz tests on the session-file/bisection core are worth far more than incidental coverage of CLI argument-parsing branches. Not testing Tauri’s own window-chrome plumbing, or third-party component internals — test Foldback’s usage of them, not their own correctness.

Performance

The constraint

Foldback’s hot path runs inside someone else’s frame budget — a 60Hz game has 16.6ms per frame total, and hashing is competing with everything else the game does that frame. The design rule: treat under ~5% of that budget (≈830µs) as the ceiling for combined hashing cost at a given entity count, and a low single-digit percentage as the actual target — a debug-adjacent tool asking for more than that is a hard sell regardless of the exact number.

Measured cost at scale

A dedicated benchmark answers the load-bearing question directly: does the hashing budget hold at realistic RTS-scale entity counts (grounded in real numbers — 5,000–50,000 simulated entities in a busy late-game battle, not a round guess)?

The budget holds with large margin, confirmed on two tiers (a full-power dev machine and a throttled stand-in) with both mean and p99 tail latency measured.

EntitiesFull hot path (hash + ring-buffer handoff), worst case (p99, throttled tier)% of 16.6ms frame
1001.48 µs0.009%
1,0002.88 µs0.017%
5,00015.45 µs0.093%
10,00024.85 µs0.150%
50,000102.02 µs0.615%
100,000193.19 µs1.164%

Real per-entity marginal cost: ~1.5–1.6 ns/entity. Scaling is clean and linear throughout — no cache-locality or allocation knee found in Foldback’s own code path at any tested entity count, on either tier. Tail latency is tight everywhere measured (p99/p50 ratio ≤1.07) — the specific failure mode of “a spike is worse than consistent slowness” did not occur.

Design rules the hot path follows

  • Zero-alloc on the hash_tick call itself — the caller serializes, Foldback only hashes bytes it’s handed.
  • No syscalls on the hot path — file/socket I/O happens on a background thread via a bounded SPSC channel.
  • Snapshot compression (zstd) is explicitly off the hot path — it’s real cost (single-digit milliseconds at 100,000 entities), too slow for per-tick use, and only runs at the configured snapshot cadence.

What’s not benchmarked

A second tier that’s genuinely different hardware (rather than the same CPU under core-affinity/priority throttling) and peak memory of the ring buffer + pending-frame queue.

CI & Releases

CI

fmt-and-lint            cargo fmt --check + clippy -D warnings --all-features
core-test               cargo test --workspace --all-features, matrix: ubuntu/windows/macos
determinism             hash::tests::known_vectors, matrix: ubuntu/windows/macos/
                        ARM64(×2), each at opt-level 0 and 3
bindings-unity-cs       .NET P/Invoke harness against a real built foldback_sys,
                        matrix: ubuntu/windows/macos
bindings-unity-il2cpp   a real Unity Editor + IL2CPP AOT player build/run, windows-latest
bindings-godot          a real headless Godot 4.7.2 engine, matrix: ubuntu/windows/macos
fuzz-smoke              cargo-fuzz against the session-file parser, 60s bounded run,
                        ubuntu-latest only (libFuzzer needs a real clang toolchain —
                        confirmed not to link on MSVC)

Triggers: pull_request, push to main, workflow_dispatch. All jobs above are required to merge (branch protection on main) except bindings-unity-il2cpp, which needs real UNITY_EMAIL/UNITY_PASSWORD secrets and so only runs meaningfully on this repo’s own pushes, not arbitrary forks’ PRs. The determinism job is treated as a release blocker if it ever fails — never a flaky-retry candidate, since a desync tool whose own hash isn’t cross-platform-stable is self-defeating.

cli-golden tests (golden-file tests in crates/foldback-cli/tests/golden.rs) and foldback-ui’s own unit tests already run as part of core-test’s cargo test --workspace --all-features — no separate jobs needed while that stays fast. The Unreal binding has no CI leg at all (no scriptable install path for Unreal Engine the way unity-setup/a downloaded Godot binary provide for the other two) — verified locally against a real Unreal Engine 5.8.2 build instead; see bindings/unreal/README.md.

Documentation site

This site — built with mdBook, deployed to GitHub Pages on every merge to main that touches docs/**, independent of the crate/UI release cadence.

Repository topology

Single monorepo — one Cargo.toml workspace, one CI pipeline, one version-bump PR can touch core+CLI+UI+bindings together when they need to move in lockstep (e.g. a protocol version bump), rather than coordinating releases across five separate repos.

Versioning scheme

  • foldback-core/foldback-sys/foldback-rs/foldback-cli share one workspace version, bumped together — they’re tightly coupled enough that independent versioning would just create confusing “which CLI version works with which core” questions.
  • foldback-ui versions independently — a UI bugfix release shouldn’t force a crates.io core bump, and vice versa.
  • Each engine binding versions independently (its own package manifest — package.json for the Unity UPM package, plugin.cfg for the Godot addon) and declares a compatible core-version range.
  • The .foldback file format and live-mode protocol get their own version number (format_version, protocol_version), independent of crate versions — two different foldback-cli versions might both speak format_version: 1.
  • Pre-1.0 (0.x) for everything until the API has had real usage — not rushing to 1.0.0 just to look mature.

Governance & Trademark

License

MIT OR Apache-2.0, dual-licensed — the Rust-ecosystem convention, so anyone pulling in foldback-core as a dependency already expects this pairing. Apache-2.0 adds an explicit patent grant and patent-retaliation clause that MIT alone lacks; dual-licensing keeps the barrier to adoption as low as MIT while still making that protection available to anyone who wants it. A contributor’s PR is licensed under both automatically — see CONTRIBUTING.md’s “unless you explicitly state otherwise” clause, the actual mechanism, not just a norm.

Applied uniformly across every surface (Rust crates, the Tauri UI’s Rust and frontend code, Unity/Godot/Unreal packages) — no split-license monorepo confusion.

DCO, not a CLA. A contributor signs off (git commit -s) attesting they have the right to submit under the project’s license — no copyright assignment, ever. This is deliberately harder to change later (a license change would need affirmative agreement from every contributor who holds copyright on code still in the tree) — an accepted tradeoff for keeping contribution low-friction now.

Trademark

Protects the name, not the code — so a user who downloads something called Foldback can trust it’s the real project, which matters more than usual for a debugging tool specifically (a malicious fork mishandling session data would damage trust in the real project’s core promise).

Current posture: defer formal registration, don’t skip it forever. Relying on common-law trademark rights (which attach automatically through actual use) until there’s meaningful adoption to justify the cost of a real registered-trademark search and filing. A TRADEMARK.md (modeled on the Rust Foundation’s policy — permissive for describing compatibility, restrictive on implying official endorsement for a modified fork) gets written once that trigger fires, not before.

Contributor governance

Currently: solo maintainer. No formal governance document needed yet — imposing a committee structure on a one-person project doesn’t buy anything at this size. The RFC-shaped planning docs already in this repo (see RFCs) are what deliberate design decision-making looks like in practice, even solo.

Trigger to formalize a GOVERNANCE.md: a second contributor merging PRs regularly, not just submitting them. At that point: maintainer tiers (core-crate vs. per-binding, following the repo’s own seams), an RFC process for anything touching the protocol spec or C ABI (a markdown doc in project/rfcs/, a stated minimum comment period, maintainer consensus or the founding maintainer’s tie-break), and normal review-and-merge with no RFC needed for day-to-day PRs.

A higher bar than a normal RFC applies to two things, both close to irreversible for the whole community: changing the license (practically very hard given no copyright assignment — deliberate), and transferring trademark/project ownership.

Code of conduct enforcement: a named contact from day one, even solo — see CODE_OF_CONDUCT.md in the repo.

Bus-factor / succession: set up at bootstrap, not deferred — a second trusted crates.io owner once the crate is published, a GitHub org (not a personal account) with a second org owner once one exists. Cheap now, expensive to improvise during an actual emergency.

Funding

GitHub Sponsors / Open Collective, opt-in, disclosed plainly, once there’s real usage to justify it — doesn’t change the license or create a paid tier. Not a current priority.

RFCs

Foldback’s RFC process formally activates once a second regular contributor is merging PRs (see Governance) — until then, these are the founding maintainer’s own design-decision record, written in the same format an actual RFC process would use, so a later contributor gets the same reasoned paper trail a real process would have produced.

Template: a lightweight skeleton (Summary, Motivation, Design, Drawbacks, Alternatives considered, Prior art, Unresolved questions, History) — deliberately lighter than Rust’s own RFC template, calibrated for a young tool rather than a language with millions of users.

Index

RFCTitleStatus
0001The .foldback session file formataccepted
0002The C ABI surface (foldback-sys)accepted
0003Field-hashing derive macro defaults to opt-inaccepted
0004Live-mode transport (loopback WebSocket)accepted
0005Bisection granularity model and its snapshot-restore limitationaccepted

Numbering is sequential and never reused, even if an early-numbered RFC is later rejected — a gap in the sequence is normal.

0001 - Session File Format


rfc: 0001 title: The .foldback session file format status: accepted created: 2026-09-14 supersedes:

Summary

Define .foldback as an append-only, length-prefixed binary frame stream with a fixed 32-byte header, rather than an embedded database or other structured format.

Motivation

Every binding, the UI, and any third-party tooling built on top of Foldback reads this format — it’s externally depended on the moment a game starts recording. Getting the extensibility and failure-mode story right before the first byte is written matters more here than almost anywhere else in the project.

Design

Full spec: Protocol & File Format Spec §1. Fixed 32-byte header carrying format_version; a stream of length-prefixed frames after it. format_version is bumped only on breaking frame-layout changes. Unknown frame types are safely skippable by their length prefix — this is the actual forward-compatibility mechanism, not a version-negotiation handshake.

Drawbacks

No random-access index in v1 — large-session scrubbing performance in the UI’s read path is untested until perf-spike-style benchmarking covers it. Treated as an additive v2 concern, not a v1 blocker.

Alternatives considered

A structured format (SQLite, an embedded DB) was rejected in favor of an append-only log. A database adds write-amplification and corruption-recovery complexity a debug tool doesn’t need. A truncated log file degrading gracefully to “everything before the cut” is both simpler and exactly the right failure mode for “the game crashed mid-recording.”

Prior art

Append-only, length-prefixed framing is the same shape used by most streaming record/replay formats (e.g. PCAP-style capture files) specifically because it tolerates truncation gracefully — the same property that makes it right here.

Unresolved questions

Whether a v2 index format should be a separate sidecar file or an in-band frame type — not decided here, deferred until real large-session performance data exists.

History

  • 2026-09-14: accepted.

0002 - C ABI Surface


rfc: 0002 title: The C ABI surface (foldback-sys) status: accepted created: 2026-09-14 supersedes:

Summary

foldback-sys exposes a low-level, status-code-based C ABI, generated into foldback.h by cbindgen from #[no_mangle] extern "C" functions — never hand-written or hand-edited.

Motivation

Every non-Rust binding (Unity, Godot, Unreal) depends on this surface being stable and safe to call across an FFI boundary from a language/runtime that may disable exceptions (Unreal) or use ahead-of-time compilation with real marshaling restrictions (Unity IL2CPP).

Design

Full spec: Protocol & File Format Spec §3. cbindgen-generated header is the single source of truth. Every fallible call returns a status code rather than throwing/panicking across the boundary. catch_unwind at every FFI entry point is a hard rule — a Rust panic must never unwind across the FFI boundary, since that’s undefined behavior on the C/C++/C# side.

Drawbacks

Status-code-based error handling is more verbose for callers than an idiomatic wrapper would be. Accepted deliberately: the idiomatic wrapper is exactly what foldback-rs and the per-engine binding layers exist to provide on top of this low-level, safety-first surface — the verbosity is pushed to one place instead of leaking into every caller.

Alternatives considered

An exception-based or panic-propagating API was not seriously considered — it’s simply unsound across this FFI boundary given the target runtimes (Unreal disables exceptions in places; IL2CPP has its own marshaling constraints). Not a close call.

Prior art

Status-code-return + catch_unwind-at-the-boundary is the standard, well-established pattern for Rust libraries exposing a C ABI (e.g. how most -sys crates in the ecosystem that wrap fallible Rust logic for C consumers behave).

Unresolved questions

None outstanding — this is a narrow, fully-resolved RFC.

History

  • 2026-09-14: accepted.

0003 - Field-Hashing Opt-In


rfc: 0003 title: Field-hashing derive macro defaults to opt-in status: accepted created: 2026-09-14 supersedes:

Summary

#[derive(FoldbackHash)] (and its per-engine equivalents) hashes nothing until a field is explicitly marked #[foldback(hash)] — opt-in, not opt-out.

Motivation

Get this decided once, correctly, before four different engine bindings each have to mirror it independently. A mismatch between engines here (one opt-in, one opt-out) would be a confusing, undocumented inconsistency discovered by users the hard way, long after the first binding shipped.

Design

Full reasoning: Cookbook §5. #[derive(FoldbackHash)] marks no fields for hashing by default; a field must be explicitly annotated (#[foldback(hash)] in Rust, [FoldbackHash] in C#, the foldback_ name prefix in Godot — GDScript has no custom-attribute mechanism GDExtension can hook into, see Auto/Reflective Hashing, UPROPERTY(meta=(FoldbackHash)) in Unreal) to be included.

Drawbacks

More integration friction per field than an opt-out default would have — every field a user cares about tracking needs an explicit annotation. Mitigated, not eliminated, by foldback lint’s unmarked-field-surfacing (see the Cookbook) that surfaces unmarked fields rather than leaving the gap invisible.

Alternatives considered

Opt-out (hash everything by default, #[skip] to exclude) was rejected specifically because it silently turns any newly-added non-deterministic field (a timestamp, a debug name) into a permanent phantom divergence source with no signal that it happened. That’s the one failure mode most likely to teach users to distrust the tool’s own bisection results.

Prior art

Opt-in field selection for serialization/hashing purposes is the more conservative and more common choice in determinism-sensitive tooling generally (compare: explicit #[derive(Hash)] field inclusion conventions in other Rust serialization ecosystems) — erring toward “silently include too little, loudly lint it” over “silently include too much.”

Unresolved questions

None outstanding for the default itself — the foldback lint unmarked-field-surfacing mechanism referenced above has its own design detail in the cookbook, not repeated here.

History

  • 2026-09-14: accepted.

0004 - Live-Mode Transport


rfc: 0004 title: Live-mode transport (loopback WebSocket) status: accepted created: 2026-09-14 supersedes:

Summary

Live mode uses a loopback WebSocket for its transport, with JSON control messages and binary data frames that share the exact same byte layout as .foldback file frames.

Motivation

Needed a decision between a Unix socket/named pipe (simpler, desktop-only) and a WebSocket (slightly heavier, but the same framing works unmodified if a genuinely remote session — a QA machine streaming to a developer over a real network — is ever wanted).

Design

Full spec: Protocol & File Format Spec §2. JSON for control messages (session start/stop, metadata). Binary data frames reuse the file format’s own frame layout deliberately, so the UI’s parser is one code path for both live and offline mode — no separate live-mode deserializer to keep in sync.

Drawbacks

A WebSocket is heavier than a Unix socket/named pipe for the common desktop-only case (local game process talking to a local UI process) — accepted for the optionality it buys on the remote-streaming case, and because the framing reuse benefit (one parser, not two) outweighs the marginal transport overhead.

Alternatives considered

gRPC/protobuf was rejected specifically on dependency-footprint grounds (protocol spec §2.3): a lot of this audience’s game code is C/C++/C# that doesn’t want a protobuf toolchain pulled in just for a debug hook, and hand-rolled framing doesn’t need schema codegen since it reuses the file format’s own frame layout already.

A plain Unix socket/named pipe was considered and not chosen, specifically to keep the door open to a genuinely remote (networked) session without a later transport rewrite.

Prior art

Reusing on-disk record format as the wire format for live streaming is a well-established pattern (e.g. many replay-capable tools serialize the same frame structure whether writing to disk or streaming live) — chosen here for exactly the same “one code path” reason.

Unresolved questions

None outstanding for the transport choice itself; authentication/access-control for a genuinely networked (non-loopback) session is out of scope for this RFC and not yet designed.

History

  • 2026-09-14: accepted.

0005 - Bisection Granularity Model


rfc: 0005 title: Bisection granularity model and its snapshot-restore limitation status: accepted created: 2026-09-14 supersedes:

Summary

Ratifies the tick→entity→field bisection model as Foldback’s central mechanism, and formally documents a real limitation: bisection can only narrow to whatever granularity of hash data was actually recorded (Level 1/2/3) — narrowing below that requires re-simulating from a snapshot with finer instrumentation enabled, which requires the game to have deterministic snapshot-restore capability.

Motivation

The bisection model is the product’s actual premise. It has a real limitation worth ratifying as an explicit, accepted-with-eyes-open design constraint rather than leaving it as a footnote discovered later by a confused user. The RFC format is the right place to make a limitation this central official rather than incidental.

Design

Bisection reads whatever granularity of hash data was actually recorded at capture time (Level 1 combined-blob, Level 2 per-entity, or Level 3 per-field, per the cookbook). Narrowing below the recorded granularity requires re-simulating from a snapshot with finer instrumentation enabled. This requires the game itself to support deterministic snapshot-restore — rollback-style games have this by construction; a pure lockstep-without-rollback game may not.

Drawbacks

This is a real gap in the “bisect to find exactly what diverged” pitch for one class of integration (lockstep-without-rollback games lacking snapshot-restore). Accepted as an honest, documented limitation — stated plainly in the guide — rather than something to silently under-promise around or discover via a support issue.

Alternatives considered

Always recording at the finest granularity (Level 3, per-field) by default was considered and rejected — the perf-spike work shows headroom exists, but always-maximal instrumentation still isn’t free, and it would remove the explicit level-selection tooling exists to provide (per cookbook recipes 1–4, users choose granularity deliberately based on their integration’s needs).

Prior art

The general shape — coarse continuous monitoring with the option to re-run at finer instrumentation once a problem window is identified — mirrors how sampling profilers and most production tracing tools handle the same fidelity-vs-overhead tradeoff.

Unresolved questions

Whether foldback-cli should grow a first-class --resimulate <sim-binary> mode that calls back into a game’s own binary to re-run from a snapshot generically. Not decided here.

History

  • 2026-09-14: accepted.

FAQ

This page grows from real questions people actually ask, not speculation.

Where do I start? How Foldback Works for the pieces and what each one does; the Quickstart for the smallest real integration. Not yet on crates.io — depend on it as a git or path dependency in the meantime.

Why xxHash3 and not a cryptographic hash? This is integrity-checking (did two peers compute the same state), not security — a non-cryptographic hash is faster and that’s the only property that matters here. See the performance spike for why speed was worth validating rather than assuming.

Does Foldback slow down my game? The validated hot-path cost (hash + ring-buffer handoff) is under 200µs even at 100,000 simulated entities — well under 1.2% of a 60Hz frame budget. See Performance for the full numbers.

Why is field-hashing opt-in instead of opt-out? So a newly-added non-deterministic field (a timestamp, a debug label) can never silently become a phantom divergence source. See RFC-0003.

Can I use this with Unity/Godot/Unreal? Yes to all three — see their integration pages: Unity, Godot, Unreal. Each has reflective hashing (see Auto/Reflective Hashing), and Unreal also has a Mass Entity integration.

Troubleshooting

This page grows from real reported problems.

Foldback reports a divergence I can’t explain in my own simulation logic

Read this section before filing a bug against Foldback. Foldback can only be as deterministic as what’s fed into it — if a divergence is real but the cause isn’t in your gameplay code, it’s almost always one of these:

  • A compiler optimization flag on your own build, most commonly -ffast-math or aggressive FMA (fused multiply-add) contraction. These can change floating-point results in ways that are still IEEE-754-legal per compiler but not bit-identical across platforms/builds — Foldback will correctly and faithfully report the resulting divergence, because it is a real difference in the hashed state, just not a bug in your simulation. If you’re optimizing for performance, check your build flags first before debugging gameplay logic. This is a well-documented, real class of bug — not a hypothetical.
  • A non-deterministic field accidentally included in what you hash — a wall-clock timestamp, a Instant::now()-derived value, an uninitialized-memory read, a hash-map iteration order (if you’re hashing a container yourself outside Foldback’s own field-hashing helpers, which already sort HashMap/HashSet for you). Double-check exactly which fields #[foldback(hash)] (or your reflective walker’s tag) actually covers — the opt-in design (cookbook §5) exists specifically to make this list explicit and reviewable, not to eliminate the mistake entirely.
  • A race condition in your own state capture — reading state for hashing before all of a tick’s writes have landed (a threading issue in your game’s own update loop, not in Foldback).

None of the above are bugs in Foldback: the whole point of the divergence detector is to report differences faithfully, even when the cause is upstream of it. If you’ve ruled out all three and still see an unexplained divergence, that’s worth a real issue report — see below.

foldback analyze/ci-check errors with “session file magic bytes do not match FBK1”

The file isn’t a valid .foldback file — either it’s a different file entirely, or it’s been corrupted/truncated before any valid header was written. Check the file was actually produced by Session::builder().record_to(...).

ended cleanly: false in an analyze report

Expected for a session where finish_recording() was never called — most commonly, the process crashed or exited before recording finished. This is by design: the format is truncation-tolerant, so everything recorded up to the cut is still valid and analyzable, but the report tells you honestly that it’s an incomplete recording rather than pretending otherwise.

divergence: none found but I know something’s wrong

check_divergence/find_first_divergence only compares ticks where 2 or more peers have reported a hash — a tick with only one peer’s data isn’t flagged as clean, it’s just not comparable yet. Make sure every peer is actually calling record_peer_hash (or hash_tick for the local peer) for every tick.

Also: Level 1 (per-tick) hashing only tells you which tick diverged, nothing finer. If you need to know which entity or field, that’s Level 2/3 — not built yet, see Hashing Levels.

Filing a real issue

If your problem isn’t covered here, open an issue with a minimal reproduction — a .foldback file or a small repro project, per the bug report template.