The M1 replay system in its three halves: the recording (M1-DET-02;
PRD FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed,
ADR 0002, ARCH-007, SCALE-005) — a versioned replay log format
plus the ReplayRecorder (the atomic, size-bounded writer), the
parseReplay/loadReplay readers, and the replay-identity hashes —
the execution (M1-DET-03): World::stateHash (the
deterministic state hash), runReplay (the identity-checked,
tick-by-tick re-run that produces the per-tick hash stream), and the
laige-replay runner that prints the stream and compares it against a
baseline — and the diff (M1-DET-05; FR-11.3): World::stateDiff
(the bounded per-(entity, component) comparison),
World::componentStateHash (the state hash without the PRNG substream
state — the diff's tick-alignment key), diffReplays (replay two logs
in lock step, align by tick, report the first divergent tick + the
bounded state diff), and laige-replay --diff <logA> <logB> --config <cfg> — the editor replay viewer's (M5-ED-15) report source. The
engine records one frame per completed tick
(Engine::startReplayRecording), laige-run --replay <path> wires the
flag that M1-HEAD-01 stubbed, and laige-replay --log <log> --config <cfg> [--expect <baseline>] closes the loop.
Public header: src/laige-sim/include/laige/sim/replay.h (the full
contract: format layout, identity hashes, the error tables, the
performance notes); implementation: src/laige-sim/replay.cpp.
Engine wiring: src/laige-sim/engine.cpp (Engine::startReplayRecording,
the per-tick write in the loop's onTick hook, finalization in
run_headless, abandonment in shutdown). CLI: tools/run/laige-run.cpp
(the --replay flag). Unit suite: ctest -R replay_record
(tests/laige-sim/replay_record_tests.cpp); fuzz: ctest -R fuzz_replay_parse (the parser's malformed-input surface, TEST-005).
laige::EngineConfig config;
config.tickRateHz = 60;
config.seed = 42;
laige::Engine engine = laige::Engine::create(config).value();
// Game code registers on the world (as always, before the run)...
engine.world()->registerComponent<MyComponent>();
engine.world()->registerSystem(MySystem_Def);
// ...then opts into replay recording, after all registration:
const laige::Status rec =
engine.startReplayRecording("/tmp/run.log",
laige::kDefaultReplaySizeLimit);
if (rec.isError()) { /* actionable: rec.error() + rec.errorText() */ }
const laige::Status status = engine.run_headless(10'000);
// The log appears at "/tmp/run.log" only after the run completes
// successfully (atomic temp+rename); on a failed run there is no
// partial log at the final path.The format is versioned (ARCH-007): a reader rejects unsupported
versions explicitly, and the header's formatVersion is the single
version gate. All integers are little-endian on every platform
(SCALE-005: byte order specified, not assumed). Layout:
header (kReplayHeaderSize = 40 bytes, fixed):
offset 0 magic "LGRP" 4 bytes
offset 4 formatVersion u16 (= kReplayFormatVersion = 1)
offset 6 reserved u16 (must be 0)
offset 8 seed u64 (the run's master seed)
offset 16 tickRateHz u32
offset 20 componentSchemaHash u64 (ADR 0002 replay identity)
offset 28 mathBackendId u32 (the SimMathBackend value)
offset 32 configHash u64 (the EngineConfig encoding)
frame records (n of them, n == the trailer's frameCount):
offset 0 tick u64 (1, 2, 3, ... — strictly
sequential from 1)
offset 8 byteLength u32 (<= kMaxReplayFrameBytes = 1 MiB)
offset 12 data byteLength bytes (the input frame;
M1 frames are zero-length —
the input data shape lands
with M3-INPUT-03)
trailer (kReplayTrailerSize = 16 bytes, fixed, last in the file):
offset 0 frameCount u64
offset 8 fileHash u64 (FNV-1a 64, byte-stream,
over every byte before the
trailer)
The header's five identity fields are the replay identity of ADR
0002: seed, tick rate, component schema hash, math backend id, and
config hash. A log is only replayable by a run whose identity matches
(exact equality of all five) — the runner's check lands with
M1-DET-03. The fileHash is the canonical byte-stream FNV-1a 64
(offset basis 0xcbf29ce484222325, prime 0x100000001b3 — fnv.org)
over header + frames: it catches truncation and bit rot, and makes the
log self-verifying.
Malformed-input behavior (SCALE-005: the parser's malformed-input
behavior is specified — every violation below is a MalformedInput
Status, never a crash and never a silent skip, CORE-008/ARCH-007):
| violation | result |
|---|---|
| size < header, null data with size > 0 | MalformedInput |
| bad magic | MalformedInput |
unsupported formatVersion |
MalformedInput (ARCH-007: explicit reject) |
| non-zero reserved field | MalformedInput |
frame length > kMaxReplayFrameBytes |
MalformedInput (checked before any overrun read) |
| frame record/payload extends past the body | MalformedInput |
tick not previous + 1 (first must be 1) |
MalformedInput |
| trailer frameCount ≠ frames parsed | MalformedInput |
| trailer fileHash ≠ computed hash | MalformedInput |
| trailing bytes past the trailer | MalformedInput |
Both identity hashes are word-stream FNV-1a 64, big-endian byte order
per u64 word (the house convention: the determinism state hashes, the
Prng golden vectors, laige-detcheck) — pure integers, no addresses,
no wall clock (ARCH-010):
componentSchemaHash(const World&)— over[componentCount, then per registered type in id order: id, size, alignment]. It is a function of the registration order (ids are dense in registration order): two worlds that registered the same types in the same order hash identically; a different order hashes differently. O(types), stack-only (769 words max — no allocation).configHash(const EngineConfig&)— over[tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math]. The tag word identifies this encoding. M1-CFG-01 grew the config schema (version, budgets, camera, asset_roots), but the encoding covers only the simulation-affecting fields — the declared presentation values never touch simulation (ARCH-009) — so the encoding (tag 1) is unchanged and every committed baseline/replay log stays valid (api/config.md).makeReplayIdentity(const World&, const EngineConfig&)— assembles the header'sReplayIdentityfrom the two hashes plus the config's seed/tick rate/backend.
These are the replay identity, not the sim state: M1-DET-03's
world.state_hash hashes the live sim state (components, PRNG state,
tick) on top of the identity the header already carries.
Write side, move-only: create(identity, path, maxBytes) →
writeFrame(tick, data, len) per completed tick → finish().
- Atomic — the recorder writes
path + ".tmp"(same filesystem aspath, so the finalrenameis atomic) and publishespathonly on a successfulfinish(). An interrupted or failed recorder leaves no file at the final path (the temp is removed by the destructor); a rename failure leaves the temp for inspection (the complete data is in it — the caller's Error log names it). - Size-bounded —
maxBytesbounds header + frames + trailer together (0meanskDefaultReplaySizeLimit, 128 MiB). A write that would exceed the cap is aBudgetExhaustedStatus(CORE-008: no unbounded growth, PERF-008/SCALE-003); a cap belowkMinReplaySizeLimit(header + trailer = 56) is rejected atcreateasInvalidArgument(a complete log could never fit).finish()on a cap that cannot fit the trailer is the sameBudgetExhausted. - Strict tick sequence — frames must arrive as tick 1, 2, 3, ...
(the engine's
onTickhook hands the recorder the completed tick count); any other tick is anInvalidArgument. - Sticky failure — the first failure sets the recorder's state;
every later
writeFrame/finishreturns the sameStatus(no partial recovery, no second failure of a different code). - Error table — empty path / cap below minimum / bad tick /
frame over
kMaxReplayFrameBytes→InvalidArgument; cap breach →BudgetExhausted; open/write/rename I/O →IoError.
parseReplay(const uint8_t*, size)— the in-memory parser; the full malformed-input table above. O(size), one output allocation per frame (theReplayLog's frames); no per-byte allocation.loadReplay(path, maxBytes = kDefaultReplaySizeLimit)— the file wrapper: a bounded read (the ADR 0003 JSON-bound precedent — a file larger thanmaxBytesis aMalformedInput, not a truncated parse), thenparseReplay. Missing/unreadable file →IoError; empty path →MalformedInput.
ReplayLog is the parsed value: identity (the header's five fields)
plus frames (tick + the payload bytes). The frame payload is an
opaque byte blob by design — M1 records zero-length frames (there
is no input system yet); the input data shape lands with M3-INPUT-03,
and the format version is the migration point (ARCH-007).
Status Engine::startReplayRecording(std::string_view path,
std::uint64_t maxBytes) noexcept;
bool Engine::replayRecordingActive() const noexcept;
std::uint64_t Engine::replayBytesWritten() const noexcept;
- Opt-in — recording is off by default; the disabled cost is one null check per tick (LOG-003/PERF-003: no formatting, no allocation, no I/O when off).
- Phase — call it once, after all component/system
registration, before
run_headless: the identity is captured from the live world + config at call time, so a registration after the call makes the recorded identity stale (the schedule-stale precedent, M1-SYS-02). - Per tick — the engine's loop
onTickhook writes one zero-length frame per completed tick (M1: no input yet; the frame bytes are the future input blob). - Failure stops the run — a recorder failure mid-run is not
swallowed:
run_headlessreturns the recorder'sStatus(at most one frame of extra ticks, the loop's bounded frame contract), the log is not published (no partial file at the final path), and the ordered shutdown still runs. - Finalization — on a successful bounded run,
run_headlesscallsfinish()(trailer + atomic rename) and logsreplay/record_finished(Info: path, bytes, frames); a failed run's shutdown logsreplay/record_aborted(Warn: path, bytes) and discards the temp. - Debug builds only — in release builds (
NDEBUG) the call is anInvalidArgumentwith areplay/record_disabledwarn (recording is a development tool; the format and the engine wiring exist in every build, the opt-in does not). - Structured events (AGENTS §14, subsystem
replay):record_started(Info: path, size_limit, seed, math_backend, config_hash, schema_hash),record_finished(Info),record_failed(Error: path, tick, error),record_aborted(Warn),record_already_started(Warn),record_start_failed(Warn),record_disabled(Warn, release builds).
laige-run --headless CONFIG.json [--ticks N] [--replay LOG]
--replay LOG now records the run (it was the M1-HEAD-01 stub):
- The engine's identity is captured at start (laige-run registers no
game components of its own — the built-in registration is complete at
create), the run records one zero-length frame per completed tick, and on a clean bounded run the log is atomically published atLOG. - The recorded tick count is platform-stable: a bounded
--ticks Nrun completes exactly N ticks (the CLI's frame budget 1 — api/engine.md), so a log recorded for N ticks carries exactly N frame records (N + 1 hash lines on replay) on every platform. A run under the engine's default catch-up budget can overshoot the target by up to budget - 1 ticks under overload; the CLI never uses that budget for bounded runs. - Debug builds only: in a release build the flag fails with
InvalidArgument(replay/record_disabled), the same contract as the engine call. - Failure is exit 2 (the flag's contract, like a config error):
laige-run: replay: {error text}on stderr, the run does not happen. A mid-run recording failure exits1(the run failed — thestatus=summary line carries the error name) with no partial log atLOG.
std::uint64_t World::stateHash(std::uint64_t tick) const noexcept;
— the 64-bit FNV-1a hash of the authoritative sim state at tick
completed ticks. Full contract in
api/entity.md ("The deterministic state hash"): the
canonical stream (tick → live handles → per-archetype component bytes
in lexicographic signature order → per-system PRNG substream state),
the scope in/out lists (history and non-authoritative state excluded —
convergent worlds hash identically), and the complexity/allocation
contract (cold path, O(capacity + live bytes + kMaxArchetypes²), no
allocation, const, no logging).
struct ReplayIdentityDiff { /* one bool per identity field */ };
ReplayIdentityDiff replayIdentityDiff(const ReplayLog&, const World&,
const EngineConfig&) noexcept;ADR 0002's replay identity is enforced field by field: a log
replayed against a (world, config) whose seed, tickRateHz,
componentSchemaHash, mathBackendId, or configHash differs is a
rejected replay — runReplay fails with ErrorCode::InvalidArgument
plus the replay/identity_mismatch structured warn naming exactly
which fields differ (both sides' values — the log's and the
world+config's). The caller must surface it (CORE-008: never silently
replay a foreign log); replayIdentityDiff is exposed for callers who
want the per-field report without a full run.
struct ReplayRunResult { std::vector<std::uint64_t> tickHashes; };
Result<ReplayRunResult, ErrorCode> runReplay(const ReplayLog&, World&,
const EngineConfig&) noexcept;One deterministic replay of a loaded log against a world:
- Identity check —
replayIdentityDiff; any differing field isErrorCode::InvalidArgument(thereplay/identity_mismatchwarn names the fields and both sides' values). - Determinism check — the world's deterministic mode must be
enabled (it is by default); a disabled world fails with
ErrorCode::InvalidArgument+ thereplay/determinism_disabledwarn — replaying a non-deterministic sim would produce meaningless hashes. - Schedule —
world.scheduleSystems(...)once, before the loop (the schedule is a function of the registrations, which the identity already pinned); a schedule failure returns the world'sStatus(already logged by the world). - Loop — one frame per tick:
beginFrame(); runSystems(schedule);(the log's frames are zero-length in M1 — there is no input system yet; non-empty frames are accepted and ignored until M3-INPUT-03 defines consumption). A failing tick returns the world'sStatusand the replay stops (no partial hashes in the result).
The result's tickHashes[i] is world.stateHash(i) after i
completed ticks — size frameCount + 1: index 0 is the initial
state's hash (before any tick), and index i (≥ 1) is the state
after tick i. That is the hash line contract the laige-replay
runner prints and compares:
<tick> <hash> tick 0,1,2,...,N — one line per completed tick,
plus the tick-0 initial line (N+1 lines total)
<hash> = 16 lowercase hex digits (the canonical FNV-1a 64 text form)
No allocation on the replay per tick beyond the result vector's single growth; the per-tick work is exactly the normal engine tick (the replay is the sim, not a second engine).
laige-replay --log LOG --config CONFIG.json [--expect BASELINE]
- stdout is only hash lines (the contract above) — pipeable and diff-able; the summary and every diagnostic go to stderr.
- Exit codes:
0— replayed and (if--expect) matched;1— hash mismatch (the first diverging tick is reported) or stream-length mismatch (the first missing/extra tick);2— usage/identity/log/baseline error (nothing was replayed). --expect BASELINE— compares the replayed stream against a baseline file of<tick> <hash>lines (CRLF tolerated, trailing newline optional). The first mismatch is reported on stderr as an actionable line pair —hash mismatch at tick 5 (first divergence)+ the baseline and replay values — and the run exits1. A different stream length is reported asbaseline stream length mismatch+first missing/extra at tick N, also1.- Baseline grammar is strict — exactly
<tick> <16 lowercase hex>, tick strictly0, 1, 2, ...; any other line is a malformed-baseline failure (2) naming the offending line. - Bounds (CORE-005): config read ≤ 1 MiB; baseline ≤ 8 MiB /
65 536 lines / 64 bytes per line (named constants in
tools/replay/laige-replay.cpp); no unbounded read before parsing (the ADR 0003 bounded-read precedent). - Scope:
laige-replayreplays logs recorded bylaige-run --headless --replay(the engine's built-in registration). A game-scenario log is replayed by the scenario binary itself: it loads the log (loadReplay), checks the replay identity against its own (world, config) (replayIdentityDiff— a mismatch is a rejected replay, never a silent divergence), and re-runs the log's frame count through the same tick looprunReplaydrives (M1-SAMPLE-01'shello --logwires this; M1-DET-04's detcheck--run-a/--run-bcompares two scenarios' hash streams).
struct ReplayDiffResult {
bool identical; // every aligned tick matched (and equal
// frame counts)
std::uint64_t firstDivergentTick; // the first divergent completed
// tick (0 = the initial state; in the
// identical case: 0, a documented
// placeholder)
bool lengthDivergence; // the frame counts differ (no shared-
// tick divergence before the shorter log
// ends; firstDivergentTick = the first
// tick only one replay has)
bool fullStateDivergent; // the FULL state hashes (PRNG substream
// state included) differed on some
// aligned tick — the honest PRNG report
std::uint64_t framesA, framesB;
std::uint32_t differingItems; // the EXACT difference count at the
// first divergence (unbounded)
std::vector<World::StateDiffItem> entries; // the BOUNDED report
};
Result<ReplayDiffResult, ErrorCode> diffReplays(const ReplayLog& logA,
const ReplayLog& logB, World& worldA, World& worldB,
const EngineConfig& configA, const EngineConfig& configB,
std::uint32_t maxEntries = kReplayDiffDefaultEntries) noexcept;Replay both logs in lock step on two caller-provided worlds and report where they first differ (FR-11.3: "diff two replays by frame/state"). The steps, in order:
- Identity check, per log — each log against THAT log's own
(world, config)(replayIdentityDiff, the M1-DET-03 check). Both logs are checked before the error is returned, so one report names every failing log (each with areplay/diff_identity_mismatchwarn listing the differing fields and both sides' values). A mismatch is a rejected diff —InvalidArgument, never a silent divergence (CORE-008, ADR 0002). - Determinism check, per config — a config with determinism
disabled is a rejected diff (
replay/diff_determinism_disabledwarn,InvalidArgument) — the M1-DET-03 precedent. - Lock-step tick walk —
scheduleSystemsonce per world, then onebeginFrame(); runSystems();per completed tick on BOTH worlds, aligned overmin(framesA, framesB)ticks (the recorded frame bytes are opaque in M1 — no input system consumes them yet, M3-INPUT-03). - Tick alignment on the component-state hash — each aligned tick
compares
worldA.componentStateHash(tick)againstworldB.componentStateHash(tick): the canonical state-hash stream (entity.md) minus the per-system PRNG substream state. The substream state is a pure function of the (master seed, draws) — the seed is a replay-identity field the diff legitimately varies (two different-seed replays of the same inputs diverge in substream state from tick 0 while their components may not differ for hundreds of ticks), so aligning on the fullstateHashwould report tick 0 and hide the first real component divergence. The fullstateHashis still compared at every aligned tick and feeds thefullStateDivergentflag (never silent — CORE-008). - Bounded state diff at the first divergence —
worldA .stateDiff(worldB, maxEntries, fn)(entity.md) reports the exact difference count and the firstmaxEntriesdifferences in canonical order (slots ascending; an entity-presence item —componentId == 0— before the slot's component items; components ascending in the union of the two worlds' sets). Both worlds sit at the divergent tick's state (the walk stopped there), so the itembytesA/bytesBviews are valid until the next mutation of the involved entities (non-owning, PERF-005).
maxEntries: 0 = count only (entries empty, differingItems
exact); 1 .. kMaxReplayDiffEntries (64) = the report bound; a value
above 64 is clamped to it (a report beyond 64 items is a full
dump — the bounded-report scope, CORE-003). The default is
kReplayDiffDefaultEntries (16).
Result field table:
| field | identical | divergent | length divergence |
|---|---|---|---|
identical |
true |
false |
false |
firstDivergentTick |
0 (placeholder) |
the first divergent completed tick | the first tick only one replay has (alignedTicks + 1) |
lengthDivergence |
false |
false |
true |
fullStateDivergent |
false |
as observed (often true — different seeds) |
as observed over the shared ticks |
differingItems / entries |
0 / empty |
exact / bounded (at the divergence tick) | 0 / empty (no shared state at that tick) |
Performance (PERF-002/003): per tick, exactly two normal engine
ticks plus two state hashes and (on a matching tick) no diff work.
The state-diff pass runs ONCE, only at the first divergence:
O(capacity + Σ component bytes) with the report bounded by
maxEntries items; no allocation beyond the bounded entries
vector. Cold path (development tooling), never in the run hot path.
Misuse warnings:
- Fresh, identically-registered worlds —
worldA/worldBmust carry the SAME component registrations (in the same order) as each log's recording: the identity check enforces the schema hash, andstateDiff's registry match is a debug assert (CPP-012). The worlds must start at the initial state the logs were recorded from — the diff drives ticks from scratch, exactly asrunReplay. - Scenario logs need the scenario binary — a log recorded with
game-scenario registrations (e.g.
hello --replay) is schema-incompatible with this binary's built-in registrations, and the identity check rejects it: diff scenario logs from the scenario's own binary (the same constraint as M1-DET-03's--log), or usediffReplaysdirectly with matching worlds. - The entries' byte views die with the worlds — read
result.entries[*].bytesA/bytesBbefore the worlds are destroyed or the involved entities mutated (non-owning views, PERF-005).
laige-replay --diff LOG_A LOG_B --config CONFIG.json [--entries N]
-
The per-log config is
CONFIG.jsonwith THAT log's header seed (config.seed := log.identity.seedfor each side — the seed is the identity field the diff legitimately varies; the other fields are identity-checked and must match the recording). -
stdout is the report only — stable, machine-greppable grammar (the editor replay viewer's source, M5-ED-15; CI fragments):
replay_diff result=<identical|divergent|length_divergence> frames_a=<n> frames_b=<n> first_divergent_tick=<n> length_divergence=<true|false> full_state_divergent=<true|false> differing_items=<n> reported_items=<n> item slot=<s> component=<c> presence=<both|a_only|b_only> a=<hex|-> b=<hex|->one
itemline per reported difference, in the canonical order;component 0= an entity-presence difference;a=/b=are the component bytes in lowercase hex,-where that side is absent. -
stderr is human summary + diagnostics (the identity-mismatch report, the PRNG note when
full_state_divergent=true, usage errors) — never on stdout. -
Exit codes:
0— identical;1— divergent (the first divergence tick + the bounded diff are reported) or length- divergent (the first tick only one replay has);2— usage error (--diffneeds two log paths;--entriesneeds an integer in0..64;--configrequired;--diffmutually exclusive with--log/--expect), config/log I/O error, identity mismatch, or a failed diff run. -
--entries N— the report bound (library semantics:0= count only,>64rejected at the CLI — the clamping is the library API's job).
- Disabled — one null check per tick in the engine's onTick hook; no allocation, no logging, no I/O.
- Enabled — one bounded stdio write per completed tick (the 12-byte
record; M1 frames carry no payload), one FNV-1a extension over the
record bytes, one size check. The write is to a page-cache-backed
local file (PERF-002: the recording is an explicit, bounded, opt-in
cost — the API makes it visible, it is never hidden).
createis cold (one open + one 40-byte header write);finishis cold (one 16-byte trailer write + one rename). - Bounds — total log size ≤
maxBytes(128 MiB default); a single frame ≤ 1 MiB; the reader's read is bounded bymaxBytesbefore parsing. - Traps — recording a long run at the default cap publishes the
log only if it fits; a
BudgetExhaustedmid-run stops the run (by design — a truncated log would be useless).
- Start recording after all registration, before the run — the identity is captured at call time; registering components after it makes the recorded schema hash stale (the log would be rejected by the M1-DET-03 runner's identity check).
- One recording per run — a second
startReplayRecordingon the same engine is anInvalidArgumentplus arecord_already_startedwarn. - Release builds cannot record —
NDEBUGis the contract (debug-only tooling); use a debug build for capture. - Do not hand-write frame ticks —
writeFrameexpects the strict 1, 2, 3, ... sequence; a hand-built tick violates the format. - The cap is total, not per-frame — header + all frames + trailer
must fit; size your cap against the expected tick count
(≈
40 + 12 × ticks + 16for M1 zero-length frames).
ctest -R replay_record— the step's Verify: the format round trip (record → parse → identical bytes, including the PRNG-payload case and the 1 MiB frame boundary), the full malformed-input table (every truncation cut of a valid log, bad magic/version, length overrun, tick sequence, trailer count, fileHash, trailing garbage), the recorder contract (atomic publish, no partial file on interruption, the size limit at the exact boundary, sticky failure, move semantics), the identity hashes (registration-order stability, per-field sensitivity), and the engine integration (8-tick run → 8 empty frames + matching identity; the mid-run failure stop with therecord_failed/record_abortedevents; double-start and stopped-engine failures).ctest -R replay_replay— the M1-DET-03 Verify (theStateHash.*+DetReplay.*suites intests/laige-sim/replay_replay_tests.cpp): the state hash's known- answer vector, capacity/handle/component/archetype/PRNG sensitivity, convergent-world equality (different histories, same live state, same hash), the zero-allocation proof; and the replay half — the 500-tick record → replay integration (identical hash streams, the step's headline test), a perturbed-world divergence at the exact tick, the per-field identity-mismatch rejection, the determinism-disabled rejection, and an engine round trip (record underrun_headless, replay through two fresh engines, world-level twin state comparison).ctest -R replay_diff— the M1-DET-05 Verify (theStateDiff.*+ReplayDiff.*suites intests/laige-sim/replay_diff_tests.cpp): the state-diff contract (identical worlds report nothing; component byte differences with the canonical slot/component order; entity- and component-presence items; the bounded report vs the exact count), the component-state hash's PRNG-excluded scope, and the diff driver — the tick-37 integration scenario (two different-seed replays whose first component divergence lands at tick 37 report tick 37 + the diverged component, the machine-greppablereplay-diff-37line), the tick-0 initial-state divergence, the length divergence, the identical case, the identity-mismatch rejection (both logs named, per field), the determinism-disabled rejection, and the draw-path KAT against an independentlaige::Prng.ctest -R "^replay_"— thelaige-replayrunner's CTest entries (tests/replay/, one generated check script per case): the smoke stream contract (N+1 lines, tick sequence, 16-hex hashes), the double-run determinism,--expectmatch / perturbed (divergence at the right tick) / truncated (length report) / malformed (line report) / identity mismatch (field report) / usage error / missing log, and the diff mode —replay_diff_identical(two 16-tick recordings, same fixture:result=identical, exit 0),replay_diff_length(16 vs 10 ticks:result=length_divergence,first_divergent_tick=11, exit 1),replay_diff_identity(a budget-128 config against a budget-64 log: theconfig_hash DIFFERSreport, exit 2), andreplay_diff_usage(--diffwith one log path, exit 2) — the stdout report grammar fragments asserted per case.ctest -R fuzz_replay_parse— the parser's malformed-input surface under the bounded-every-commit fuzz gate (1000 deterministic inputs; the corpus includes a valid v1 log as a mutate/truncate base; TEST-005, NFR-8.7).- The include-graph lint and the API manifest (
laige-api.json) cover the new public declarations (stateHash,componentStateHash,stateDiff,replayIdentityDiff,runReplay,diffReplays; regenerated in this change).
- api/entity.md —
World, the handle contract, andWorld::stateHash(the state-hash scope and canonical stream). - api/engine.md —
Engine, the run contract, thestartReplayRecordingwiring, thelaige-runCLI. - api/detcheck.md — the cross-configuration hash-stream comparison (M1-DET-04) built on the same hash lines.
- concepts/determinism.md — the determinism scope, the replay identity (ADR 0002), the SimMath-only rule.
- api/determinism.md — the G-R8 trait and the
SimMathBackendids. - ADR 0002 — Deterministic math strategy — replay identity: inputs + seed + backend + config.
- testing.md — the fuzz-runner and seed conventions this suite follows.