diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ad9bbd..fbad70d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -254,4 +254,5 @@ if(LAIGE_BUILD_TESTS) add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner add_subdirectory(tools/detcheck) # M0-TOOL-02: laige-detcheck add_subdirectory(tools/run) # M1-HEAD-01: laige-run (+ smoke test) + add_subdirectory(tools/replay) # M1-DET-03: laige-replay endif() diff --git a/docs/README.md b/docs/README.md index f49518f..37b3f23 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,10 @@ PRNG substreams, and the `seed`/`determinism` config keys; M1-DET-02: replay recording — the versioned replay log format (replay identity per ADR 0002), the `ReplayRecorder` (atomic temp+rename, size-bounded), `Engine::startReplayRecording`, and -`laige-run --replay`). +`laige-run --replay`; M1-DET-03: replay execution — +`World::stateHash` (the deterministic state hash), `runReplay` +(the identity-checked re-run and its per-tick hash stream), and the +`laige-replay` runner). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -29,7 +32,7 @@ still to land. - [Building Laige](getting-started/building.md) — the source of truth for the canonical build commands, build trees, options, compiler policy (NFR-8.10), sanitizer builds (NFR-8.2), and the current M0 - status. Tool commands: `laige-run`, `laige-fuzz`, `laige-bench`, + status. Tool commands: `laige-run`, `laige-replay`, `laige-fuzz`, `laige-bench`, `laige-detcheck`, the `laige-api` manifest target, and the include-graph lint. @@ -104,12 +107,14 @@ still to land. `detail::IsDeterminismSafe`, and `LAIGE_DETERMINISM_SAFE(Type, MemberTypes...)` (M1-DET-01; `laige-sim`). -- [Replay recording](api/replay.md) — the versioned replay log - format (replay identity: seed, tick rate, component schema hash, - math backend id, config hash — ADR 0002), the `ReplayRecorder` - (atomic temp+rename, size-bounded), the `parseReplay`/`loadReplay` - readers, the identity hashes, and the engine/CLI wiring - (M1-DET-02; `laige-sim`). +- [Replay recording and execution](api/replay.md) — the versioned + replay log format (replay identity: seed, tick rate, component + schema hash, math backend id, config hash — ADR 0002), the + `ReplayRecorder` (atomic temp+rename, size-bounded), the + `parseReplay`/`loadReplay` readers, the identity hashes, the + engine/CLI wiring (M1-DET-02), and the execution half: + `World::stateHash`, `replayIdentityDiff`/`runReplay`, and the + `laige-replay` runner (M1-DET-03; `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index 4e83964..ea7a510 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -11,9 +11,12 @@ in its header comment, which this page mirrors); CTest suite: (e.g. Debug+ASan vs Release, or two compiler builds) and asserts that the per-tick state hashes are identical — bit-identity of the deterministic state across configurations (NFR-8.3, FR-1.4). The engine state-hash API -arrives with **M1-DET-03** (`world.state_hash`); this tool therefore works -against the **hash-file output contract** defined below and compares two -such streams. +landed with **M1-DET-03** (`World::stateHash`, +[api/entity.md](entity.md)); this tool works against the **hash-file +output contract** defined below and compares two such streams. The +current scenario fixture still prints its own ad-hoc hash stream; the +scenario wiring that switches it to the `World::stateHash` stream lands +with M1-SAMPLE-01 (the tool's line-by-line comparison is unchanged). ## Scenario contract @@ -156,9 +159,10 @@ body index and a nudge in [-4, 3] applied to x. Per-tick hash: FNV-1a 64 and compiler by the language standard; no float anywhere in the workload. **Hash scope (M0):** tick counter + seed + body words. The Prng position is a pure function of (seed, nudge history) in this workload; the -definitive scope — including PRNG state and the exact hash function — is -defined by M1-DET-03's `world.state_hash`, which replaces the ad-hoc FNV -computation of scenarios (the tool's line-by-line comparison is unchanged). +definitive scope — including PRNG state and the exact hash function — +is M1-DET-03's `World::stateHash` +([api/entity.md](entity.md)), which the scenario wiring switches to +with M1-SAMPLE-01 (the tool's line-by-line comparison is unchanged). ## Performance and bounds diff --git a/docs/api/engine.md b/docs/api/engine.md index d95351d..db731f9 100644 --- a/docs/api/engine.md +++ b/docs/api/engine.md @@ -216,7 +216,10 @@ Cross-build/platform determinism is **M1-DET-04** (the detcheck matrix); replay **recording** landed with **M1-DET-02** ([api/replay.md](replay.md) — `Engine::startReplayRecording`, the versioned log format, and `laige-run --replay`); replay **execution** -(the `laige-replay` runner and `world.state_hash`) is **M1-DET-03**. +landed with **M1-DET-03** — `World::stateHash` (the deterministic +state hash, [api/entity.md](entity.md)) and `runReplay` / the +`laige-replay` runner ([api/replay.md](replay.md), "The execution +half"). ## Replay recording (M1-DET-02) diff --git a/docs/api/entity.md b/docs/api/entity.md index 78e9fa1..57f0ca4 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -247,6 +247,51 @@ stale handle assert in debug and degrade in release. bit-identical `Entity` handle sequences on every platform, so handles are replay state from M1 on. +## The deterministic state hash (`World::stateHash`, M1-DET-03) + +```cpp +std::uint64_t World::stateHash(std::uint64_t tick) const noexcept; +``` + +The 64-bit FNV-1a hash of the world's **authoritative sim state** at +`tick` completed ticks — the per-tick value the replay runner +(`runReplay`, [api/replay.md](replay.md)) and the detcheck scenario +contract ([api/detcheck.md](detcheck.md)) compare run against run. + +- **Pure function of the live state** — never of the operation + history that produced it. Two worlds that converge on the same live + state (same live handles, same component bytes, same PRNG substream + states) hash identically, whatever their create/destroy interleavings + were; dead-slot generations, the free-list order, and empty archetypes + are deliberately out (see the header's scope list, entity.h). +- **Canonical stream (FNV-1a 64, the house word-stream convention — + big-endian per u64 word; basis `0xcbf29ce484222325`, prime + `0x100000001b3`, fnv.org):** `tick` → live count → per live slot + ascending `(slot, generation)` → per non-empty archetype in + lexicographic signature order `(component ids ascending, row count, + then the raw component bytes row-major in signature column order)` → + per system ascending `(system id, has-substream flag, substream + seed/state1/state2)`. Component bytes go in raw memory order (every + P0 target is little-endian — PRD §6). +- **Determinism scope (ARCH-010):** same build, platform, architecture, + and compiler — the hash is pure integers over a canonical byte + order; no wall clock, no addresses, no unordered containers. + Cross-build identity is M1-DET-04's detcheck matrix. +- **Performance (PERF-002/003, DOC-004):** `O(capacity + live component + bytes + kMaxArchetypes²)` (the per-set ordering is an insertion sort + over ≤ 256 non-empty archetypes); **no allocation** (fixed stack + state), no logging, no side effects, `const`. COLD path: the replay + runner and detcheck scenarios call it once per tick; the engine's + per-tick hot path never does. +- **Not a cryptographic hash** — a state-difference detector for + determinism verification (FR-1.4/FR-11.3), not a security primitive + (DEP-002). + +Verified by `ctest -R replay_replay` (the `StateHash.*` suites: the +known-answer vector, capacity independence, tick/handle sensitivity, +component and archetype sensitivity, convergence equality, PRNG +sensitivity, and the zero-allocation proof). + ## Usage (performant pattern) ```cpp @@ -311,3 +356,7 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } `guardrailStats()`, the `ecs/entity_budget_{25,50,100}` and `ecs/churn_per_frame` warns (the "Guardrails" section above); suite `ctest -R ecs_guardrails`. +- **M1-DET-03 (done):** `World::stateHash` — the deterministic state + hash ("The deterministic state hash" section above); the replay + execution half and the `laige-replay` runner live in + [api/replay.md](replay.md); suite `ctest -R replay_replay`. diff --git a/docs/api/iteration_order.md b/docs/api/iteration_order.md index e27f292..1e87c27 100644 --- a/docs/api/iteration_order.md +++ b/docs/api/iteration_order.md @@ -174,8 +174,10 @@ floats, addresses, wall clocks, or platform intrinsics enter it. The scope the contract promises is therefore the widest ARCH-010 allows: **the same state visited identically on every supported platform, architecture, and compiler** (same build). The state *hash* that -M1-DET-03 computes over the visit order inherits this scope once the -component-value encoding is pinned there. +M1-DET-03's `World::stateHash` computes inherits this scope: the +canonical stream feeds the component values in this visit order +(entity rows ascending, columns in the archetype signature order — +[api/entity.md](entity.md)). ## Performance (DOC-004) diff --git a/docs/api/prng.md b/docs/api/prng.md index 39e7a43..7ef4ac8 100644 --- a/docs/api/prng.md +++ b/docs/api/prng.md @@ -122,6 +122,8 @@ every statistical sense, but they are *not* cryptographically independent | `std::uint32_t next_range(std::uint32_t min, std::uint32_t max)` | Uniform in `[min, max)`; `max - min in [1, 2^32 - 1]`. `min >= max`: debug assert, documented UB in release (span underflow). Expected < 2 draws; the rejection probability per draw is `< 1/2` (it is `(2^64 mod n) / 2^64`, which is 0 for power-of-two spans). | | `float next_float01()` | Uniform in `[0, 1)`, 24-bit resolution. Never 1.0; 0.0 with probability 2^-24. | | `std::uint64_t seed() const` | The construction seed (save/replay identity). | +| `std::uint64_t statePart1() const` | The first state word (the xorshift128+ `s0_`; M1-DET-03: feeds `World::stateHash`). Read-only, O(1), no side effects. | +| `std::uint64_t statePart2() const` | The second state word (`s1_`). Same contract as `statePart1()`. | | `Prng substream(std::uint32_t id) const` | `deriveSubstream(seed(), id)`; a fresh stream position, independent of this stream's current state. | | `static Prng deriveSubstream(std::uint64_t seed, std::uint32_t id)` | The documented derivation: `Prng(seed + id * K)`. Composes (see Algorithm). | | `static void seedState(std::uint64_t seed, std::uint64_t& part1, std::uint64_t& part2)` | Seed-to-state mapping; exposed for determinism verification and M1 save/replay (a saved stream is `(seed, part1, part2)`). | @@ -174,9 +176,10 @@ system as `SystemContext::rng` (non-null in deterministic mode, `nullptr` when disabled; see [api/system_registry.md](system_registry.md)). The stream is advanced in place during the system's draws, so the registry's stream state is -the replay state (its inclusion in the state hash lands with -M1-DET-03). Scope and guarantees: -[concepts/determinism.md](../concepts/determinism.md). +the replay state: M1-DET-03's `World::stateHash` feeds each system's +substream `(seed, statePart1, statePart2)` into the hash (see +[api/replay.md](replay.md), the state-hash scope). Scope and +guarantees: [concepts/determinism.md](../concepts/determinism.md). ## Misuse warnings diff --git a/docs/api/replay.md b/docs/api/replay.md index 1b3e7f3..8b531ff 100644 --- a/docs/api/replay.md +++ b/docs/api/replay.md @@ -1,14 +1,18 @@ -# Replay recording (`ReplayRecorder`, M1-DET-02) - -The M1 replay **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 engine records one frame per completed -tick (`Engine::startReplayRecording`) and `laige-run --replay ` -wires the flag that M1-HEAD-01 stubbed. Replay **execution** (the -`laige-replay` runner, `world.state_hash`) is M1-DET-03; this step -lands the recording half. +# Replay recording and execution (`ReplayRecorder`, `runReplay`, M1-DET-02/03) + +The M1 replay system in both 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 — +and 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. The engine records one frame per completed tick +(`Engine::startReplayRecording`), `laige-run --replay ` wires the +flag that M1-HEAD-01 stubbed, and `laige-replay --log --config + [--expect ]` closes the loop. Public header: `src/laige-sim/include/laige/sim/replay.h` (the full contract: format layout, identity hashes, the error tables, the @@ -234,6 +238,115 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG] `status=` summary line carries the error name) with no partial log at `LOG`. +## The state hash (`World::stateHash`, M1-DET-03) + +`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](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). + +## The identity check (`replayIdentityDiff`, M1-DET-03) + +```cpp +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. + +## The execution half (`runReplay`, M1-DET-03) + +```cpp +struct ReplayRunResult { std::vector tickHashes; }; +Result runReplay(const ReplayLog&, World&, + const EngineConfig&) noexcept; +``` + +One deterministic replay of a loaded log against a world: + +1. **Identity check** — `replayIdentityDiff`; any differing field is + `ErrorCode::InvalidArgument` (the `replay/identity_mismatch` warn + names the fields and both sides' values). +2. **Determinism check** — the world's deterministic mode must be + enabled (it is by default); a disabled world fails with + `ErrorCode::InvalidArgument` + the `replay/determinism_disabled` + warn — replaying a non-deterministic sim would produce + meaningless hashes. +3. **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's + `Status` (already logged by the world). +4. **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's `Status` + and 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 0,1,2,...,N — one line per completed tick, + plus the tick-0 initial line (N+1 lines total) + = 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). + +## The `laige-replay` runner (M1-DET-03) + +``` +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 ` ` 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 exits `1`. A different stream length is + reported as `baseline stream length mismatch` + `first + missing/extra at tick N`, also `1`. +- **Baseline grammar is strict** — exactly ` <16 lowercase hex>`, + tick strictly `0, 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-replay` replays logs recorded by `laige-run + --headless --replay` (the engine's built-in registration). A + game-scenario log is replayed by the scenario binary itself through + the same `runReplay` library (the M1-SAMPLE-01 scenario wires it; + M1-DET-04's detcheck `--run-a/--run-b` compares two scenarios' hash + streams). + ## Performance (PERF-002/003, LOG-003) - **Disabled** — one null check per tick in the engine's onTick hook; @@ -283,17 +396,41 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG] empty frames + matching identity; the mid-run failure stop with the `record_failed`/`record_aborted` events; double-start and stopped-engine failures). +- `ctest -R replay_replay` — the M1-DET-03 Verify (the + `StateHash.*` + `DetReplay.*` suites in + `tests/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 + under `run_headless`, replay through two fresh engines, world-level + twin state comparison). +- `ctest -R "^replay_"` — the `laige-replay` runner'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, `--expect` match / perturbed (divergence at + the right tick) / truncated (length report) / malformed (line + report) / identity mismatch (field report) / usage error / missing + log — exit codes 0/1/2 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 header (regenerated in this change). + cover the new public declarations (`stateHash`, `replayIdentityDiff`, + `runReplay`; regenerated in this change). ## Related +- [api/entity.md](entity.md) — `World`, the handle contract, and + `World::stateHash` (the state-hash scope and canonical stream). - [api/engine.md](engine.md) — `Engine`, the run contract, the `startReplayRecording` wiring, the `laige-run` CLI. +- [api/detcheck.md](detcheck.md) — the cross-configuration hash-stream + comparison (M1-DET-04) built on the same hash lines. - [concepts/determinism.md](../concepts/determinism.md) — the determinism scope, the replay identity (ADR 0002), the SimMath-only rule. diff --git a/docs/concepts/determinism.md b/docs/concepts/determinism.md index 12a5e5a..6d50b04 100644 --- a/docs/concepts/determinism.md +++ b/docs/concepts/determinism.md @@ -42,15 +42,17 @@ default) guarantees: - `fp32_pinned` cross-ISA determinism is not promised; any desyncing CI pair is declared unsupported for that backend (ADR 0002 review conditions). -- Replay *execution* (the `laige-replay` runner feeding a recorded - input stream back through the sim, `world.state_hash`) is - M1-DET-03. Replay *recording* has landed with M1-DET-02: the - versioned log format, the `ReplayRecorder`, and the +- Replay *execution* has landed with M1-DET-03: `World::stateHash` + (the deterministic state hash — [api/entity.md](../api/entity.md)), + `runReplay` (the identity-checked, tick-by-tick re-run and its + per-tick hash stream), and the `laige-replay` runner + ([api/replay.md](../api/replay.md)). Replay *recording* landed with + M1-DET-02: the versioned log format, the `ReplayRecorder`, and the `Engine::startReplayRecording` / `laige-run --replay` wiring ([api/replay.md](../api/replay.md)). -- PRNG *state introspection* (reading a substream's state words) is - M1-DET-03 (its state words join `world.state_hash`); - `laige::Prng` deliberately has no state getters. +- PRNG *state introspection* has landed with M1-DET-03: + `Prng::statePart1()/statePart2()` (read-only; the substream state + words join `World::stateHash` — [api/prng.md](../api/prng.md)). ## Why only SimMath ops (G-R8) diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index 243a92e..f78957b 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -37,6 +37,7 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | Fuzz (long, nightly form) | `./build/bin/laige-fuzz --runs=1000000` | | Benchmarks | `./build/bin/laige-bench --suite=` | | Determinism check | `./build/bin/laige-detcheck --scenario=` | +| Replay runner | `./build/bin/laige-replay --log LOG --config CONFIG.json [--expect BASELINE]` | | API manifest | `cmake --build build --target laige-api` | | Include-graph lint + dependency count | `python3 tools/laige-include-lint` | | Determinism source scan (sim module) | `python3 tools/laige-determinism-lint` | @@ -52,8 +53,12 @@ Notes: M0-TEST-01 documents the CI lane semantics — bounded `--runs=1000` in every P0 job's `ctest`, the nightly long-run form above — and the seed-handling rules), `laige-bench` (M0-CORE-08), `laige-detcheck` - (M0-TOOL-02), and target `laige-api` (M0-TOOL-01). Their command forms - were fixed here when they were reserved, so no step can drift them. + (M0-TOOL-02), `laige-replay` (M1-DET-03: prints the replayed + per-tick state-hash stream on stdout; `--expect BASELINE` compares + and exits 1 at the first diverging tick — contract in + [docs/api/replay.md](../api/replay.md)), and target `laige-api` + (M0-TOOL-01). Their command forms were fixed here when they were + reserved, so no step can drift them. Fuzz and randomized-test seeds: fixed default `0x1F055EED`, overridable (`laige-fuzz --seed=…`; tests via the `LAIGE_TEST_SEED` environment variable) — see [docs/testing.md](../testing.md). diff --git a/laige-api.json b/laige-api.json index 2ed3280..bffcf91 100644 --- a/laige-api.json +++ b/laige-api.json @@ -307,11 +307,13 @@ {"name": "laige::Prng::next_range", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 152, "signature": "std::uint32_t next_range(std::uint32_t min, std::uint32_t max)", "summary": "Uniform value in [min, max) (max - min must be in [1, 2^32 - 1]). Unbiased (Lemire reduction with rejection); expected < 2 draws. `min >= max` is a debug assert (documented UB in release).", "budget": null, "experimental": false}, {"name": "laige::Prng::next_float01", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 156, "signature": "float next_float01()", "summary": "Uniform value in [0, 1) at 24-bit resolution: exactly k * 2^-24 for an integer k in [0, 2^24). Never 1.0; 0.0 with probability 2^-24.", "budget": null, "experimental": false}, {"name": "laige::Prng::seed", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 160, "signature": "std::uint64_t seed() const", "summary": "The seed this stream was constructed from (save/replay identity, PRD §10.3; M1-DET-03 hashes this together with the substream id).", "budget": null, "experimental": false}, - {"name": "laige::Prng::substream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 164, "signature": "Prng substream(std::uint32_t id) const", "summary": "A substream of this stream's seed: deriveSubstream(seed(), id). Independent stream position; id 0 == the master stream.", "budget": null, "experimental": false}, - {"name": "laige::Prng::deriveSubstream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 169, "signature": "static Prng deriveSubstream(std::uint64_t seed, std::uint32_t id)", "summary": "Substream derivation (documented hash, see the preamble): Prng(seed + id * kSplitmix64Increment). Composes: deriveSubstream(deriveSubstream(seed, i), j) == deriveSubstream(seed, i+j).", "budget": null, "experimental": false}, - {"name": "laige::Prng::seedState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 174, "signature": "static void seedState(std::uint64_t seed, std::uint64_t& part1, std::uint64_t& part2)", "summary": "Seed-to-state mapping (documented in the preamble). Exposed for determinism verification and M1 save/replay: a saved stream is (seed, part1, part2) and restores by seedState + stepState calls.", "budget": null, "experimental": false}, - {"name": "laige::Prng::stepState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 180, "signature": "static void stepState(std::uint64_t& part1, std::uint64_t& part2)", "summary": "One transition step on a raw state (see the preamble). Exposed for determinism verification (the PrngPeriod suite reconstructs the state map over GF(2) from this) and M1 save/replay.", "budget": null, "experimental": false}, - {"name": "laige::splitMix64", "kind": "function", "header": "src/laige-core/include/laige/prng.h", "line": 192, "signature": "inline std::uint64_t splitMix64(std::uint64_t z)", "summary": "splitmix64 (Stafford 2018) — seeding-only; a bijection of u64.", "budget": null, "experimental": false}, + {"name": "laige::Prng::statePart1", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 166, "signature": "std::uint64_t statePart1() const", "summary": "The stream's state word 1 (the save/replay identity's part1; PRD §10.3 — a saved stream is (seed, part1, part2)). Read-only; O(1), no side effects. M1-DET-03: World::stateHash folds these into the deterministic state hash with the substream id.", "budget": null, "experimental": false}, + {"name": "laige::Prng::statePart2", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 170, "signature": "std::uint64_t statePart2() const", "summary": "The stream's state word 2 (the save/replay identity's part2). Read-only; O(1), no side effects. See statePart1().", "budget": null, "experimental": false}, + {"name": "laige::Prng::substream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 174, "signature": "Prng substream(std::uint32_t id) const", "summary": "A substream of this stream's seed: deriveSubstream(seed(), id). Independent stream position; id 0 == the master stream.", "budget": null, "experimental": false}, + {"name": "laige::Prng::deriveSubstream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 179, "signature": "static Prng deriveSubstream(std::uint64_t seed, std::uint32_t id)", "summary": "Substream derivation (documented hash, see the preamble): Prng(seed + id * kSplitmix64Increment). Composes: deriveSubstream(deriveSubstream(seed, i), j) == deriveSubstream(seed, i+j).", "budget": null, "experimental": false}, + {"name": "laige::Prng::seedState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 184, "signature": "static void seedState(std::uint64_t seed, std::uint64_t& part1, std::uint64_t& part2)", "summary": "Seed-to-state mapping (documented in the preamble). Exposed for determinism verification and M1 save/replay: a saved stream is (seed, part1, part2) and restores by seedState + stepState calls.", "budget": null, "experimental": false}, + {"name": "laige::Prng::stepState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 190, "signature": "static void stepState(std::uint64_t& part1, std::uint64_t& part2)", "summary": "One transition step on a raw state (see the preamble). Exposed for determinism verification (the PrngPeriod suite reconstructs the state map over GF(2) from this) and M1 save/replay.", "budget": null, "experimental": false}, + {"name": "laige::splitMix64", "kind": "function", "header": "src/laige-core/include/laige/prng.h", "line": 202, "signature": "inline std::uint64_t splitMix64(std::uint64_t z)", "summary": "splitmix64 (Stafford 2018) — seeding-only; a bijection of u64.", "budget": null, "experimental": false}, {"name": "laige::Result", "kind": "class", "header": "src/laige-core/include/laige/result.h", "line": 43, "signature": "template class Result", "summary": "A result carrying either a success value of type T or a failure of type E (default: laige::ErrorCode).", "budget": null, "experimental": false}, {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 51, "signature": "Result() = delete", "summary": "An empty Result has no defined state and is therefore unrepresentable (API-008).", "budget": null, "experimental": false}, {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 53, "signature": "Result(const Result&) = default", "summary": null, "budget": null, "experimental": false}, @@ -459,68 +461,69 @@ {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 609, "signature": "Engine(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 610, "signature": "Engine& operator=(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Engine::~Engine", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 614, "signature": "~Engine() noexcept", "summary": "The destructor shuts down (CONC-006: owned work is released even when the caller forgets shutdown()).", "budget": null, "experimental": false}, - {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 188, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, - {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 189, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 190, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 192, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 193, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 201, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 204, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 218, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, - {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 219, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 220, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 221, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 222, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 223, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 224, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 235, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 252, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 253, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 254, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 255, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 256, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 257, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 258, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 259, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 331, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, - {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 335, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 340, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 346, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, - {"name": "laige::World::Options::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 352, "signature": "std::uint64_t seed{0}", "summary": "The master simulation seed (M1-DET-01; PRD §10.3: the seed is part of the replay identity). Every system's PRNG substream is derived from (seed, system id) — the Prng::deriveSubstream contract (laige/prng.h). Default 0 — a valid master seed (the Prng's state is nonzero for every 64-bit seed, prng.h).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::deterministic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 360, "signature": "bool deterministic{true}", "summary": "Deterministic mode on/off (M1-DET-01; S-7: deterministic by default). When true, registerSystem derives each system's PRNG substream and SystemContext::rng names it; when false, the streams are not created and SystemContext::rng is nullptr (a system that draws must handle nullptr as \"no random source\"). See determinism.h \"Determinism mode semantics\" for the full M1 scope.", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 366, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 371, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, - {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 381, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, - {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 388, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, - {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 391, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 394, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, - {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 398, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, - {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 402, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 417, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, - {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 423, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 444, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 449, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 455, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 466, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, - {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 476, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, - {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 493, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, - {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 501, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 507, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 512, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 547, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, - {"name": "laige::World::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 588, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, - {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 593, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 600, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, - {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 622, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 641, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, - {"name": "laige::World::systemTimingStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 654, "signature": "[[nodiscard]] Result systemTimingStats(SystemId id) const noexcept", "summary": "The per-system timing snapshot (SystemTimingStats: the run count, the last measured ms, and the warn/error counts). O(1), no allocation, no side effects (a pure query, like system()). `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::systemTimingWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 663, "signature": "[[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept", "summary": "The per-system rolling window (the M0-CORE-08 Histogram of the last kSystemTimingWindowSamples measured run times, ms). Cold path: the M1-PROF-02 frame graph's budgetCheck consumes it (its stats() is O(n log n)). nullptr for an invalid id or a moved-from world. The window is owned by the world (one owner thread — CONC-001): never keep the reference past the world.", "budget": null, "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 676, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 680, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 681, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 682, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 683, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 688, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 274, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, + {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 275, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 276, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 278, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 279, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 287, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 290, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 304, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, + {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 305, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 306, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 307, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 308, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 309, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 310, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 321, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 338, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 339, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 340, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 341, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 342, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 343, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 344, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 345, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 417, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, + {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 421, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 426, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 432, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, + {"name": "laige::World::Options::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 438, "signature": "std::uint64_t seed{0}", "summary": "The master simulation seed (M1-DET-01; PRD §10.3: the seed is part of the replay identity). Every system's PRNG substream is derived from (seed, system id) — the Prng::deriveSubstream contract (laige/prng.h). Default 0 — a valid master seed (the Prng's state is nonzero for every 64-bit seed, prng.h).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::deterministic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 446, "signature": "bool deterministic{true}", "summary": "Deterministic mode on/off (M1-DET-01; S-7: deterministic by default). When true, registerSystem derives each system's PRNG substream and SystemContext::rng names it; when false, the streams are not created and SystemContext::rng is nullptr (a system that draws must handle nullptr as \"no random source\"). See determinism.h \"Determinism mode semantics\" for the full M1 scope.", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 452, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 457, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, + {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 467, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, + {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 474, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, + {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 477, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 480, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, + {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 484, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, + {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 488, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 503, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, + {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 509, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 530, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 535, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 541, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 552, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, + {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 562, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, + {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 579, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, + {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 587, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 593, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 598, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 633, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, + {"name": "laige::World::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 674, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, + {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 679, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 686, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, + {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 708, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 727, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 740, "signature": "[[nodiscard]] Result systemTimingStats(SystemId id) const noexcept", "summary": "The per-system timing snapshot (SystemTimingStats: the run count, the last measured ms, and the warn/error counts). O(1), no allocation, no side effects (a pure query, like system()). `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 749, "signature": "[[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept", "summary": "The per-system rolling window (the M0-CORE-08 Histogram of the last kSystemTimingWindowSamples measured run times, ms). Cold path: the M1-PROF-02 frame graph's budgetCheck consumes it (its stats() is O(n log n)). nullptr for an invalid id or a moved-from world. The window is owned by the world (one owner thread — CONC-001): never keep the reference past the world.", "budget": null, "experimental": false}, + {"name": "laige::World::stateHash", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 769, "signature": "[[nodiscard]] std::uint64_t stateHash(std::uint64_t tick) const noexcept", "summary": "The deterministic 64-bit hash of the world's live sim state at `tick` completed ticks (the replay hash line, M1-DET-03): the tick, the live entity handles, the archetype assignment, every live component's bytes in the canonical order, and every system's PRNG substream state (preamble \"Canonical encoding\"). A pure function of the state — the free list, dead-slot generations, empty archetypes, presentation, timing, and guardrail state are EXCLUDED (preamble \"SCOPE\"). `tick` is the caller's completed-tick count (0 before the first tick — the initial state's hash). Cold path: O(capacity + live component bytes + kMaxArchetypes²); no allocation, no side effects, no logging. allocation.", "budget": "O(capacity + live component bytes + kMaxArchetypes²); no", "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 782, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 786, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 787, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 788, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 789, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 794, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 253, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 256, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 258, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, @@ -580,45 +583,56 @@ {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kReplayMagic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 242, "signature": "inline constexpr std::uint8_t kReplayMagic[4] = {'L', 'G', 'R', 'P'}", "summary": "The log's magic (the first 4 bytes: \"LGRP\" — Laige GRePlay).", "budget": null, "experimental": false}, - {"name": "laige::kReplayFormatVersion", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 246, "signature": "inline constexpr std::uint16_t kReplayFormatVersion = 1", "summary": "The supported format version (ARCH-007: readers accept 1, reject everything else explicitly).", "budget": null, "experimental": false}, - {"name": "laige::kReplayHeaderSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 249, "signature": "inline constexpr std::size_t kReplayHeaderSize = 40", "summary": "The fixed header size in bytes (see the header layout above).", "budget": null, "experimental": false}, - {"name": "laige::kReplayFrameRecordOverhead", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 252, "signature": "inline constexpr std::size_t kReplayFrameRecordOverhead = 12", "summary": "The per-frame fixed record size in bytes (tick u64 + length u32).", "budget": null, "experimental": false}, - {"name": "laige::kReplayTrailerSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 255, "signature": "inline constexpr std::size_t kReplayTrailerSize = 16", "summary": "The fixed trailer size in bytes (frameCount u64 + fileHash u64).", "budget": null, "experimental": false}, - {"name": "laige::kMinReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 259, "signature": "inline constexpr std::uint64_t kMinReplaySizeLimit = 56", "summary": "The smallest size limit that can ever hold a complete log (header + trailer, zero frames).", "budget": null, "experimental": false}, - {"name": "laige::kMaxReplayFrameBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 264, "signature": "inline constexpr std::uint32_t kMaxReplayFrameBytes = 1u << 20", "summary": "The maximum frame blob in bytes (1 MiB): the u32 length field's documented domain cap for M1 opaque frames (M3-INPUT-03 defines the payload shape; the cap stands until a format version raises it).", "budget": null, "experimental": false}, - {"name": "laige::kDefaultReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 269, "signature": "inline constexpr std::uint64_t kDefaultReplaySizeLimit = 128ull << 20", "summary": "The default total log size limit (128 MiB = header + frames + trailer): about 11.6M zero-length frames, about 5.2 hours of 60 Hz simulation. 0 passed to create() / loadReplay means this.", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 278, "signature": "struct ReplayIdentity", "summary": "The replay identity: the header's five identity fields. A plain value (PERF-005); compared field-by-field at replay time (M1-DET-03) — a log is bit-exact only under its own identity.", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 280, "signature": "std::uint64_t seed{}", "summary": "The master simulation seed (config.seed).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 282, "signature": "std::uint32_t tickRateHz{}", "summary": "The simulation tick rate in hertz (config.tickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 285, "signature": "std::uint64_t componentSchemaHash{}", "summary": "FNV-1a 64 over the component registry (see the header preamble \"The replay identity\").", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 288, "signature": "std::uint32_t mathBackendId{}", "summary": "The laige::SimMathBackend value (0 = FixedPoint16_16, 1 = FloatPinned32 — ADR 0002's backend ids).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 290, "signature": "std::uint64_t configHash{}", "summary": "FNV-1a 64 over the provisional EngineConfig field encoding.", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 296, "signature": "struct ReplayFrame", "summary": "One recorded input frame: the completed tick number (the strict 1, 2, 3, ... sequence) plus the opaque byte blob (M1: zero bytes — no input system exists yet; M3-INPUT-03 defines the payload shape).", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame::tick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 298, "signature": "std::uint64_t tick{}", "summary": "The completed tick this frame belongs to (1-based, in order).", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame::data", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 300, "signature": "std::vector data", "summary": "The frame's opaque input bytes (empty in M1).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 307, "signature": "struct ReplayLog", "summary": "A parsed replay log (the loadReplay / parseReplay result): the identity plus every frame in tick order. Cold-path value: the frame storage is owned (one allocation per frame blob; M1 blobs are empty, so M1 logs cost one vector each).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog::identity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 309, "signature": "ReplayIdentity identity{}", "summary": "The log's replay identity (the header).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 312, "signature": "std::vector frames", "summary": "Every recorded frame, in tick order (empty when the log has no frames — legal: a zero-tick run).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder", "kind": "class", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 324, "signature": "class ReplayRecorder", "summary": "The replay log writer: create() -> writeFrame() per completed tick -> finish() (see the header preamble \"Recorder contract\" for the full error table). Move-only; the engine owns one per run (opt-in, debug builds only). The recorder logs nothing — the engine emits the structured replay/* events (LOG-001/002).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 332, "signature": "[[nodiscard]] static Result create(const ReplayIdentity& identity, std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Create the recorder for `path` (the FINAL path — the temp file `path + \".tmp\"` is the only thing created now): validates the bounds, opens the temp file, and writes the header carrying `identity`. Error table in the header preamble; @budget one file open + one 40-byte write (cold path); allocates the stdio buffer (one setup allocation, owned by the FILE).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 336, "signature": "ReplayRecorder(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 337, "signature": "ReplayRecorder& operator=(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 341, "signature": "ReplayRecorder(ReplayRecorder&& other) noexcept", "summary": "Move transfers the open file; the source becomes finished (a finished recorder does nothing — the moved-out GameLoop precedent).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 342, "signature": "ReplayRecorder& operator=(ReplayRecorder&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::~ReplayRecorder", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 345, "signature": "~ReplayRecorder() noexcept", "summary": "An unfinished recorder removes its temp file (the final path is never touched; the failure was already reported through a Status).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::writeFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 351, "signature": "[[nodiscard]] Status writeFrame(std::uint64_t tick, const std::uint8_t* data, std::size_t len) noexcept", "summary": "Record one input frame for completed tick `tick` carrying `len` bytes at `data` (len == 0: data may be nullptr). Error table in the header preamble; @budget one 12-byte (+ len) stdio write, no allocation (the stdio buffer holds the data until it flushes).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::finish", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 359, "signature": "[[nodiscard]] Status finish() noexcept", "summary": "Finalize: flush, write the trailer, close, and atomically rename the temp file onto the final path. Error table in the header preamble; @budget one flush + one 16-byte write + one rename (cold path).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::status", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 364, "signature": "[[nodiscard]] Status status() const noexcept", "summary": "The sticky failure Status (ok while nothing has failed; the last error otherwise — the caller reports it, the recorder does not log). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::finished", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 367, "signature": "[[nodiscard]] bool finished() const noexcept", "summary": "True once finish() succeeded. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::bytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 371, "signature": "[[nodiscard]] std::uint64_t bytesWritten() const noexcept", "summary": "Bytes written so far (header + frame bytes; the trailer is counted when finish() writes it). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::frameCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 374, "signature": "[[nodiscard]] std::uint64_t frameCount() const noexcept", "summary": "Frames recorded so far. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::path", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 378, "signature": "[[nodiscard]] const char* path() const noexcept", "summary": "The FINAL path (the rename destination; the temp file is `path() + \".tmp\"`). Valid for the recorder's lifetime.", "budget": null, "experimental": false}, - {"name": "laige::parseReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 425, "signature": "[[nodiscard]] Result parseReplay(const std::uint8_t* data, std::size_t size) noexcept", "summary": "Parse a replay log from memory (the byte-level reader; the loadReplay file wrapper calls it). Accepts formatVersion == 1 and rejects every structural violation with MalformedInput (never a crash — SCALE-005 / ARCH-007; the violation table in the header preamble). Cold path (the parser's only allocations are the parsed log's frame storage).", "budget": "O(size) time, O(total frame bytes) allocation.", "experimental": false}, - {"name": "laige::loadReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 437, "signature": "[[nodiscard]] Result loadReplay(std::string_view path, std::uint64_t maxBytes = kDefaultReplaySizeLimit) noexcept", "summary": "Load and parse a replay log from `path`: reads the WHOLE file (a bounded read — an oversized file, size > maxBytes, is a MalformedInput, the ADR 0003 JSON-bound precedent) and passes it to parseReplay. maxBytes == 0 means kDefaultReplaySizeLimit.", "budget": "O(file size) time, O(file size) allocation (the bounded read).", "experimental": false}, - {"name": "laige::componentSchemaHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 452, "signature": "[[nodiscard]] std::uint64_t componentSchemaHash(const World& world) noexcept", "summary": "FNV-1a 64 over the world's component registry: the word stream [componentCount, then per type id in ascending order: id, size, alignment] (the house word-stream hash convention — big-endian byte order per u64 word; no addresses enter the words, ARCH-010). O(n) in the registered types (setup path — the registry is fixed before the run), no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, - {"name": "laige::configHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 460, "signature": "[[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept", "summary": "FNV-1a 64 over the provisional EngineConfig's canonical field encoding (tag word 1 — the M1-HEAD-01 surface: tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math). M1-CFG-01 refines the schema, and this encoding with it (under the format's versioning). O(1), no allocation.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::makeReplayIdentity", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 466, "signature": "[[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, const EngineConfig& config) noexcept", "summary": "Assemble the full replay identity (ADR 0002) from the world's component registry and the engine config. O(n) in the registered types, no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::kReplayMagic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 251, "signature": "inline constexpr std::uint8_t kReplayMagic[4] = {'L', 'G', 'R', 'P'}", "summary": "The log's magic (the first 4 bytes: \"LGRP\" — Laige GRePlay).", "budget": null, "experimental": false}, + {"name": "laige::kReplayFormatVersion", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 255, "signature": "inline constexpr std::uint16_t kReplayFormatVersion = 1", "summary": "The supported format version (ARCH-007: readers accept 1, reject everything else explicitly).", "budget": null, "experimental": false}, + {"name": "laige::kReplayHeaderSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 258, "signature": "inline constexpr std::size_t kReplayHeaderSize = 40", "summary": "The fixed header size in bytes (see the header layout above).", "budget": null, "experimental": false}, + {"name": "laige::kReplayFrameRecordOverhead", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 261, "signature": "inline constexpr std::size_t kReplayFrameRecordOverhead = 12", "summary": "The per-frame fixed record size in bytes (tick u64 + length u32).", "budget": null, "experimental": false}, + {"name": "laige::kReplayTrailerSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 264, "signature": "inline constexpr std::size_t kReplayTrailerSize = 16", "summary": "The fixed trailer size in bytes (frameCount u64 + fileHash u64).", "budget": null, "experimental": false}, + {"name": "laige::kMinReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 268, "signature": "inline constexpr std::uint64_t kMinReplaySizeLimit = 56", "summary": "The smallest size limit that can ever hold a complete log (header + trailer, zero frames).", "budget": null, "experimental": false}, + {"name": "laige::kMaxReplayFrameBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 273, "signature": "inline constexpr std::uint32_t kMaxReplayFrameBytes = 1u << 20", "summary": "The maximum frame blob in bytes (1 MiB): the u32 length field's documented domain cap for M1 opaque frames (M3-INPUT-03 defines the payload shape; the cap stands until a format version raises it).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 278, "signature": "inline constexpr std::uint64_t kDefaultReplaySizeLimit = 128ull << 20", "summary": "The default total log size limit (128 MiB = header + frames + trailer): about 11.6M zero-length frames, about 5.2 hours of 60 Hz simulation. 0 passed to create() / loadReplay means this.", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 287, "signature": "struct ReplayIdentity", "summary": "The replay identity: the header's five identity fields. A plain value (PERF-005); compared field-by-field at replay time (M1-DET-03) — a log is bit-exact only under its own identity.", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 289, "signature": "std::uint64_t seed{}", "summary": "The master simulation seed (config.seed).", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 291, "signature": "std::uint32_t tickRateHz{}", "summary": "The simulation tick rate in hertz (config.tickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 294, "signature": "std::uint64_t componentSchemaHash{}", "summary": "FNV-1a 64 over the component registry (see the header preamble \"The replay identity\").", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 297, "signature": "std::uint32_t mathBackendId{}", "summary": "The laige::SimMathBackend value (0 = FixedPoint16_16, 1 = FloatPinned32 — ADR 0002's backend ids).", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentity::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 299, "signature": "std::uint64_t configHash{}", "summary": "FNV-1a 64 over the provisional EngineConfig field encoding.", "budget": null, "experimental": false}, + {"name": "laige::ReplayFrame", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 305, "signature": "struct ReplayFrame", "summary": "One recorded input frame: the completed tick number (the strict 1, 2, 3, ... sequence) plus the opaque byte blob (M1: zero bytes — no input system exists yet; M3-INPUT-03 defines the payload shape).", "budget": null, "experimental": false}, + {"name": "laige::ReplayFrame::tick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 307, "signature": "std::uint64_t tick{}", "summary": "The completed tick this frame belongs to (1-based, in order).", "budget": null, "experimental": false}, + {"name": "laige::ReplayFrame::data", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 309, "signature": "std::vector data", "summary": "The frame's opaque input bytes (empty in M1).", "budget": null, "experimental": false}, + {"name": "laige::ReplayLog", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 316, "signature": "struct ReplayLog", "summary": "A parsed replay log (the loadReplay / parseReplay result): the identity plus every frame in tick order. Cold-path value: the frame storage is owned (one allocation per frame blob; M1 blobs are empty, so M1 logs cost one vector each).", "budget": null, "experimental": false}, + {"name": "laige::ReplayLog::identity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 318, "signature": "ReplayIdentity identity{}", "summary": "The log's replay identity (the header).", "budget": null, "experimental": false}, + {"name": "laige::ReplayLog::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 321, "signature": "std::vector frames", "summary": "Every recorded frame, in tick order (empty when the log has no frames — legal: a zero-tick run).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder", "kind": "class", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 333, "signature": "class ReplayRecorder", "summary": "The replay log writer: create() -> writeFrame() per completed tick -> finish() (see the header preamble \"Recorder contract\" for the full error table). Move-only; the engine owns one per run (opt-in, debug builds only). The recorder logs nothing — the engine emits the structured replay/* events (LOG-001/002).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 341, "signature": "[[nodiscard]] static Result create(const ReplayIdentity& identity, std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Create the recorder for `path` (the FINAL path — the temp file `path + \".tmp\"` is the only thing created now): validates the bounds, opens the temp file, and writes the header carrying `identity`. Error table in the header preamble; @budget one file open + one 40-byte write (cold path); allocates the stdio buffer (one setup allocation, owned by the FILE).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 345, "signature": "ReplayRecorder(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 346, "signature": "ReplayRecorder& operator=(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 350, "signature": "ReplayRecorder(ReplayRecorder&& other) noexcept", "summary": "Move transfers the open file; the source becomes finished (a finished recorder does nothing — the moved-out GameLoop precedent).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 351, "signature": "ReplayRecorder& operator=(ReplayRecorder&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::~ReplayRecorder", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 354, "signature": "~ReplayRecorder() noexcept", "summary": "An unfinished recorder removes its temp file (the final path is never touched; the failure was already reported through a Status).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::writeFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 360, "signature": "[[nodiscard]] Status writeFrame(std::uint64_t tick, const std::uint8_t* data, std::size_t len) noexcept", "summary": "Record one input frame for completed tick `tick` carrying `len` bytes at `data` (len == 0: data may be nullptr). Error table in the header preamble; @budget one 12-byte (+ len) stdio write, no allocation (the stdio buffer holds the data until it flushes).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::finish", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 368, "signature": "[[nodiscard]] Status finish() noexcept", "summary": "Finalize: flush, write the trailer, close, and atomically rename the temp file onto the final path. Error table in the header preamble; @budget one flush + one 16-byte write + one rename (cold path).", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::status", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 373, "signature": "[[nodiscard]] Status status() const noexcept", "summary": "The sticky failure Status (ok while nothing has failed; the last error otherwise — the caller reports it, the recorder does not log). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::finished", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 376, "signature": "[[nodiscard]] bool finished() const noexcept", "summary": "True once finish() succeeded. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::bytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 380, "signature": "[[nodiscard]] std::uint64_t bytesWritten() const noexcept", "summary": "Bytes written so far (header + frame bytes; the trailer is counted when finish() writes it). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::frameCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 383, "signature": "[[nodiscard]] std::uint64_t frameCount() const noexcept", "summary": "Frames recorded so far. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRecorder::path", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 387, "signature": "[[nodiscard]] const char* path() const noexcept", "summary": "The FINAL path (the rename destination; the temp file is `path() + \".tmp\"`). Valid for the recorder's lifetime.", "budget": null, "experimental": false}, + {"name": "laige::parseReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 434, "signature": "[[nodiscard]] Result parseReplay(const std::uint8_t* data, std::size_t size) noexcept", "summary": "Parse a replay log from memory (the byte-level reader; the loadReplay file wrapper calls it). Accepts formatVersion == 1 and rejects every structural violation with MalformedInput (never a crash — SCALE-005 / ARCH-007; the violation table in the header preamble). Cold path (the parser's only allocations are the parsed log's frame storage).", "budget": "O(size) time, O(total frame bytes) allocation.", "experimental": false}, + {"name": "laige::loadReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 446, "signature": "[[nodiscard]] Result loadReplay(std::string_view path, std::uint64_t maxBytes = kDefaultReplaySizeLimit) noexcept", "summary": "Load and parse a replay log from `path`: reads the WHOLE file (a bounded read — an oversized file, size > maxBytes, is a MalformedInput, the ADR 0003 JSON-bound precedent) and passes it to parseReplay. maxBytes == 0 means kDefaultReplaySizeLimit.", "budget": "O(file size) time, O(file size) allocation (the bounded read).", "experimental": false}, + {"name": "laige::componentSchemaHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 461, "signature": "[[nodiscard]] std::uint64_t componentSchemaHash(const World& world) noexcept", "summary": "FNV-1a 64 over the world's component registry: the word stream [componentCount, then per type id in ascending order: id, size, alignment] (the house word-stream hash convention — big-endian byte order per u64 word; no addresses enter the words, ARCH-010). O(n) in the registered types (setup path — the registry is fixed before the run), no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::configHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 469, "signature": "[[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept", "summary": "FNV-1a 64 over the provisional EngineConfig's canonical field encoding (tag word 1 — the M1-HEAD-01 surface: tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math). M1-CFG-01 refines the schema, and this encoding with it (under the format's versioning). O(1), no allocation.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::makeReplayIdentity", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 475, "signature": "[[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, const EngineConfig& config) noexcept", "summary": "Assemble the full replay identity (ADR 0002) from the world's component registry and the engine config. O(n) in the registered types, no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::ReplayIdentityDiff", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 502, "signature": "struct ReplayIdentityDiff", "summary": "The field-by-field result of replayIdentityDiff: one bit per identity field (ADR 0002); empty() == every field matches.", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 503, "signature": "bool seed{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 504, "signature": "bool tickRateHz{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 505, "signature": "bool componentSchemaHash{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 506, "signature": "bool mathBackendId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 507, "signature": "bool configHash{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::empty", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 510, "signature": "[[nodiscard]] bool empty() const noexcept", "summary": "True when no field differs (the identities match).", "budget": null, "experimental": false}, + {"name": "laige::replayIdentityDiff", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 523, "signature": "[[nodiscard]] ReplayIdentityDiff replayIdentityDiff(const ReplayLog& log, const World& world, const EngineConfig& config) noexcept", "summary": "Compare a loaded log's replay identity against the identity the caller's (world, config) would produce (makeReplayIdentity). A non-empty result is a REJECTED REPLAY (never a silent divergence — the header preamble \"The replay identity\"): the log was recorded under a different identity and must not be replayed on this world. O(n) in the registered types, no allocation, no side effects.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::ReplayRunResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 531, "signature": "struct ReplayRunResult", "summary": "The result of runReplay: the per-tick state hashes (the hash line contract above): tickHashes[i] == the world's stateHash(i) after the replay — index 0 is the initial state, indices 1..frameCount one entry per completed tick.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRunResult::tickHashes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 533, "signature": "std::vector tickHashes", "summary": "frameCount + 1 entries (tick 0 .. frameCount).", "budget": null, "experimental": false}, + {"name": "laige::runReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 580, "signature": "[[nodiscard]] Result runReplay(const ReplayLog& log, World& world, const EngineConfig& config) noexcept", "summary": "Replay `log` headlessly on `world`:", "budget": "O(frameCount × tick work + state hash); one allocation", "experimental": false}, {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 393, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 394, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 399, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 7c4ee6b..145d420 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -183,7 +183,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R replay_record` green; malformed log file (truncated, bad version) → `Status` error, never crash. - **Size:** ~200 lines + tests -- [ ] **M1-DET-03 · State hashing + replay runner** +- [x] **M1-DET-03 · State hashing + replay runner** - **Refs:** FR-1.4 (replayable and diffable), FR-11.3; AGENTS TEST-004 - **Depends:** M1-DET-02, M1-ECS-05 - **Scope:** diff --git a/src/laige-core/include/laige/prng.h b/src/laige-core/include/laige/prng.h index 5d665fd..9e4591f 100644 --- a/src/laige-core/include/laige/prng.h +++ b/src/laige-core/include/laige/prng.h @@ -159,6 +159,16 @@ class Prng { // PRD §10.3; M1-DET-03 hashes this together with the substream id). std::uint64_t seed() const { return seed_; } + // The stream's state word 1 (the save/replay identity's part1; PRD + // §10.3 — a saved stream is (seed, part1, part2)). Read-only; O(1), + // no side effects. M1-DET-03: World::stateHash folds these into the + // deterministic state hash with the substream id. + std::uint64_t statePart1() const { return s0_; } + + // The stream's state word 2 (the save/replay identity's part2). + // Read-only; O(1), no side effects. See statePart1(). + std::uint64_t statePart2() const { return s1_; } + // A substream of this stream's seed: deriveSubstream(seed(), id). // Independent stream position; id 0 == the master stream. Prng substream(std::uint32_t id) const; diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index ac9fcec..77f535c 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -60,9 +60,15 @@ # the Engine::startReplayRecording wiring in engine.cpp (the per-tick # empty-frame write, the mid-run failure stop, the finalization in # run_headless, the abandonment in shutdown). +# M1-DET-03 adds state_hash.cpp: the deterministic state hash +# (World::stateHash — the per-tick replay hash line, a pure function of +# the live state; the public contract lives in include/laige/sim/ +# entity.h) and the replay EXECUTION half in replay.cpp +# (replayIdentityDiff + runReplay; the public types and contract live +# in include/laige/sim/replay.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp - engine.cpp replay.cpp) + engine.cpp replay.cpp state_hash.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 7a67648..d27c3b8 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -125,11 +125,26 @@ engine wiring (`Engine::startReplayRecording`: one zero-length frame per completed tick, a mid-run failure stops the run, the ordered shutdown abandons an unfinished recording) and the `laige-run --replay` flag (debug builds only; it was the -M1-HEAD-01 stub); replay execution (`world.state_hash`, the -`laige-replay` runner) is M1-DET-03; API contract in +M1-HEAD-01 stub); API contract in [docs/api/replay.md](../docs/api/replay.md), tests under [tests/laige-sim](../tests/laige-sim) (CTest entries `replay_record` + `fuzz_replay_parse`). +M1-DET-03 landed replay execution — `World::stateHash` (the +deterministic state hash: a pure function of the live state, the +canonical FNV-1a 64 stream, cold path, no allocation — +`include/laige/sim/entity.h`, `state_hash.cpp`), +`replayIdentityDiff` + `runReplay` (the identity-checked, tick-by-tick +re-run producing the per-tick hash stream — +`include/laige/sim/replay.h`, `replay.cpp`), and the `laige-replay` +runner (`tools/replay`: prints ` ` lines on stdout, +`--expect BASELINE` comparison, exit codes 0/1/2); API contract in +[docs/api/replay.md](../docs/api/replay.md) + +[docs/api/entity.md](../docs/api/entity.md), tests under +[tests/laige-sim](../tests/laige-sim) (CTest entry `replay_replay`) +and [tests/replay](../tests/replay) (CTest entries `replay_smoke`, +`replay_deterministic`, `replay_expect_*`, +`replay_identity_mismatch`, `replay_usage_error`, +`replay_log_missing`). The profiler, PRNG state introspection (M1-DET-03), the detcheck matrix (M1-DET-04), and the remaining M1 steps land next; physics, input, and animation in M3. diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 6652af5..bf00fc9 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -33,7 +33,9 @@ // kBudgetCriticalMultiplier, // World::systemTimingStats/systemTimingWindow — the // per-tick rolling windows plus the G-R5 warn/error -// events, driven from runSystems). +// events, driven from runSystems); M1-DET-03 adds the +// deterministic state hash (stateHash — the per-tick +// replay hash line, a pure function of the live state). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -151,6 +153,90 @@ // bit-identical too.) // // --------------------------------------------------------------------------- +// Deterministic state hash (M1-DET-03; FR-1.4, FR-11.3, ARCH-010) +// --------------------------------------------------------------------------- +// +// stateHash(tick) is the deterministic 64-bit hash of the world's LIVE +// sim state at `tick` completed ticks — the per-tick hash line the +// replay runner (M1-DET-03, replay.h runReplay) and the detcheck +// scenario contract (docs/api/detcheck.md) compare run against run. +// It is a pure function of the state — never of the operation history +// that produced it: two worlds that converge on the same live state +// (same live handles, same components, same PRNG states) hash +// identically, whatever their create/destroy/move interleavings were. +// +// Canonical encoding (FNV-1a 64, offset basis 0xcbf29ce484222325, +// prime 0x100000001b3 — the house word-stream convention, fnv.org; +// the same constants as the fpx16_16 determinism KAT, the Prng golden +// vectors, and laige-detcheck). One streaming FNV state over a fixed +// byte stream: +// +// 1. u64 tick (the completed tick count) +// 2. u64 liveCount (the live entity count) +// 3. per live slot, ascending slot-id: +// u16 slot id +// u16 generation +// 4. per distinct live component set S (every NON-EMPTY archetype), +// ascending lexicographic order over S's sorted component-id +// vector (the canonical order of the set — history-independent): +// u16 component count +// u32 x count the component ids, ascending +// u32 row count +// the raw component bytes: per row, ascending slot order (the +// dense-id row order), per column in S's signature order, the +// component's sizeof(T) bytes in memory order +// 5. per system, ascending system id (1..systemCount): +// u32 system id +// u8 1 if the system has a PRNG substream, else 0 +// u64 x 3 the substream's seed, state part1, state part2 +// (when present) +// +// All u16/u32/u64 words are fed big-endian (the house word-stream +// convention); the component bytes are fed in raw memory order (every +// P0 target is little-endian — PRD §6). +// +// SCOPE (exactly what enters the hash): the tick counter; the live +// entity handles (slot + generation); the archetype assignment — +// carried by step 4 (a live slot's archetype is the unique component +// set whose row list contains it; a slot in no list has no +// components); every live component's bytes in that canonical order; +// every system's PRNG substream state (seed + the two state words — +// the draw position, PRD §10.3). +// +// SCOPE (deliberately EXCLUDED — history or non-authoritative): +// - dead slots and their generations (history; the live set is the +// state — two histories with the same live set differ in dead +// generations and must hash the same); +// - the free-list order (same live set, different free stacks after +// different destroy orders — hashing it would make the hash +// history-dependent); +// - empty archetypes and the assigned archetype ids (history: +// first-seen order; step 4's signature order is the canonical +// stand-in); +// - the component registry and the world capacity (replay identity +// and config, not state — ADR 0002); +// - presentation state (the PresentationSnapshot's alpha is +// wall-clock and presentation-only — ARCH-009; presentation.h); +// - timing diagnostics (wall-clock — the system.h lines that carry +// the documented exception markers); +// - the guardrail counters (diagnostic bookkeeping, not +// authoritative state); +// - the master seed itself (replay identity; represented through +// the derived substream seeds when systems exist). +// +// Complexity: O(capacity + the live component bytes + +// kMaxArchetypes²) — the per-set ordering is an insertion sort over at +// most kMaxArchetypes (256) non-empty archetypes comparing ≤ +// kMaxArchetypeComponents (32) component ids. No allocation (fixed +// stack state), no side effects, no logging. COLD path: the replay +// runner and the detcheck scenarios call it once per tick; the +// engine's per-tick hot path never does (PERF-002/003). +// +// Not a cryptographic hash: a state-difference detector for +// determinism verification (FR-1.4/FR-11.3), not a security primitive +// (DEP-002). +// +// --------------------------------------------------------------------------- // Misuse warnings // --------------------------------------------------------------------------- // @@ -662,6 +748,26 @@ class World { // thread — CONC-001): never keep the reference past the world. [[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept; + // ------------------------------------------------------------- + // Deterministic state hash (M1-DET-03; full contract in the header + // preamble "Deterministic state hash" and docs/api/entity.md) + // ------------------------------------------------------------- + + // The deterministic 64-bit hash of the world's live sim state at + // `tick` completed ticks (the replay hash line, M1-DET-03): the tick, + // the live entity handles, the archetype assignment, every live + // component's bytes in the canonical order, and every system's PRNG + // substream state (preamble "Canonical encoding"). A pure function + // of the state — the free list, dead-slot generations, empty + // archetypes, presentation, timing, and guardrail state are EXCLUDED + // (preamble "SCOPE"). `tick` is the caller's completed-tick count + // (0 before the first tick — the initial state's hash). Cold path: + // O(capacity + live component bytes + kMaxArchetypes²); no + // allocation, no side effects, no logging. + // @budget O(capacity + live component bytes + kMaxArchetypes²); no + // allocation. + [[nodiscard]] std::uint64_t stateHash(std::uint64_t tick) const noexcept; + // Destroy every live entity (shutdown path, CONC-006). Every handle // becomes stale; the capacity is unchanged and the world is // immediately reusable. O(capacity + detached rows * row-stride), diff --git a/src/laige-sim/include/laige/sim/replay.h b/src/laige-sim/include/laige/sim/replay.h index 65688e9..8f7fa58 100644 --- a/src/laige-sim/include/laige/sim/replay.h +++ b/src/laige-sim/include/laige/sim/replay.h @@ -10,12 +10,14 @@ // state is a pure function of (config, seed, registration order, N, // inputs)); CORE-005 (the size bounds below are named constants). // -// This header carries the RECORDED half of the replay contract: the -// versioned, byte-exact log of one deterministic run — the replay +// This header carries the FULL replay contract: the RECORDED half — +// the versioned, byte-exact log of one deterministic run (the replay // identity in a fixed header plus one opaque input frame per completed -// tick. The EXECUTION half (loading a log, feeding its frames back -// through the sim, comparing per-tick state hashes) is M1-DET-03, which -// reads exactly this format through parseReplay / loadReplay. +// tick) — and the EXECUTION half (M1-DET-03): loading a log, checking +// its identity against the caller's (world, config), feeding its +// frames back through the sim tick by tick, and producing the per-tick +// state hashes (World::stateHash) that the laige-replay runner +// (tools/replay) prints and compares against a baseline. // // ReplayIdentity The replay identity (ADR 0002) as a plain value. // ReplayFrame One recorded input frame: the completed tick @@ -33,6 +35,13 @@ // configHash The provisional EngineConfig's deterministic hash. // makeReplayIdentity // The full identity from (world, config). +// ReplayIdentityDiff +// The field-by-field identity comparison result. +// replayIdentityDiff +// Compare a log's identity against (world, config). +// ReplayRunResult One replay's per-tick state hashes. +// runReplay The execution half: identity check + the +// deterministic tick-by-tick replay + the hashes. // // --------------------------------------------------------------------------- // The log format (version 1; SCALE-005: byte order, bounds, version, @@ -466,4 +475,110 @@ loadReplay(std::string_view path, [[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, const EngineConfig& config) noexcept; +// ----------------------------------------------------------------------- +// Replay execution (M1-DET-03) +// ----------------------------------------------------------------------- +// +// The execution half: a recorded log is replayed headlessly on a +// fully-registered world (the SAME registrations as the recording +// run — the caller's responsibility: the component registry and the +// system order are what the identity and the sim behavior depend on) +// and the per-tick state hashes (World::stateHash) are produced. +// +// The HASH LINE CONTRACT (the laige-replay stdout form; the same +// contract laige-detcheck enforces on scenario binaries — +// docs/api/detcheck.md): one line per tick, in order, +// +// +// +// where is a non-negative decimal (no leading zeros) and +// is the 16 lowercase hex digits of a 64-bit state hash. The +// FIRST line is tick 0 (the INITIAL state, before any tick); line i+1 +// is the state after completed tick i. A log with N frames therefore +// produces N+1 lines (ticks 0..N). + +// The field-by-field result of replayIdentityDiff: one bit per +// identity field (ADR 0002); empty() == every field matches. +struct ReplayIdentityDiff { + bool seed{}; + bool tickRateHz{}; + bool componentSchemaHash{}; + bool mathBackendId{}; + bool configHash{}; + + // True when no field differs (the identities match). + [[nodiscard]] bool empty() const noexcept { + return !seed && !tickRateHz && !componentSchemaHash && + !mathBackendId && !configHash; + } +}; + +// Compare a loaded log's replay identity against the identity the +// caller's (world, config) would produce (makeReplayIdentity). A +// non-empty result is a REJECTED REPLAY (never a silent divergence — +// the header preamble "The replay identity"): the log was recorded +// under a different identity and must not be replayed on this world. +// O(n) in the registered types, no allocation, no side effects. +// @budget O(componentCount); no allocation. +[[nodiscard]] ReplayIdentityDiff +replayIdentityDiff(const ReplayLog& log, const World& world, + const EngineConfig& config) noexcept; + +// The result of runReplay: the per-tick state hashes (the hash line +// contract above): tickHashes[i] == the world's stateHash(i) after the +// replay — index 0 is the initial state, indices 1..frameCount one +// entry per completed tick. +struct ReplayRunResult { + // frameCount + 1 entries (tick 0 .. frameCount). + std::vector tickHashes; +}; + +// Replay `log` headlessly on `world`: +// +// 1. Check the log's identity against (world, config) (step 0 below); +// 2. Check the determinism mode: a log recorded with determinism +// DISABLED is not replayable (determinism.h: the seed and the +// substreams are part of the replay identity only in deterministic +// mode) — rejected; +// 3. Drive exactly log.frames.size() ticks, each one +// world.beginFrame() + world.runSystems(schedule) (the schedule is +// computed once, before the first tick; one frame per tick — the +// original run's frame grouping is a wall-clock fact, not part of +// the deterministic contract, engine.h; the recorded frame bytes +// are fed as opaque blobs: M1 has no input system to consume +// them, M3-INPUT-03 defines consumption — non-empty frames are +// accepted and ignored); +// 4. Return the per-tick state hashes (World::stateHash — tick 0 +// before the loop, then one hash per completed tick). +// +// replay identity mismatch (any field — see replayIdentityDiff) +// -> ErrorCode::InvalidArgument + one +// structured warn +// (replay/identity_mismatch naming +// the differing fields, LOG-002) +// determinism disabled (config.determinism.enabled == false — the +// identity already matched, so the +// log's header says the same) +// -> ErrorCode::InvalidArgument + one +// structured warn +// (replay/determinism_disabled) +// scheduleSystems fails -> the world's Status (the world +// already logged it; no partial +// result) +// a tick's runSystems fails -> the world's Status (the world +// already logged it; the replay +// stops — no partial hashes) +// +// Cold path (a caller's run, never the engine's per-tick hot path): +// O(frameCount × per-tick system work + per-tick state hash); +// allocates only the result vector (the hash itself allocates +// nothing — entity.h stateHash contract). The caller's world is the +// same kind the recording run used: built-in + game registrations, +// same order (ARCH-010). +// @budget O(frameCount × tick work + state hash); one allocation +// (the result vector). +[[nodiscard]] Result +runReplay(const ReplayLog& log, World& world, + const EngineConfig& config) noexcept; + } // namespace laige diff --git a/src/laige-sim/replay.cpp b/src/laige-sim/replay.cpp index 626c46b..5d8574e 100644 --- a/src/laige-sim/replay.cpp +++ b/src/laige-sim/replay.cpp @@ -36,6 +36,7 @@ #include // MoveFileExA / MOVEFILE_REPLACE_EXISTING #endif +#include "laige/logging.h" // the structured replay/* events (runReplay) #include "laige/sim/engine.h" // EngineConfig (configHash, makeReplayIdentity) namespace laige { @@ -575,4 +576,87 @@ ReplayIdentity makeReplayIdentity(const World& world, configHash(config)}; } +// ----------------------------------------------------------------------- +// Replay execution (M1-DET-03) +// ----------------------------------------------------------------------- + +ReplayIdentityDiff +replayIdentityDiff(const ReplayLog& log, const World& world, + const EngineConfig& config) noexcept { + const ReplayIdentity expected = makeReplayIdentity(world, config); + return ReplayIdentityDiff{ + log.identity.seed != expected.seed, + log.identity.tickRateHz != expected.tickRateHz, + log.identity.componentSchemaHash != expected.componentSchemaHash, + log.identity.mathBackendId != expected.mathBackendId, + log.identity.configHash != expected.configHash}; +} + +Result +runReplay(const ReplayLog& log, World& world, + const EngineConfig& config) noexcept { + // Step 1: the identity (replay.h preamble "The replay identity": a + // replay recorded under identity X is bit-exact only when replayed + // under X — a mismatch is a rejected replay, never a silent + // divergence). + const ReplayIdentityDiff diff = replayIdentityDiff(log, world, config); + if (!diff.empty()) { + // The differing fields are structured values, never message text + // (LOG-002; the system_timing.cpp precedent). + LAIGE_LOG_WARN("replay", "identity_mismatch", + "Replay identity mismatch: the log was recorded under " + "a different identity than (world, config); the replay " + "is rejected (ADR 0002)", + laige::log::field("seed", diff.seed), + laige::log::field("tick_rate", diff.tickRateHz), + laige::log::field("component_schema", + diff.componentSchemaHash), + laige::log::field("math_backend", diff.mathBackendId), + laige::log::field("config", diff.configHash)); + return Result(ErrorCode::InvalidArgument); + } + // Step 2: the determinism mode (determinism.h "Determinism mode + // semantics": a run recorded with determinism disabled is not + // replayable — the seed and the substreams are part of the replay + // identity only in deterministic mode). + if (!config.determinism.enabled) { + LAIGE_LOG_WARN("replay", "determinism_disabled", + "The replay was recorded with determinism disabled; " + "such runs are not replayable (determinism.h)", + laige::log::field("seed", log.identity.seed)); + return Result(ErrorCode::InvalidArgument); + } + // Step 3: the deterministic tick driver. One frame per tick: the + // original run's frame grouping is a wall-clock fact, not part of the + // deterministic contract (engine.h "Determinism scope"); the + // guardrail warn events a replay emits may therefore differ from + // the recorded run's (diagnostics, excluded from the state hash — + // entity.h scope). The recorded frame bytes are opaque in M1: no + // input system consumes them yet (M3-INPUT-03 defines consumption — + // non-empty frames are accepted and ignored). + SystemSchedule schedule; + const Status schedStatus = world.scheduleSystems(schedule); + if (!schedStatus.ok()) { + // The world already logged the failure (system/schedule_*); no + // partial result (CORE-008). + return Result(schedStatus.error()); + } + ReplayRunResult result; + result.tickHashes.reserve(log.frames.size() + 1); + // Tick 0: the initial state (the hash line contract's first line). + result.tickHashes.push_back(world.stateHash(0)); + for (std::size_t i = 0; i < log.frames.size(); ++i) { + world.beginFrame(); + static_cast(log.frames[i].data); // opaque in M1 (above) + const Status tickStatus = world.runSystems(schedule); + if (!tickStatus.ok()) { + // The world already logged the failed system; the replay stops — + // no partial hashes (CORE-008). + return Result(tickStatus.error()); + } + result.tickHashes.push_back(world.stateHash(i + 1)); + } + return Result::success(std::move(result)); +} + } // namespace laige diff --git a/src/laige-sim/state_hash.cpp b/src/laige-sim/state_hash.cpp new file mode 100644 index 0000000..9a4d609 --- /dev/null +++ b/src/laige-sim/state_hash.cpp @@ -0,0 +1,143 @@ +// laige-sim deterministic state hash (M1-DET-03). +// +// Implementation of World::stateHash declared in +// include/laige/sim/entity.h — see that header for the canonical +// encoding, the scope (exactly what enters the hash and what is +// deliberately excluded — history and non-authoritative state), and +// the performance contract, and docs/api/entity.md for the API +// document. +// +// House hash conventions (docs/testing.md, determinism_tests, +// laige-detcheck): FNV-1a 64 — offset basis 0xcbf29ce484222325, prime +// 0x100000001b3 (fnv.org). Word-stream values are fed big-endian byte +// order per u64; raw component bytes are fed in memory order (every P0 +// target is little-endian — PRD §6). One streaming FNV state covers +// the whole stream (FNV-1a is a streaming hash, replay.cpp trailer +// precedent), so no intermediate buffer and no allocation. + +#include "laige/sim/entity.h" // the contract (this header) + +#include +#include + +namespace laige { + +namespace { + +// FNV-1a 64 constants (fnv.org — the house convention). +inline constexpr std::uint64_t kFnvOffsetBasis = 0xcbf29ce484222325ull; +inline constexpr std::uint64_t kFnvPrime = 0x100000001b3ull; + +// One streaming FNV-1a 64 state (stack value; no allocation). +struct Fnv1a64 { + std::uint64_t h = kFnvOffsetBasis; + + // Feed one word, big-endian byte order (the house word-stream + // convention). + void word(std::uint64_t v) noexcept { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (v >> shift) & 0xFFull; + h *= kFnvPrime; + } + } + + // Feed raw bytes in memory order (the component column bytes). + void bytes(const std::uint8_t* p, std::size_t n) noexcept { + for (std::size_t i = 0; i < n; ++i) { + h ^= static_cast(p[i]); + h *= kFnvPrime; + } + } +}; + +// Lexicographic compare of two 0-terminated sorted signatures +// (entity.h "Canonical encoding" step 4): -1 when a < b, 0 when equal, +// 1 when a > b; a proper prefix sorts first. +int compareSig(const std::uint32_t* a, std::uint16_t countA, + const std::uint32_t* b, std::uint16_t countB) noexcept { + for (std::uint16_t i = 0; i < countA && i < countB; ++i) { + if (a[i] != b[i]) return a[i] < b[i] ? -1 : 1; + } + if (countA != countB) return countA < countB ? -1 : 1; + return 0; +} + +} // namespace + +std::uint64_t World::stateHash(std::uint64_t tick) const noexcept { + Fnv1a64 h; + // Step 1: the completed tick count. + h.word(tick); + // Step 2: the live entity count. + h.word(inUse_); + // Step 3: the live handles, ascending slot order (dead slots and + // their generations are history — entity.h scope). + for (std::uint32_t s = 0; s < capacity_; ++s) { + if (alive_[s] != 0) { + h.word(static_cast(s)); + h.word(static_cast(generations_[s])); + } + } + // Step 4: per distinct live component set (every NON-EMPTY + // archetype), ascending lexicographic signature order — the + // canonical, history-independent order of the set (the assigned + // archetype ids are first-seen history and are never hashed). + std::uint32_t order[kMaxArchetypes]{}; + std::uint32_t nonEmpty = 0; + for (std::uint32_t a = 0; a < archetypeCount_; ++a) { + if (archetypes_[a].size > 0) order[nonEmpty++] = a; + } + // Insertion sort over the non-empty archetypes by signature (at most + // kMaxArchetypes entries comparing ≤ kMaxArchetypeComponents ids — + // a cold path, not the per-tick hot path). + for (std::uint32_t i = 1; i < nonEmpty; ++i) { + const std::uint32_t key = order[i]; + const detail::ArchetypeRecord& keyRec = archetypes_[key]; + std::uint32_t j = i; + while (j > 0 && + compareSig(archetypes_[order[j - 1]].sig, + archetypes_[order[j - 1]].sigCount, keyRec.sig, + keyRec.sigCount) > 0) { + order[j] = order[j - 1]; + --j; + } + order[j] = key; + } + for (std::uint32_t i = 0; i < nonEmpty; ++i) { + const detail::ArchetypeRecord& arch = archetypes_[order[i]]; + h.word(arch.sigCount); + for (std::uint16_t c = 0; c < arch.sigCount; ++c) { + h.word(arch.sig[c]); + } + h.word(arch.size); + // The raw component bytes: rows in ascending slot order (the + // dense-id row order, archetype.h invariant I2), columns in + // signature order. + for (std::uint32_t row = 0; row < arch.size; ++row) { + for (std::uint16_t c = 0; c < arch.sigCount; ++c) { + const detail::ArchetypeColumn& col = arch.columns[c]; + h.bytes(reinterpret_cast( + col.base + static_cast(row) * col.size), + col.size); + } + } + } + // Step 5: the per-system PRNG state, ascending system id (the draw + // position is the replay state — PRD §10.3; systems without a + // substream hash their absence, not nothing). + for (std::uint32_t i = 0; i < systemCount_; ++i) { + h.word(static_cast(i + 1)); + const detail::SystemRecord& rec = systems_[i]; + if (rec.rng.has_value()) { + h.word(1); + h.word(rec.rng->seed()); + h.word(rec.rng->statePart1()); + h.word(rec.rng->statePart2()); + } else { + h.word(0); + } + } + return h.h; +} + +} // namespace laige diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b0309cd..779a85e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -85,3 +85,7 @@ add_subdirectory(api) # two-run scenario (built-in + fixture scenario binaries) in # tests/detcheck. add_subdirectory(detcheck) + +# Replay runner checks (M1-DET-03): laige-replay replays a recorded +# log and compares the per-tick state hashes (tests/replay). +add_subdirectory(replay) diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 4a8ceb8..754ec01 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,5 +1,5 @@ # laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03 -# + M1-LOOP-01/02 + M1-HEAD-01 + M1-DET-01/02): +# + M1-LOOP-01/02 + M1-HEAD-01 + M1-DET-01/02/03): # entity handle + World entity storage, component type registry, # archetype SoA storage, query API + iteration legality, # deterministic iteration order, the ECS guardrails (G-R3/G-R4), the @@ -18,7 +18,14 @@ # the replay recording (M1-DET-02: the versioned log format's # round-trip + malformed-input behavior, the recorder's atomic # temp+rename + size limit, the replay-identity hashes, and the -# engine's per-tick empty-frame recording + failure stop). +# engine's per-tick empty-frame recording + failure stop), and the +# deterministic state hash + replay execution (M1-DET-03: +# world.stateHash's exact scope — the tick, the live handles, the +# archetype assignment, every component's bytes, the PRNG substream +# states — its pure-function-of-state property over convergent +# worlds, and runReplay's 500-tick record->replay round trip, the +# first-divergence detection, the identity-mismatch rejection, and +# the determinism-disabled rejection). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus @@ -26,17 +33,18 @@ # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, -# `determinism_mode`, and `replay_record` entries are the M1-ECS-01, -# M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, M1-ECS-07, -# M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, M1-LOOP-02, M1-HEAD-01, -# M1-DET-01, and M1-DET-02 Verify commands (`ctest -R entity`, -# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R -# query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, -# `ctest -R ecs_stress`, `ctest -R system_registry`, `ctest -R -# scheduler`, `ctest -R system_timing`, `ctest -R game_loop`, -# `ctest -R presentation`, `ctest -R engine`, `ctest -R -# determinism_mode`, and `ctest -R replay_record`), selecting exactly -# the suites below from the shared executable. +# `determinism_mode`, `replay_record`, and `replay_replay` entries +# are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, +# M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, +# M1-LOOP-02, M1-HEAD-01, M1-DET-01, M1-DET-02, and M1-DET-03 Verify +# commands (`ctest -R entity`, `ctest -R component_registry`, +# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`, +# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`, `ctest -R +# system_registry`, `ctest -R scheduler`, `ctest -R system_timing`, +# `ctest -R game_loop`, `ctest -R presentation`, `ctest -R engine`, +# `ctest -R determinism_mode`, `ctest -R replay_record`, and +# `ctest -R replay_replay`), selecting exactly the suites below from +# the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp @@ -49,7 +57,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp presentation_tests.cpp engine_tests.cpp determinism_tests.cpp - replay_record_tests.cpp) + replay_record_tests.cpp + replay_replay_tests.cpp) # M1-ECS-03: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own # new/delete (strong symbols in the Clang/GCC TSan runtime archives, @@ -222,6 +231,17 @@ add_test(NAME replay_record COMMAND laige-sim_tests --gtest_filter=Replay*) +# M1-DET-03: the deterministic state hash + the replay execution +# half. The step's Verify command is `ctest -R replay_replay`; this +# entry selects exactly the StateHash / DetReplay suites from the +# shared laige-sim_tests executable (NOT Replay*-prefixed, so the +# replay_record entry above selects exactly the recording suites). +# The machine-greppable replay-500 / replay-engine lines land in the +# ctest output (docs/testing.md §4). +add_test(NAME replay_replay + COMMAND laige-sim_tests + --gtest_filter=StateHash.*:DetReplay.*) + # M1-DET-01: the G-R8 trait compile-checks (the compile-time half of # the determinism guarantee). Each fixture is compiled (not linked, # not run) with the engine policy flags; the positive fixture must @@ -285,6 +305,6 @@ if(LAIGE_TSAN) set_tests_properties(laige-sim_tests entity component_registry archetype query iter_order ecs_guardrails ecs_stress system_registry scheduler system_timing game_loop presentation engine determinism_mode - replay_record PROPERTIES + replay_record replay_replay PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/replay_replay_tests.cpp b/tests/laige-sim/replay_replay_tests.cpp new file mode 100644 index 0000000..01e2916 --- /dev/null +++ b/tests/laige-sim/replay_replay_tests.cpp @@ -0,0 +1,905 @@ +// laige-sim deterministic state hash + replay execution suite +// (M1-DET-03). +// +// Step Verify scope (roadmap/M1-heartbeat.md, `ctest -R +// replay_replay`): +// - `world.stateHash(tick)`: the deterministic 64-bit hash of the +// world's LIVE sim state — exactly the tick, the live handles, the +// archetype assignment, every live component's bytes, and every +// system's PRNG substream state — and a PURE function of the state +// (the free list, dead-slot generations, empty archetypes, the +// registry/capacity, presentation, timing, and guardrail state are +// excluded; convergent worlds hash identically) +// - the replay execution half (`laige::runReplay`): the 500-tick +// recorded scenario replays to the identical per-tick hash stream +// (the step's integration test); a perturbed second run diverges +// first at tick 7 (the first-divergence report the laige-replay +// tool and the baselines compare); an identity mismatch is a +// rejected replay (InvalidArgument + the structured +// replay/identity_mismatch warn, never a silent divergence); a +// log recorded with determinism disabled is not replayable +// - the engine round trip: a recorded headless engine run replays to +// the same per-tick hashes (debug builds — the recorder is +// debug-only, M1-DET-02) +// +// Suite names: StateHash.* and DetReplay.* — deliberately NOT +// Replay*-prefixed (the replay_record CTest entry selects exactly the +// Replay* suites from this shared executable). +// +// The sim in the tests: a trivial mover (RpMove) — one PRNG draw per +// tick (fixed position: before the iteration — the call order is the +// replay state) nudges the velocity; each entity then integrates +// position += velocity through SimMathFpx16 ops only (G-R8, ADR +// 0002). The perturbation probe (RpPerturb) fires once, at a +// test-assigned tick, when armed — the detcheck synthetic-perturbed +// failure-path stand-in. + +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige/sim/engine.h" +#include "laige/sim/replay.h" +#include "laige/sim/system.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "replay_replay_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "replay_replay_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "replay_replay_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test components (global scope: LAIGE_COMPONENT and +// LAIGE_DETERMINISM_SAFE specialize traits at global scope; distinct +// from the other suites' types in the shared executable) +// --------------------------------------------------------------------------- + +struct RpPos { + laige::fpx16_16 x{}; + laige::fpx16_16 y{}; +}; +LAIGE_COMPONENT(RpPos); +LAIGE_DETERMINISM_SAFE(RpPos, laige::fpx16_16, laige::fpx16_16); + +struct RpVel { + laige::fpx16_16 vx{}; + laige::fpx16_16 vy{}; +}; +LAIGE_COMPONENT(RpVel); +LAIGE_DETERMINISM_SAFE(RpVel, laige::fpx16_16, laige::fpx16_16); + +// A third component (the identity-mismatch test's extra registration). +struct RpExtra { + laige::fpx16_16 v{}; +}; +LAIGE_COMPONENT(RpExtra); +LAIGE_DETERMINISM_SAFE(RpExtra, laige::fpx16_16); + +// --------------------------------------------------------------------------- +// The trivial mover + the perturbation probe (FR-1.3: plain functions) +// --------------------------------------------------------------------------- + +namespace { + +// Test plumbing for the perturbation scenario (the owner thread writes +// it only — PRD §10.2). +std::uint64_t gRpTickCounter = 0; // completed ticks (RpMove advances it) +std::uint64_t gRpPerturbTick = 0; // 0 = never perturb + +} // namespace + +// The mover: one PRNG draw per tick (fixed position: before the +// iteration), then integrates position += velocity for every entity +// through SimMathFpx16 ops only (G-R8). The draw nudges the velocity — +// the only randomness -> state edge in the sim. +LAIGE_SYSTEM(RpMove, 1) +void RpMove(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + ++gRpTickCounter; + std::uint32_t nudge = 0; + if (ctx.rng != nullptr) { + nudge = ctx.rng->next_range(0, 5); + } + const laige::fpx16_16 step = + laige::fpx16_16::fromInt32(static_cast(nudge)); + static_cast(ctx.each( + [step](laige::Entity e, RpPos& p, RpVel& v) { + static_cast(e); + p.x = laige::fpx16_16::add(p.x, v.vx); + p.y = laige::fpx16_16::add(p.y, v.vy); + v.vx = laige::fpx16_16::add(v.vx, step); + }, + laige::Write{}, laige::Write{})); +} + +// The perturbation probe: when armed (gRpPerturbTick != 0) and the +// mover has completed exactly that tick, adds 1 to every entity's +// RpExtra value — the state perturbation that makes the second world +// diverge (the detcheck synthetic-perturbed fixture's semantics). +// Registered in BOTH worlds (the registrations are identical — the +// identity is unchanged); only the global arm differs. It writes +// RpExtra (not RpPos) so the schedule keeps one writer per component +// (the scheduler rejects a double writer — M1-SYS-02). +LAIGE_SYSTEM(RpPerturb, 1) +void RpPerturb(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + if (gRpPerturbTick != 0 && gRpTickCounter == gRpPerturbTick) { + static_cast(ctx.each( + [](laige::Entity e, RpExtra& p) { + static_cast(e); + p.v = laige::fpx16_16::add(p.v, laige::fpx16_16::fromInt32(1)); + }, + laige::Write{})); + } +} + +// A system that draws nothing (the "PRNG state enters the hash even +// when undrawn" test). +LAIGE_SYSTEM(RpIdle, 1) +void RpIdle(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +namespace { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// An independent FNV-1a 64 over u64 words, big-endian byte order per +// word (the house convention — a test-local implementation, kept +// independent of the engine's stateHash path so the KAT checks one +// against the other). +std::uint64_t fnv1a64Words(const std::uint64_t* w, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (w[i] >> shift) & 0xFFull; + h *= 0x100000001b3ull; // FNV prime + } + } + return h; +} + +laige::World makeWorld(std::uint32_t capacity, std::uint64_t seed, + bool deterministic = true) { + laige::World::Options opts; + opts.capacity = capacity; + opts.seed = seed; + opts.deterministic = deterministic; + laige::Result r = + laige::World::create(opts); + if (!r.ok()) { + ADD_FAILURE() << "World::create failed"; + abort(); + } + return std::move(r).takeValue(); +} + +// The fixture world: capacity 64, RpPos/RpVel registered (ids 1, 2), +// RpMove (id 1) registered, and — when withPerturb — the RpExtra +// component (id 3) + RpPerturb (id 2) with both entities carrying +// RpExtra(0). Two entities in the fixed initial state (e1 at (3, -2), +// velocity (1, 0); e2 at (-1, 4), velocity (0, 1)). +laige::World makeRpWorld(std::uint64_t seed, bool withPerturb, + bool deterministic = true) { + laige::World world = makeWorld(64, seed, deterministic); + if (!world.registerComponent().ok() || + !world.registerComponent().ok()) { + ADD_FAILURE() << "component registration failed"; + abort(); + } + if (withPerturb && !world.registerComponent().ok()) { + ADD_FAILURE() << "RpExtra registration failed"; + abort(); + } + if (!world.registerSystem( + RpMove_Def, + laige::Io{}, + laige::Io{}).ok()) { + ADD_FAILURE() << "mover registration failed"; + abort(); + } + if (withPerturb && + !world.registerSystem( + RpPerturb_Def, + laige::Io{}).ok()) { + ADD_FAILURE() << "perturb registration failed"; + abort(); + } + const laige::Result e1 = world.create(); + const laige::Result e2 = world.create(); + if (!e1.ok() || !e2.ok()) { + ADD_FAILURE() << "entity creation failed"; + abort(); + } + static_cast(world.addComponent( + e1.value(), RpPos{laige::fpx16_16::fromInt32(3), + laige::fpx16_16::fromInt32(-2)})); + static_cast(world.addComponent( + e2.value(), RpPos{laige::fpx16_16::fromInt32(-1), + laige::fpx16_16::fromInt32(4)})); + static_cast(world.addComponent( + e1.value(), RpVel{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent( + e2.value(), RpVel{laige::fpx16_16::fromInt32(0), + laige::fpx16_16::fromInt32(1)})); + if (withPerturb) { + static_cast(world.addComponent( + e1.value(), RpExtra{laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent( + e2.value(), RpExtra{laige::fpx16_16::fromInt32(0)})); + } + return world; +} + +// Runs `ticks` ticks on `world` (one beginFrame() + one runSystems per +// tick — the replay driver's frame discipline) and returns the +// per-tick state hashes (tick 0 first). +std::vector runTicks(laige::World& world, std::uint64_t ticks) { + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + std::vector hashes; + hashes.reserve(ticks + 1); + hashes.push_back(world.stateHash(0)); + for (std::uint64_t t = 1; t <= ticks; ++t) { + world.beginFrame(); + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "runSystems failed at tick " << t; + abort(); + } + hashes.push_back(world.stateHash(t)); + } + return hashes; +} + +// Records `ticks` ticks of `world` to a replay log at `path` (one +// zero-length frame per completed tick, the M1-DET-02 shape) and +// returns the per-tick state hashes (tick 0 first). +struct RecordResult { + std::string path; + std::vector hashes; +}; + +RecordResult recordRun(laige::World& world, const laige::EngineConfig& config, + std::uint64_t ticks, const std::string& path) { + const laige::ReplayIdentity identity = + laige::makeReplayIdentity(world, config); + laige::Result recR = + laige::ReplayRecorder::create(identity, path, 0); + if (!recR.ok()) { + ADD_FAILURE() << "ReplayRecorder::create failed"; + abort(); + } + laige::ReplayRecorder rec = std::move(recR).takeValue(); + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + std::vector hashes; + hashes.reserve(ticks + 1); + hashes.push_back(world.stateHash(0)); + for (std::uint64_t t = 1; t <= ticks; ++t) { + world.beginFrame(); + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "runSystems failed at tick " << t; + abort(); + } + const laige::Status w = rec.writeFrame(t, nullptr, 0); + if (w.isError()) { + ADD_FAILURE() << "writeFrame failed at tick " << t; + abort(); + } + hashes.push_back(world.stateHash(t)); + } + if (!rec.finish().ok()) { + ADD_FAILURE() << "ReplayRecorder::finish failed"; + abort(); + } + return RecordResult{path, std::move(hashes)}; +} + +// A temp path under gtest's temp dir (the replay_record_tests pattern). +std::string tempPath(const char* name) { + return std::string(::testing::TempDir()) + name; +} + +// --------------------------------------------------------------------------- +// The log event capture (the determinism_tests MemorySink pattern — +// Warn+ only, rate limiting off) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + entries.push_back(Entry{record.severity, std::string(record.subsystem), + std::string(record.event), + std::string(record.message)}); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +std::size_t countEvents(const MemorySink& sink, std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.event == event) ++n; + } + return n; +} + +// The shared test config (the 500-tick scenario's identity). +laige::EngineConfig makeConfig(std::uint64_t seed) { + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 64; + config.churnPerFrameBudget = 256; + config.seed = seed; + return config; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The state hash (M1-DET-03; entity.h "Deterministic state hash") +// --------------------------------------------------------------------------- + +TEST(StateHash, EmptyWorldKAT) { + // The canonical encoding of an empty world is the word stream + // [tick, liveCount] — checked against the independent FNV + // implementation (the KAT: a change to the encoding fails here). + laige::World w0 = makeWorld(0, 0); + const std::uint64_t w00[] = {0, 0}; + const std::uint64_t w07[] = {7, 0}; + EXPECT_EQ(w0.stateHash(0), fnv1a64Words(w00, 2)); + EXPECT_EQ(w0.stateHash(7), fnv1a64Words(w07, 2)); +} + +TEST(StateHash, CapacityIsNotState) { + // The world capacity is config, not state (entity.h scope): two + // empty worlds of different capacity hash identically. + laige::World small = makeWorld(0, 5); + laige::World large = makeWorld(8, 5); + EXPECT_EQ(small.stateHash(0), large.stateHash(0)); + EXPECT_EQ(small.stateHash(3), large.stateHash(3)); +} + +TEST(StateHash, TickAndHandlesEnterTheHash) { + laige::World w = makeWorld(4, 9); + static_cast(w.registerComponent()); + const laige::Result e1 = w.create(); + ASSERT_TRUE(e1.ok()); + static_cast(w.addComponent(e1.value(), RpPos{ + laige::fpx16_16::fromInt32(5), + laige::fpx16_16::fromInt32(5)})); + // The tick counter enters. + EXPECT_NE(w.stateHash(5), w.stateHash(6)); + // The slot id enters: a one-entity world of capacity 2 lives in + // slot 1, not slot 3. + laige::World w2 = makeWorld(2, 9); + static_cast(w2.registerComponent()); + const laige::Result e2 = w2.create(); + ASSERT_TRUE(e2.ok()); + static_cast(w2.addComponent(e2.value(), RpPos{ + laige::fpx16_16::fromInt32(5), + laige::fpx16_16::fromInt32(5)})); + EXPECT_NE(w.stateHash(0), w2.stateHash(0)); + // The generation enters: destroy + recreate bumps it (same slot). + const std::uint64_t before = w.stateHash(0); + ASSERT_TRUE(w.destroy(e1.value()).ok()); + const laige::Result e3 = w.create(); + ASSERT_TRUE(e3.ok()); + EXPECT_NE(before, w.stateHash(0)); + static_cast(e3.value()); +} + +TEST(StateHash, ComponentValuesEnterTheHash) { + laige::World w = makeWorld(4, 9); + static_cast(w.registerComponent()); + const laige::Result e = w.create(); + ASSERT_TRUE(e.ok()); + static_cast( + w.addComponent(e.value(), + RpPos{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(1)})); + const std::uint64_t h1 = w.stateHash(0); + static_cast(w.addComponent( + e.value(), RpPos{laige::fpx16_16::fromInt32(2), + laige::fpx16_16::fromInt32(1)})); + EXPECT_NE(h1, w.stateHash(0)); + // The same final value reached by two histories hashes the same + // (the hash is a function of the state, not the path): overwrite + // back to (1,1) in place (the archetype is unchanged). + static_cast(w.addComponent( + e.value(), RpPos{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(1)})); + EXPECT_EQ(h1, w.stateHash(0)); +} + +TEST(StateHash, ArchetypeAssignmentEntersTheHash) { + // The same bytes in two different component sets -> different hashes + // (the signature words enter the stream). + laige::World a = makeWorld(4, 9); + laige::World b = makeWorld(4, 9); + static_cast(a.registerComponent()); + static_cast(a.registerComponent()); + static_cast(b.registerComponent()); + static_cast(b.registerComponent()); + auto ea = a.create(); + auto eb = b.create(); + ASSERT_TRUE(ea.ok() && eb.ok()); + static_cast(a.addComponent( + ea.value(), RpPos{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + static_cast(b.addComponent( + eb.value(), RpVel{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + EXPECT_NE(a.stateHash(0), b.stateHash(0)); + // Adding a component (a different set) changes the hash too. + static_cast(a.addComponent( + ea.value(), RpVel{laige::fpx16_16::fromInt32(0), + laige::fpx16_16::fromInt32(0)})); + EXPECT_NE(a.stateHash(0), b.stateHash(0)); +} + +TEST(StateHash, ConvergentWorldsHashIdentically) { + // Sequence A (direct): create s3, add RpPos(5), destroy, create s3 + // (gen 2), add RpPos(7). + laige::World a = makeWorld(4, 0); + static_cast(a.registerComponent()); + auto a1 = a.create(); + ASSERT_TRUE(a1.ok()); + static_cast(a.addComponent( + a1.value(), RpPos{laige::fpx16_16::fromInt32(5), + laige::fpx16_16::fromInt32(5)})); + ASSERT_TRUE(a.destroy(a1.value()).ok()); + auto a2 = a.create(); + ASSERT_TRUE(a2.ok()); + static_cast(a.addComponent( + a2.value(), RpPos{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + // Sequence B (interleaved): the same creates/destroys in a different + // order — B also creates (and destroys) a scratch entity, so slot 2 + // ends at generation 2 in B but generation 1 in A (a dead-slot + // generation difference the hash must NOT see). + laige::World b = makeWorld(4, 0); + static_cast(b.registerComponent()); + auto b1 = b.create(); // slot 3 + auto b2 = b.create(); // slot 2 + ASSERT_TRUE(b1.ok() && b2.ok()); + ASSERT_TRUE(b.destroy(b2.value()).ok()); + ASSERT_TRUE(b.destroy(b1.value()).ok()); + auto b3 = b.create(); // slot 3 again (gen 2) + ASSERT_TRUE(b3.ok()); + static_cast(b.addComponent( + b3.value(), RpPos{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + // Identical live state (slot 3, gen 2, RpPos(7)) -> identical hash, + // despite the different dead-slot generations. + EXPECT_EQ(a.stateHash(0), b.stateHash(0)); + + // Archetype-id divergence: C first-sees {RpVel} then {RpPos} (the + // {RpVel} archetype is EMPTY in the final state); D first-sees + // {RpPos} only. Same live state -> same hash (the per-set ordering + // is by SIGNATURE, and empty archetypes are excluded — entity.h + // scope; the assigned ids are first-seen history). + laige::World c = makeWorld(4, 0); + static_cast(c.registerComponent()); + static_cast(c.registerComponent()); + auto c1 = c.create(); + ASSERT_TRUE(c1.ok()); + static_cast(c.addComponent(c1.value(), + RpVel{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + static_cast(c.removeComponent(c1.value())); + static_cast(c.addComponent(c1.value(), + RpPos{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + laige::World d = makeWorld(4, 0); + static_cast(d.registerComponent()); + static_cast(d.registerComponent()); + auto d1 = d.create(); + ASSERT_TRUE(d1.ok()); + static_cast(d.addComponent(d1.value(), + RpPos{laige::fpx16_16::fromInt32(7), + laige::fpx16_16::fromInt32(7)})); + EXPECT_EQ(c.stateHash(0), d.stateHash(0)); +} + +TEST(StateHash, PrngStateEntersTheHash) { + // No systems vs one undrawn system: the substream's existence (and + // state) enters the hash even before any draw. + laige::World plain = makeWorld(64, 42); + static_cast(plain.registerComponent()); + laige::World withIdle = makeWorld(64, 42); + static_cast(withIdle.registerComponent()); + ASSERT_TRUE(withIdle.registerSystem(RpIdle_Def).ok()); + EXPECT_NE(plain.stateHash(0), withIdle.stateHash(0)); + + // The same seed + same systems + same draws -> identical streams; + // a different seed diverges from tick 0. + const std::uint64_t seedA = 0xA5A5; + laige::World a = makeRpWorld(seedA, false); + laige::World b = makeRpWorld(seedA, false); + const std::vector ha = runTicks(a, 8); + const std::vector hb = runTicks(b, 8); + ASSERT_EQ(ha.size(), hb.size()); + for (std::size_t i = 0; i < ha.size(); ++i) { + EXPECT_EQ(ha[i], hb[i]) << "tick " << i; + } + laige::World c = makeRpWorld(seedA + 1, false); + const std::vector hc = runTicks(c, 8); + EXPECT_NE(ha[0], hc[0]); // the substream seeds differ at tick 0 + + // The draw position is the replay state: hashing the same world + // before vs after one tick differs (the stream advanced). + EXPECT_NE(ha[0], ha[1]); +} + +#if defined(LAIGE_ALLOC_COUNTER) +// The state hash is allocation-free (entity.h @budget): a world with +// entities, components, and systems hashes cleanly under the counter. +TEST(StateHash, NoAllocation) { + laige::World w = makeRpWorld(0x9E37, true); + // Warm-up: exercise the world's one-time touch paths (and the + // logger singleton) before the measured window. + static_cast(w.stateHash(0)); + runTicks(w, 2); + laige::test::resetAllocCounter(); + for (int i = 0; i < 16; ++i) { + static_cast(w.stateHash(static_cast(i))); + } + EXPECT_EQ(laige::test::allocCounter(), 0u); +} +#endif + +// --------------------------------------------------------------------------- +// The replay execution half (M1-DET-03; replay.h "Replay execution") +// --------------------------------------------------------------------------- + +// The step's integration test: a 500-tick scenario is recorded, +// replayed on a fresh identically-registered world, and the per-tick +// hashes are identical (bit-exact replay, FR-11.3). +TEST(DetReplay, FiveHundredTickRecordReplay) { + const std::uint64_t seed = 0x0123456789abcdefULL; + const laige::EngineConfig config = makeConfig(seed); + laige::World a = makeRpWorld(seed, false); + const RecordResult rec = recordRun(a, config, 500, + tempPath("replay-500-a.log")); + + laige::World b = makeRpWorld(seed, false); + const laige::Result logR = + laige::loadReplay(rec.path); + ASSERT_TRUE(logR.ok()); + ASSERT_EQ(logR.value().frames.size(), 500u); + const laige::Result rr = + laige::runReplay(logR.value(), b, config); + ASSERT_TRUE(rr.ok()); + const std::vector& hashes = rr.value().tickHashes; + ASSERT_EQ(hashes.size(), 501u); // tick 0..500 + for (std::size_t i = 0; i < hashes.size(); ++i) { + EXPECT_EQ(hashes[i], rec.hashes[i]) << "tick " << i; + } + // The stream is non-trivial (the mover actually moved things). + EXPECT_NE(hashes[0], hashes[500]); + // Machine-greppable identity line (docs/testing.md §4): the seed, + // the frame count, and the first/last hash — byte-identical across + // CI runs of the same commit. + std::printf("replay-500 seed=0x%016llx frames=500 hash0=0x%016llx " + "hash500=0x%016llx\n", + static_cast(seed), + static_cast(hashes[0]), + static_cast(hashes[500])); + std::fflush(stdout); +} + +// The perturbation fixture: two identically-registered worlds (the +// probe registered in both), one with the probe armed at tick 7 — +// identical hashes through tick 6, first divergence at tick 7 (the +// first-divergence report laige-replay --expect and the baselines +// compare). +TEST(DetReplay, PerturbedDivergesAtTick7) { + const std::uint64_t seed = 0x5EED; + gRpTickCounter = 0; + gRpPerturbTick = 0; + laige::World clean = makeRpWorld(seed, true); // probe registered, unarmed + const std::vector hClean = runTicks(clean, 20); + + gRpTickCounter = 0; + gRpPerturbTick = 7; + laige::World perturbed = makeRpWorld(seed, true); + const std::vector hPert = runTicks(perturbed, 20); + gRpPerturbTick = 0; + + ASSERT_EQ(hClean.size(), hPert.size()); + std::size_t firstDiff = hClean.size(); + for (std::size_t i = 0; i < hClean.size(); ++i) { + if (hClean[i] != hPert[i]) { + firstDiff = i; + break; + } + } + EXPECT_EQ(firstDiff, 7u); +} + +// An identity mismatch is a REJECTED replay: InvalidArgument, the +// structured warn names the differing fields, and each field of the +// diff is exactly the field that changed (ADR 0002). +TEST(DetReplay, IdentityMismatchRejected) { + MemorySink* sink = installCaptureSink(); + const std::uint64_t seed = 42; + const laige::EngineConfig config = makeConfig(seed); + laige::World a = makeRpWorld(seed, false); + const RecordResult rec = recordRun(a, config, 8, + tempPath("replay-identity-a.log")); + const laige::Result logR = + laige::loadReplay(rec.path); + ASSERT_TRUE(logR.ok()); + const laige::ReplayLog& log = logR.value(); + + // Control: the matching identity is empty and the replay runs. + laige::World match = makeRpWorld(seed, false); + EXPECT_TRUE(laige::replayIdentityDiff(log, match, config).empty()); + ASSERT_TRUE(laige::runReplay(log, match, config).ok()); + EXPECT_EQ(countEvents(*sink, "identity_mismatch"), 0u); + + laige::EngineConfig seedCfg = config; + seedCfg.seed = 43; + laige::EngineConfig tickCfg = config; + tickCfg.tickRateHz = 120; + laige::EngineConfig backendCfg = config; + backendCfg.determinism.math = laige::SimMathBackend::FloatPinned32; + laige::EngineConfig churnCfg = config; + churnCfg.churnPerFrameBudget = 255; + laige::World extraWorld = makeRpWorld(seed, false); + static_cast(extraWorld.registerComponent()); + + auto checkCase = [&](const char* name, const laige::EngineConfig& cfg, + laige::World& w, bool seedDiff, bool tickRateDiff, + bool schemaDiff, bool backendDiff, bool cfgHashDiff) { + const laige::ReplayIdentityDiff d = + laige::replayIdentityDiff(log, w, cfg); + EXPECT_EQ(d.seed, seedDiff) << name; + EXPECT_EQ(d.tickRateHz, tickRateDiff) << name; + EXPECT_EQ(d.componentSchemaHash, schemaDiff) << name; + EXPECT_EQ(d.mathBackendId, backendDiff) << name; + EXPECT_EQ(d.configHash, cfgHashDiff) << name; + const laige::Result r = + laige::runReplay(log, w, cfg); + EXPECT_TRUE(r.isError()) << name; + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument) << name; + } + }; + // The world is unchanged for the config-side cases (the `match` + // world — fresh from the control replay above); the schema case + // uses the extra-registration world. + checkCase("seed", seedCfg, match, true, false, false, false, true); + checkCase("tick_rate", tickCfg, match, false, true, false, false, true); + checkCase("backend", backendCfg, match, false, false, false, true, true); + checkCase("churn", churnCfg, match, false, false, false, false, true); + checkCase("schema", config, extraWorld, false, false, true, false, false); + EXPECT_EQ(countEvents(*sink, "identity_mismatch"), 5u); + restoreLogger(); +} + +// A log recorded with determinism DISABLED is not replayable +// (determinism.h): the identity matches (the config hash carries the +// mode), but runReplay rejects with the deterministic warn. +TEST(DetReplay, DeterminismDisabledRejected) { + MemorySink* sink = installCaptureSink(); + const std::uint64_t seed = 7; + laige::EngineConfig config = makeConfig(seed); + config.determinism.enabled = false; + laige::World a = makeRpWorld(seed, false, /*deterministic*/ false); + const RecordResult rec = recordRun(a, config, 5, + tempPath("replay-nodet-a.log")); + const laige::Result logR = + laige::loadReplay(rec.path); + ASSERT_TRUE(logR.ok()); + laige::World b = makeRpWorld(seed, false, /*deterministic*/ false); + EXPECT_TRUE(laige::replayIdentityDiff(logR.value(), b, config).empty()); + const laige::Result r = + laige::runReplay(logR.value(), b, config); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "determinism_disabled"), 1u); + restoreLogger(); +} + +#if !defined(NDEBUG) +// The engine round trip: a headless engine run records its replay log +// (M1-DET-02 wiring — run_headless ALWAYS ends in the ordered +// shutdown, CONC-006, so the recording engine's world is released by +// the time the run returns; the final-state check therefore goes +// through a world-level twin with the identical built-in + game +// registrations and initial state, run for exactly the recorded tick +// count). The log replays on fresh identically-registered engines to +// the same per-tick hashes (the recorder is debug-builds-only, +// M1-DET-02). +TEST(DetReplay, EngineRoundTrip) { + const std::uint64_t seed = 0x2A; + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 8; + config.churnPerFrameBudget = 256; + config.seed = seed; + + auto makeEngine = [&]() { + laige::Result r = + laige::Engine::create(config); + if (!r.ok()) { + ADD_FAILURE() << "Engine::create failed"; + abort(); + } + laige::Engine engine = std::move(r).takeValue(); + laige::World& w = *engine.world(); + if (!w.registerComponent().ok() || + !w.registerComponent().ok() || + !w.registerSystem(RpMove_Def, + laige::Io{}, + laige::Io{}) + .ok()) { + ADD_FAILURE() << "engine registration failed"; + abort(); + } + const laige::Result e1 = w.create(); + const laige::Result e2 = w.create(); + if (!e1.ok() || !e2.ok()) { + ADD_FAILURE() << "engine entity creation failed"; + abort(); + } + static_cast(w.addComponent( + e1.value(), RpPos{laige::fpx16_16::fromInt32(3), + laige::fpx16_16::fromInt32(-2)})); + static_cast(w.addComponent( + e2.value(), RpPos{laige::fpx16_16::fromInt32(-1), + laige::fpx16_16::fromInt32(4)})); + static_cast(w.addComponent( + e1.value(), RpVel{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(0)})); + static_cast(w.addComponent( + e2.value(), RpVel{laige::fpx16_16::fromInt32(0), + laige::fpx16_16::fromInt32(1)})); + return engine; + }; + + laige::Engine recEngine = makeEngine(); + const std::string path = tempPath("replay-engine-a.log"); + const laige::Status rec = recEngine.startReplayRecording(path, 0); + ASSERT_TRUE(rec.ok()); + const laige::Status run = recEngine.run_headless(60); + ASSERT_TRUE(run.ok()); + const std::uint64_t frameCount = recEngine.stats().ticks; + EXPECT_GE(frameCount, 60u); // the bounded run reached the target + // (run_headless already ran the ordered shutdown — this second + // call exercises the idempotency, the laige-run convention.) + recEngine.shutdown(); + + const laige::Result logR = + laige::loadReplay(path); + ASSERT_TRUE(logR.ok()); + ASSERT_EQ(logR.value().frames.size(), frameCount); + + // Two fresh engines replay the SAME stream (bit-exact), and the + // stream ends on the recording engine's final state. + laige::Engine repA = makeEngine(); + const laige::Result ra = + laige::runReplay(logR.value(), *repA.world(), config); + ASSERT_TRUE(ra.ok()); + laige::Engine repB = makeEngine(); + const laige::Result rb = + laige::runReplay(logR.value(), *repB.world(), config); + ASSERT_TRUE(rb.ok()); + ASSERT_EQ(ra.value().tickHashes.size(), frameCount + 1); + ASSERT_EQ(rb.value().tickHashes.size(), ra.value().tickHashes.size()); + for (std::size_t i = 0; i < ra.value().tickHashes.size(); ++i) { + EXPECT_EQ(ra.value().tickHashes[i], rb.value().tickHashes[i]) + << "tick " << i; + } + // The world-level twin: the identical built-in + game registrations + // (the engine registers Position2DFpx16 FIRST, then the game's — + // the Engine::create order) and the identical initial state, run + // for exactly the recorded tick count. Its stream starts where the + // replay's starts and ends where it ends (the final state). + laige::World twin = makeWorld(config.entityCapacity, seed); + static_cast(twin.registerComponent()); + static_cast(twin.registerComponent()); + static_cast(twin.registerComponent()); + static_cast(twin.registerSystem(RpMove_Def, + laige::Io{}, + laige::Io{})); + const laige::Result t1 = twin.create(); + const laige::Result t2 = twin.create(); + ASSERT_TRUE(t1.ok() && t2.ok()); + static_cast(twin.addComponent( + t1.value(), RpPos{laige::fpx16_16::fromInt32(3), + laige::fpx16_16::fromInt32(-2)})); + static_cast(twin.addComponent( + t2.value(), RpPos{laige::fpx16_16::fromInt32(-1), + laige::fpx16_16::fromInt32(4)})); + static_cast(twin.addComponent( + t1.value(), RpVel{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(0)})); + static_cast(twin.addComponent( + t2.value(), RpVel{laige::fpx16_16::fromInt32(0), + laige::fpx16_16::fromInt32(1)})); + const std::vector twinHashes = runTicks(twin, frameCount); + ASSERT_EQ(twinHashes.size(), frameCount + 1); + EXPECT_EQ(twinHashes[0], ra.value().tickHashes[0]); + EXPECT_EQ(twinHashes.back(), ra.value().tickHashes.back()); + // And the stream is non-trivial. + EXPECT_NE(ra.value().tickHashes[0], + ra.value().tickHashes.back()); + repA.shutdown(); + repB.shutdown(); + std::printf("replay-engine frames=%llu hash0=0x%016llx hashN=0x%016llx\n", + static_cast(frameCount), + static_cast(ra.value().tickHashes[0]), + static_cast(ra.value().tickHashes.back())); + std::fflush(stdout); +} +#endif diff --git a/tests/replay/CMakeLists.txt b/tests/replay/CMakeLists.txt new file mode 100644 index 0000000..901a73b --- /dev/null +++ b/tests/replay/CMakeLists.txt @@ -0,0 +1,181 @@ +# laige-replay CTest suite (M1-DET-03). +# +# The tool is tested end to end against a recorded run of the engine's +# built-in scenario (`laige-run --headless ... --replay` — the M1-DET-02 +# wiring): the record -> replay -> --expect pipeline, the hash line +# contract on stdout (tick 0 first; 16 lowercase hex; one line per tick +# — a redirect captures a clean baseline file), cross-process +# determinism (two replays, byte-identical streams), the first- +# divergence report on a perturbed baseline, the stream-length and +# malformed-baseline behavior, and the identity-mismatch rejection +# (ADR 0002: a mismatch is a rejected replay, never a silent +# divergence). +# +# Each test is a generated `cmake -P` check script +# (expect-replay-result.cmake.in, the tests/api + tests/detcheck +# pattern) that asserts the exit code AND the required stdout/stderr +# fragments — CTest inverts PASS_REGULAR_EXPRESSION with WILL_FAIL, so +# the content must be checked in the script. The fixture configs live +# in fixtures/; a run of 32 ticks at 60 Hz is ~0.5 s of wall clock per +# recorded run. + +set(LAIGE_REPLAY_TEST_ENV + "LAIGE_REPLAY=$" + "LAIGE_RUN=$" + "LAIGE_FIXTURE=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/replay_smoke.json" + "LAIGE_WRONGSEED=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/replay_smoke_wrongseed.json" + "LAIGE_OUT_DIR=${CMAKE_CURRENT_BINARY_DIR}") + +# laige_replay_test(name [SETUP s] [CMD c] [SECOND_CMD c] [WRITE_OUT p] +# [DERIVE mode] [DERIVED_PATH p] [EXPECT_EXIT n] +# [EXPECT_LINES n] [EXPECT_IDENTICAL bool] +# [PERTURB_TICK n] [CHECK_OUT regex]* [CHECK_ERR regex]*) +# Builds one generated check script; CMD/SECOND_CMD may reference +# (expanded at run +# time from the environment). +function(laige_replay_test name) + set(oneValueArgs SETUP CMD SECOND_CMD WRITE_OUT DERIVE DERIVED_PATH + EXPECT_EXIT EXPECT_LINES EXPECT_IDENTICAL + PERTURB_TICK) + set(multiValueArgs CHECK_OUT CHECK_ERR) + cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" + ${ARGN}) + # The template's @VAR@ names (configure_file @ONLY scope). + set(TEST_NAME "${name}") + set(SETUP_CMD "${ARG_SETUP}") + set(CMD "${ARG_CMD}") + set(SECOND_CMD "${ARG_SECOND_CMD}") + set(WRITE_OUT "${ARG_WRITE_OUT}") + set(DERIVE "${ARG_DERIVE}") + set(DERIVED_PATH "${ARG_DERIVED_PATH}") + set(PERTURB_TICK "${ARG_PERTURB_TICK}") + set(EXPECT_EXIT "${ARG_EXPECT_EXIT}") + set(EXPECT_LINES "${ARG_EXPECT_LINES}") + set(EXPECT_IDENTICAL "${ARG_EXPECT_IDENTICAL}") + set(_checks "") + foreach(_frag IN LISTS ARG_CHECK_OUT) + string(APPEND _checks " +if(NOT _check_out MATCHES \"${_frag}\") + set(_problems \"${_problems}; stdout missing fragment '${_frag}'\") +endif() +") + endforeach() + foreach(_frag IN LISTS ARG_CHECK_ERR) + string(APPEND _checks " +if(NOT _check_err MATCHES \"${_frag}\") + set(_problems \"${_problems}; stderr missing fragment '${_frag}'\") +endif() +") + endforeach() + set(CHECKS "${_checks}") + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-replay-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} + COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") + set_tests_properties(${name} PROPERTIES + ENVIRONMENT "${LAIGE_REPLAY_TEST_ENV}" + TIMEOUT 300) + if(LAIGE_TSAN) + # The first race report is fatal (NFR-8.2 — the repo convention). + set_tests_properties(${name} PROPERTIES + ENVIRONMENT "${LAIGE_REPLAY_TEST_ENV};TSAN_OPTIONS=halt_on_error=1") + endif() +endfunction() + +# The shared setup: record a 32-tick headless run (the M1-DET-02 +# recorder wiring on laige-run; the engine's built-in registrations). +set(RECORD_SETUP + " --headless --ticks 32 --replay /replay_smoke.log") +# The shared replay command (stdout: exactly the 33 hash lines). +set(REPLAY_CMD + " --log /replay_smoke.log --config ") + +# 1) record -> replay: exit 0, the stdout contract (33 lines; each +# ' ' with tick == line index and 16 hex digits — the +# template's structural validation), the summary line on stderr. +laige_replay_test(replay_smoke + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + EXPECT_EXIT 0 + EXPECT_LINES 33 + CHECK_ERR "frames=32 hash_lines=33 result=ok") + +# 2) cross-process determinism: two replays of the same log are +# byte-identical on stdout (FR-11.3). +laige_replay_test(replay_deterministic + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + SECOND_CMD "${REPLAY_CMD}" + EXPECT_EXIT 0 + EXPECT_IDENTICAL true + CHECK_ERR "result=ok") + +# 3) --expect against the captured stream: exit 0, the streams match. +laige_replay_test(replay_expect_match + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + WRITE_OUT "/replay_smoke.baseline" + SECOND_CMD " --log /replay_smoke.log --config --expect /replay_smoke.baseline" + EXPECT_EXIT 0 + EXPECT_IDENTICAL true + CHECK_ERR "result=ok") + +# 4) --expect against a PERTURBED baseline (tick 5's hash flipped): +# exit 1 with the first-divergence report at tick 5 — the step's +# Verify criterion (a perturbed baseline fails at the right tick). +laige_replay_test(replay_expect_perturbed + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + WRITE_OUT "/replay_smoke.baseline" + DERIVE perturb + DERIVED_PATH "/replay_smoke.baseline.derived" + PERTURB_TICK 5 + SECOND_CMD " --log /replay_smoke.log --config --expect /replay_smoke.baseline.derived" + EXPECT_EXIT 1 + CHECK_ERR "hash mismatch at tick 5" "first divergence") + +# 5) --expect against a TRUNCATED baseline (32 lines for 33): exit 1 +# with the stream-length report (the first missing/extra tick). +laige_replay_test(replay_expect_truncated + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + WRITE_OUT "/replay_smoke.baseline" + DERIVE truncate + DERIVED_PATH "/replay_smoke.baseline.derived" + SECOND_CMD " --log /replay_smoke.log --config --expect /replay_smoke.baseline.derived" + EXPECT_EXIT 1 + CHECK_ERR "baseline stream length mismatch" "first missing/extra at tick 32") + +# 6) --expect against a MALFORMED baseline (tick 2's hash non-hex): +# exit 2 with the actionable line report. +laige_replay_test(replay_expect_malformed + SETUP "${RECORD_SETUP}" + CMD "${REPLAY_CMD}" + WRITE_OUT "/replay_smoke.baseline" + DERIVE corrupt + DERIVED_PATH "/replay_smoke.baseline.derived" + SECOND_CMD " --log /replay_smoke.log --config --expect /replay_smoke.baseline.derived" + EXPECT_EXIT 2 + CHECK_ERR "malformed baseline line 2") + +# 7) the replay identity mismatch (ADR 0002): a config recorded under a +# different seed is a REJECTED replay — exit 2, the report names the +# differing fields (seed, config_hash). +laige_replay_test(replay_identity_mismatch + SETUP "${RECORD_SETUP}" + CMD " --log /replay_smoke.log --config " + EXPECT_EXIT 2 + CHECK_ERR "replay identity mismatch" "seed +DIFFERS" "config_hash +DIFFERS") + +# 8) usage error: --config missing -> exit 2. +laige_replay_test(replay_usage_error + CMD " --log /replay_smoke.log" + EXPECT_EXIT 2 + CHECK_ERR "are required") + +# 9) missing log file -> exit 2 (the IoError text on stderr). +laige_replay_test(replay_log_missing + CMD " --log /does_not_exist.log --config " + EXPECT_EXIT 2 + CHECK_ERR "laige-replay: log:") diff --git a/tests/replay/expect-replay-result.cmake.in b/tests/replay/expect-replay-result.cmake.in new file mode 100644 index 0000000..6612f02 --- /dev/null +++ b/tests/replay/expect-replay-result.cmake.in @@ -0,0 +1,212 @@ +# Generated by tests/replay/CMakeLists.txt (@TEST_NAME@) — do not edit. +# +# The `cmake -P` check-script pattern (tests/api, tests/detcheck): an +# optional setup step (a recorded laige-run scenario), then the +# laige-replay command under test, then an optional second run. It +# asserts the exit code, the stdout hash-line contract (structurally: +# CMake's regex engine does not support {n} quantifiers and does not +# interpret `\n` in a pattern — a newline in a pattern is written as a +# real `\n` escape, which CMake turns into a newline character), the +# required stderr fragments, and — when requested — the byte-identity +# of the two runs' stdout. On failure the FATAL_ERROR carries the +# captured output. + +cmake_minimum_required(VERSION 3.16) + +# --- Inputs (substituted at configure time) ------------------------------ +set(_test_name "@TEST_NAME@") +set(_setup_cmd "@SETUP_CMD@") +set(_cmd "@CMD@") +set(_cmd2 "@SECOND_CMD@") +set(_expect_exit "@EXPECT_EXIT@") +set(_expect_lines "@EXPECT_LINES@") +set(_expect_identical "@EXPECT_IDENTICAL@") +set(_write_out "@WRITE_OUT@") +set(_derive "@DERIVE@") +set(_derived_path "@DERIVED_PATH@") +set(_perturb_tick "@PERTURB_TICK@") + +# --- Command expansion: -------- +# resolve to the tool/fixture paths from the ctest ENVIRONMENT property +# ($ generator expressions cannot reach configure_file). +function(expand_vars var) + string(REPLACE "" "$ENV{LAIGE_REPLAY}" _s "${${var}}") + string(REPLACE "" "$ENV{LAIGE_RUN}" _s "${_s}") + string(REPLACE "" "$ENV{LAIGE_FIXTURE}" _s "${_s}") + string(REPLACE "" "$ENV{LAIGE_WRONGSEED}" _s "${_s}") + string(REPLACE "" "$ENV{LAIGE_OUT_DIR}" _s "${_s}") + set(${var} "${_s}" PARENT_SCOPE) +endfunction() + +function(run_cmd cmd out_var err_var rc_var) + string(REPLACE " " ";" _list "${cmd}") + execute_process(COMMAND ${_list} + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err) + set(${out_var} "${_out}" PARENT_SCOPE) + set(${err_var} "${_err}" PARENT_SCOPE) + set(${rc_var} "${_rc}" PARENT_SCOPE) +endfunction() + +set(_problems "") + +# --- The setup step (the recorded scenario) -------------------------------- +if(NOT _setup_cmd STREQUAL "") + expand_vars(_setup_cmd) + run_cmd("${_setup_cmd}" _setup_out _setup_err _setup_rc) + if(NOT _setup_rc EQUAL 0) + message(FATAL_ERROR "@TEST_NAME@: setup step failed [rc=${_setup_rc}]\n" + "--- setup stdout ---\n${_setup_out}\n" + "--- setup stderr ---\n${_setup_err}") + endif() +endif() + +# --- The command under test ------------------------------------------------ +expand_vars(_cmd) +run_cmd("${_cmd}" _out _err _rc) + +# The stdout hash-line contract (one ' ' line per tick, tick +# 0 first, 16 lowercase hex digits — checked structurally, per line). +if(NOT _expect_lines STREQUAL "") + string(REPLACE "\n" ";" _ols "${_out}") + list(LENGTH _ols _ol_count) + if(_out MATCHES "\n$") + math(EXPR _lines "${_ol_count} - 1") + elseif(NOT _out STREQUAL "") + math(EXPR _lines "${_ol_count}") + else() + set(_lines 0) + endif() + if(NOT _lines EQUAL _expect_lines) + set(_problems "${_problems}; stdout line count ${_lines} " + "(expected ${_expect_lines})") + else() + math(EXPR _lines_last "${_lines} - 1") + foreach(_i RANGE 0 ${_lines_last}) + list(GET _ols ${_i} _line) + if(NOT _line MATCHES "^[0-9]+ [0-9a-f]+$") + set(_problems "${_problems}; stdout line ${_i} malformed: '${_line}'") + break() + endif() + string(FIND "${_line}" " " _sp) + math(EXPR _sp1 "${_sp} + 1") + string(SUBSTRING "${_line}" 0 ${_sp} _tick) + string(SUBSTRING "${_line}" ${_sp1} -1 _hash) + string(LENGTH "${_hash}" _hl) + if(NOT _tick STREQUAL "${_i}" OR NOT _hl EQUAL 16) + set(_problems "${_problems}; stdout line ${_i} contract " + "violation (tick=${_tick}, hash_len=${_hl})") + break() + endif() + endforeach() + endif() +endif() + +# --- The baseline capture + derivation (the --expect fixtures) -------------- +if(NOT _write_out STREQUAL "") + expand_vars(_write_out) + file(WRITE "${_write_out}" "${_out}") + if(NOT _derive STREQUAL "") + expand_vars(_derived_path) + file(READ "${_write_out}" _base) + # Locate the target line by index (each line carries a unique tick + # number, so the exact line text occurs once in the file — the + # derived file is an exact-substring replacement of the baseline). + string(REPLACE "\n" ";" _dlines "${_base}") + list(LENGTH _dlines _dn) + math(EXPR _dn_last "${_dn} - 1") + list(GET _dlines ${_dn_last} _dlast) + if(_dlast STREQUAL "") + math(EXPR _dn "${_dn} - 1") # the file ends with a newline + endif() + if(_derive STREQUAL "perturb") + # Flip the first hex digit of the perturb-tick line's hash (a + # different 16-hex hash; every other line is untouched). + list(GET _dlines ${_perturb_tick} _line) + string(FIND "${_line}" " " _dsp) + math(EXPR _dsp1 "${_dsp} + 1") + string(SUBSTRING "${_line}" 0 ${_dsp1} _line_prefix) + string(SUBSTRING "${_line}" ${_dsp1} -1 _dhash) + string(LENGTH "${_dhash}" _dhl) + if(NOT _dhl EQUAL 16 OR NOT _dhash MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "@TEST_NAME@: derive: line ${_perturb_tick} " + "has no 16-hex hash: '${_line}'") + endif() + string(SUBSTRING "${_dhash}" 0 1 _c0) + string(SUBSTRING "${_dhash}" 1 15 _rest) + if(_c0 STREQUAL "0") + set(_nc "1") + else() + set(_nc "0") + endif() + string(REPLACE "${_line}" "${_line_prefix}${_nc}${_rest}" + _content "${_base}") + elseif(_derive STREQUAL "truncate") + # Drop the last line (the first missing/extra is that tick). + math(EXPR _last "${_dn} - 1") + list(GET _dlines ${_last} _lastline) + string(REPLACE "${_lastline}\n" "" _content "${_base}") + elseif(_derive STREQUAL "corrupt") + # Replace tick 2's hash with non-hex digits (malformed line). + list(GET _dlines 2 _line) + string(FIND "${_line}" " " _dsp) + math(EXPR _dsp1 "${_dsp} + 1") + string(SUBSTRING "${_line}" 0 ${_dsp1} _line_prefix) + string(REPLACE "${_line}" "${_line_prefix}ZZZZZZZZZZZZZZZZ" + _content "${_base}") + else() + message(FATAL_ERROR "@TEST_NAME@: unknown derive mode '${_derive}'") + endif() + file(WRITE "${_derived_path}" "${_content}") + endif() +endif() + +# --- The optional second run ----------------------------------------------- +if(NOT _cmd2 STREQUAL "") + expand_vars(_cmd2) + run_cmd("${_cmd2}" _out2 _err2 _rc2) + if(_expect_identical STREQUAL "true" AND NOT _out2 STREQUAL _out) + set(_problems "${_problems}; second run stdout differs from the first") + endif() +endif() + +# The exit-code and fragment checks apply to the LAST run (the +# --expect comparison output lands on the second run; on single-run +# tests it is the first). +set(_check_rc "${_rc}") +set(_check_out "${_out}") +set(_check_err "${_err}") +if(NOT _cmd2 STREQUAL "") + set(_check_rc "${_rc2}") + set(_check_out "${_out2}") + set(_check_err "${_err2}") +endif() +if(NOT _check_rc EQUAL _expect_exit) + set(_problems "exit code ${_check_rc} (expected ${_expect_exit})") +endif() + +@CHECKS@ + +if(NOT _problems STREQUAL "") + if(_problems MATCHES "^; ") + string(SUBSTRING "${_problems}" 2 -1 _problems) + endif() + if(NOT _cmd2 STREQUAL "") + set(_second_out " +--- second run stdout --- +${_out2} +--- second run stderr --- +${_err2}") + else() + set(_second_out "") + endif() + message(FATAL_ERROR + "@TEST_NAME@: laige-replay check failed [${_problems}]\n" + "--- run stdout ---\n${_out}\n" + "--- run stderr ---\n${_err}\n" + "${_second_out}\n" + "--- end of run output ---") +endif() + +message(STATUS "@TEST_NAME@: OK (exit ${_rc}, expected ${_expect_exit})") diff --git a/tests/replay/fixtures/replay_smoke.json b/tests/replay/fixtures/replay_smoke.json new file mode 100644 index 0000000..c7e71c5 --- /dev/null +++ b/tests/replay/fixtures/replay_smoke.json @@ -0,0 +1,6 @@ +{ + "tick_rate_hz": 60, + "entity_budget": 64, + "churn_per_frame_budget": 256, + "seed": 42 +} diff --git a/tests/replay/fixtures/replay_smoke_wrongseed.json b/tests/replay/fixtures/replay_smoke_wrongseed.json new file mode 100644 index 0000000..eb585ed --- /dev/null +++ b/tests/replay/fixtures/replay_smoke_wrongseed.json @@ -0,0 +1,6 @@ +{ + "tick_rate_hz": 60, + "entity_budget": 64, + "churn_per_frame_budget": 256, + "seed": 43 +} diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index c3a3fa3..e26b222 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -7,9 +7,10 @@ // // The step's scope: run a named scenario in two build configurations // (e.g. Debug+ASan vs Release, or two compiler builds) and compare -// per-tick state hashes. The engine state-hash API arrives with -// M1-DET-03; this skeleton accepts the hash-file output contract — a -// stream of ` ` lines — and compares two such streams. +// per-tick state hashes. The engine state-hash API landed with +// M1-DET-03 (`World::stateHash`); this tool accepts the hash-file +// output contract — a stream of ` ` lines — and compares +// two such streams. // // ============================================================================ // SCENARIO CONTRACT (documented here; also in docs/api/detcheck.md) diff --git a/tools/replay/CMakeLists.txt b/tools/replay/CMakeLists.txt new file mode 100644 index 0000000..2506f78 --- /dev/null +++ b/tools/replay/CMakeLists.txt @@ -0,0 +1,32 @@ +# laige-replay (M1-DET-03): the headless replay runner. +# +# Canonical command (docs/getting-started/building.md): +# +# ./build/bin/laige-replay --log --config +# [--expect ] +# +# Loads a recorded replay log (M1-DET-02 format), checks its replay +# identity against (the engine's world, the config) — a mismatch is a +# rejected replay (ADR 0002) — replays the log headlessly (one tick +# per frame, the deterministic runReplay driver), and prints the +# per-tick state hashes (World::stateHash) as the hash line contract +# (tick 0 first; the same contract laige-detcheck enforces on +# scenario binaries). With --expect it also compares against a +# baseline hash stream and exits 1 on the first divergence. Exit +# codes 0/1/2 are documented in the source header and +# docs/api/replay.md. +# +# Gated with the test suite like the other tools: a library-only +# build does not need it. The tool's CTest suite (record → replay → +# --expect, the perturbed-baseline divergence, the identity-mismatch +# rejection) lives in tests/replay. +# +# Headless invariants (ARCH-003, NFR-8.11): the include-graph lint +# guarantees this target's include closure (laige-sim + laige-core) +# touches no GL/window/audio/input API. + +add_executable(laige-replay laige-replay.cpp) +laige_apply_engine_policy(laige-replay) +# laige-sim is the only internal dependency (PRD §10.1: arrows only +# downward); laige-core's public headers come through it (CPP-010). +target_link_libraries(laige-replay PRIVATE laige-sim) diff --git a/tools/replay/laige-replay.cpp b/tools/replay/laige-replay.cpp new file mode 100644 index 0000000..db50705 --- /dev/null +++ b/tools/replay/laige-replay.cpp @@ -0,0 +1,482 @@ +// laige-replay (M1-DET-03): the headless replay runner. +// +// FR-1.4 (determinism mode), FR-11.3 (replay: every debug run can be +// recorded and replayed bit-exactly; diff two replays by frame/state), +// PRD Appendix A (replay = input log + seed), ADR 0002 (the replay +// identity: inputs + seed + math backend + config), ARCH-007 (versioned +// replay data; readers reject explicitly), CORE-008 (a mismatch is a +// loud, actionable report — never a silent divergence). +// +// Usage (docs/api/replay.md, the "laige-replay" section): +// +// laige-replay --log --config [--expect ] +// +// Loads the recorded replay log (the M1-DET-02 versioned format, +// laige/sim/replay.h), checks its replay identity against (the +// engine's world, the config) — a mismatch is a REJECTED REPLAY, +// never a silent divergence (ADR 0002) — replays the log headlessly +// on the engine's world (runReplay: one beginFrame() + one +// runSystems per recorded tick), and prints the per-tick state hashes +// (World::stateHash) as the hash line contract: +// +// +// +// one line per tick, tick 0 (the initial state) first, then one line +// per completed tick; is 16 lowercase hex digits. This is the +// same contract laige-detcheck enforces on scenario binaries +// (docs/api/detcheck.md). +// +// Exit codes (documented, stable for CI grepping): +// 0 the replay completed and, when --expect was given, every hash +// matches the baseline (the summary line goes to stderr; the hash +// lines are the ONLY stdout content — a redirect captures a clean +// baseline file) +// 1 a per-tick hash mismatch with the --expect baseline (the first +// divergence is reported to stderr) +// 2 usage, IO, config-parse, log-load, identity-mismatch, or +// engine-create error (the message carries the actionable text) +// +// Headless invariants (ARCH-003, NFR-8.11): this binary and everything +// it links (laige-sim, laige-core) touch no GL, window, audio, or +// input API. +// +// The M1 scenario surface: this binary replays logs recorded by +// `laige-run --headless` (the engine's built-in registrations — the +// binary registers no game components of its own). A game scenario +// (M1-SAMPLE-01's hello.laige) replays through the same library +// surface (laige/sim/replay.h runReplay) from the scenario's own +// binary, which registers the scenario's components and systems on +// the world before the run (the registration order is part of the +// replay identity — ADR 0002). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below +#endif + +#include "laige/errors.h" +#include "laige/json.h" +#include "laige/result.h" +#include "laige/sim/engine.h" +#include "laige/sim/replay.h" + +namespace { + +// The 1 MiB config-document bound (the laige-run / ADR 0003 bound). +inline constexpr std::size_t kMaxConfigBytes = 1u << 20; + +// The baseline-stream bounds (CORE-005): at most kMaxExpectTicks +// hash lines (the detcheck 65536-tick contract) at at most +// kMaxBaselineLineBytes each — worst case 65536 * 65 + 65536 +// separators ~ 4.3 MiB; the file read cap is the round bound above. +inline constexpr std::uint64_t kMaxExpectTicks = 65536; +inline constexpr std::size_t kMaxBaselineLineBytes = 64; +inline constexpr std::size_t kMaxBaselineBytes = 8u << 20; + +void printUsage(std::FILE* out) { + std::fprintf(out, + "Usage: laige-replay --log --config " + "[--expect ]\n" + "\n" + " --log the recorded replay log (required;\n" + " the laige/sim/replay.h version 1\n" + " format)\n" + " --config the engine config of the recorded\n" + " run (required; the replay identity is\n" + " checked against it — ADR 0002: a\n" + " mismatch is a rejected replay)\n" + " --expect compare the replay's per-tick hashes\n" + " against the baseline hash stream\n" + " (one ' ' line per tick,\n" + " tick 0 first — the detcheck scenario\n" + " contract); a mismatch exits 1 with the\n" + " first divergence report\n" + " --help, -h this help\n" + "\n" + "stdout: exactly one ' ' line per tick (tick 0 = the\n" + "initial state; 16 lowercase hex hash digits) — nothing else. A\n" + "redirect captures a clean baseline file. All diagnostics and\n" + "the summary line go to stderr.\n" + "\n" + "Exit codes: 0 = ok (and the hashes match the baseline when\n" + "--expect was given), 1 = a per-tick hash mismatch with the\n" + "baseline, 2 = usage / IO / config / log / identity error.\n"); +} + +// Portable file open (CPP-009 compile-time platform boundary — the +// laige-run.cpp precedent: MSVC's CRT deprecates plain fopen and +// fopen_s's _SH_SECURE sharing denies re-open; _fsopen(_SH_DENYNO) is +// the plain-fopen sharing semantics every other compiler provides). +#if defined(_MSC_VER) +inline std::FILE* openFile(const char* path, const char* mode) { + return ::_fsopen(path, mode, _SH_DENYNO); +} +#else +inline std::FILE* openFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +#endif + +// Reads a file into a bounded buffer (cap bytes; over the cap is a +// MalformedInput, the ADR 0003 / loadReplay precedent). +laige::Status readFileBounded(const std::string& path, std::size_t cap, + std::string* out) { + std::FILE* file = openFile(path.c_str(), "rb"); + if (file == nullptr) { + return laige::ErrorCode::IoError; + } + out->clear(); + char chunk[8192]; + for (;;) { + const std::size_t n = std::fread(chunk, 1, sizeof(chunk), file); + if (n == 0) { + if (std::ferror(file)) { + std::fclose(file); + return laige::ErrorCode::IoError; + } + break; // clean EOF + } + out->append(chunk, n); + if (out->size() > cap) { + std::fclose(file); + return laige::ErrorCode::MalformedInput; + } + } + std::fclose(file); + return laige::Status{}; +} + +// Formats one identity field report line: ` : log= cfg=`. +void reportIdentityField(std::FILE* out, const char* name, bool differs, + const char* logValue, const char* cfgValue) { + std::fprintf(out, " %-20s %s log=%s config=%s\n", name, + differs ? "DIFFERS" : "match", logValue, cfgValue); +} + +// The 16 lowercase hex digits of a u64 (the canonical hash text form). +void formatHex16(char* out /* 17 bytes */, std::uint64_t v) { + std::snprintf(out, 17, "%016llx", + static_cast(v)); +} + +// One baseline line (the hash line contract, detcheck scenario form): +// ' ' — tick the exact decimal of the line index (no +// padding, no leading zeros), one space, 16 lowercase hex digits. +// Returns the parsed hash on success; the caller owns `hashHex` +// (17 bytes, NUL-terminated). +bool parseBaselineLine(std::string_view line, std::size_t lineIndex, + char* hashHex) { + if (line.size() > kMaxBaselineLineBytes) return false; + // The tick token: the exact decimal of lineIndex (a leading zero or + // padding would parse a different number — the contract allows no + // leading zeros, so the string form is the strict check). + const std::string expectedTick = std::to_string(lineIndex); + if (line.compare(0, expectedTick.size(), expectedTick) != 0) return false; + std::size_t pos = expectedTick.size(); + if (pos >= line.size() || line[pos] != ' ') return false; + ++pos; + const std::size_t rest = line.size() - pos; + if (rest != 16) return false; + std::uint64_t hash = 0; + for (std::size_t i = 0; i < rest; ++i) { + const char c = line[pos + i]; + int nibble = -1; + if (c >= '0' && c <= '9') { + nibble = c - '0'; + } else if (c >= 'a' && c <= 'f') { + nibble = c - 'a' + 10; // lowercase only (the contract form) + } + if (nibble < 0) return false; // uppercase or non-hex rejected + hash = (hash << 4) | static_cast(nibble); + } + formatHex16(hashHex, hash); + return true; +} + +} // namespace + +int main(int argc, char** argv) { + std::string logPath; + std::string configPath; + std::string baselinePath; + bool hasLog = false; + bool hasConfig = false; + + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--log") { + if (i + 1 >= argc) { + std::fprintf(stderr, "laige-replay: --log needs a log path\n"); + printUsage(stderr); + return 2; + } + hasLog = true; + logPath = argv[++i]; + } else if (arg == "--config") { + if (i + 1 >= argc) { + std::fprintf(stderr, + "laige-replay: --config needs a config path\n"); + printUsage(stderr); + return 2; + } + hasConfig = true; + configPath = argv[++i]; + } else if (arg == "--expect") { + if (i + 1 >= argc) { + std::fprintf(stderr, + "laige-replay: --expect needs a baseline path\n"); + printUsage(stderr); + return 2; + } + baselinePath = argv[++i]; + } else if (arg == "--help" || arg == "-h") { + printUsage(stdout); + return 0; + } else { + std::fprintf(stderr, "laige-replay: unknown option '%s'\n", + arg.c_str()); + printUsage(stderr); + return 2; + } + } + if (!hasLog || !hasConfig) { + std::fprintf(stderr, + "laige-replay: --log and --config " + "are required\n"); + printUsage(stderr); + return 2; + } + + // The config (the laige-run load path: bounded read + JSON + the + // provisional engine-config surface). + std::string document; + const laige::Status readStatus = + readFileBounded(configPath, kMaxConfigBytes, &document); + if (readStatus.isError()) { + std::fprintf(stderr, "laige-replay: config: %s\n", + laige::errorText(readStatus.error())); + return 2; + } + const laige::Result parsed = laige::parseJson(document); + if (parsed.isError()) { + std::fprintf(stderr, "laige-replay: config: %s\n", + laige::errorText(parsed.error())); + return 2; + } + const laige::Result config = + laige::parseEngineConfig(parsed.value()); + if (config.isError()) { + std::fprintf(stderr, "laige-replay: config: %s\n", + laige::errorText(config.error())); + return 2; + } + + // The log (M1-DET-02 format; the bounded read + the structural + // validation are loadReplay's). + const laige::Result log = + laige::loadReplay(logPath); + if (log.isError()) { + std::fprintf(stderr, "laige-replay: log: %s\n", + laige::errorText(log.error())); + return 2; + } + const std::uint64_t frameCount = log.value().frames.size(); + + // The engine (the built-in registrations — this binary registers no + // game components of its own; the M1 scenario surface, see the + // header preamble). + laige::Result engineResult = + laige::Engine::create(config.value()); + if (engineResult.isError()) { + std::fprintf(stderr, "laige-replay: engine: %s\n", + laige::errorText(engineResult.error())); + return 2; + } + laige::Engine engine = std::move(engineResult).takeValue(); + laige::World& world = *engine.world(); + + // The replay identity (ADR 0002): a mismatch is a REJECTED replay — + // never a silent divergence. The report names every differing field + // with both sides' values (actionable, LOG-002 shape). + const laige::ReplayIdentityDiff diff = + laige::replayIdentityDiff(log.value(), world, config.value()); + if (!diff.empty()) { + const laige::ReplayIdentity recorded = log.value().identity; + const laige::ReplayIdentity expected = + laige::makeReplayIdentity(world, config.value()); + char a[17], b[17]; + std::fprintf(stderr, + "laige-replay: replay identity mismatch — the log was " + "recorded under a different identity; a mismatch is a " + "rejected replay, never a silent divergence (ADR " + "0002)\n"); + reportIdentityField(stderr, "seed", diff.seed, + (formatHex16(a, recorded.seed), a), + (formatHex16(b, expected.seed), b)); + reportIdentityField(stderr, "tick_rate_hz", diff.tickRateHz, + (std::to_string(recorded.tickRateHz)).c_str(), + (std::to_string(expected.tickRateHz)).c_str()); + reportIdentityField(stderr, "component_schema_hash", + diff.componentSchemaHash, + (formatHex16(a, recorded.componentSchemaHash), a), + (formatHex16(b, expected.componentSchemaHash), b)); + reportIdentityField(stderr, "math_backend_id", diff.mathBackendId, + (std::to_string(recorded.mathBackendId)).c_str(), + (std::to_string(expected.mathBackendId)).c_str()); + reportIdentityField(stderr, "config_hash", diff.configHash, + (formatHex16(a, recorded.configHash), a), + (formatHex16(b, expected.configHash), b)); + std::fprintf(stderr, + "laige-replay: re-record the log or pass the config " + "(and registrations) that produced it\n"); + engine.shutdown(); + return 2; + } + + // The execution half (M1-DET-03): identity-checked, deterministic, + // tick by tick; the per-tick state hashes. + const laige::Result replay = + laige::runReplay(log.value(), world, config.value()); + if (replay.isError()) { + std::fprintf(stderr, "laige-replay: replay: %s\n", + laige::errorText(replay.error())); + engine.shutdown(); + return 2; + } + const std::vector& hashes = replay.value().tickHashes; + + // The hash lines: stdout ONLY (a redirect captures a clean + // baseline). + for (std::size_t i = 0; i < hashes.size(); ++i) { + char hex[17]; + formatHex16(hex, hashes[i]); + std::printf("%llu %s\n", static_cast(i), hex); + } + std::fflush(stdout); + + // The optional baseline comparison (the --expect half of the + // step's scope). + std::string result = "ok"; + int exitCode = 0; + if (!baselinePath.empty()) { + if (frameCount + 1 > kMaxExpectTicks) { + std::fprintf(stderr, + "laige-replay: the replay has %llu hash lines, above " + "the baseline bound kMaxExpectTicks=%llu — a " + "harness limit (CORE-005)\n", + static_cast(frameCount + 1), + static_cast(kMaxExpectTicks)); + exitCode = 2; + } else { + std::string baseline; + const laige::Status baseRead = + readFileBounded(baselinePath, kMaxBaselineBytes, &baseline); + if (baseRead.isError()) { + std::fprintf(stderr, "laige-replay: baseline: %s\n", + laige::errorText(baseRead.error())); + exitCode = 2; + } else { + // Split into lines (one separator per line; an optional + // trailing newline and an optional trailing \r are tolerated — + // the detcheck contract). + std::vector lines; + std::size_t start = 0; + for (;;) { + const std::size_t nl = baseline.find('\n', start); + if (nl == std::string::npos) { + lines.push_back(baseline.substr(start)); + break; + } + std::string line = baseline.substr(start, nl - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(std::move(line)); + start = nl + 1; + if (start >= baseline.size()) break; // trailing newline + } + if (!lines.empty() && lines.back().empty() && + baseline.size() > 0 && baseline.back() == '\n') { + lines.pop_back(); // the optional trailing newline + } + + bool lengthMismatch = false; + std::size_t firstDiff = 0; + char baseHex[17] = {0}, repHex[17] = {0}; + bool hashDiff = false; + if (lines.size() != hashes.size()) { + lengthMismatch = true; + firstDiff = lines.size() < hashes.size() + ? lines.size() + : hashes.size(); + } else { + for (std::size_t i = 0; i < lines.size(); ++i) { + char parsedHex[17]; + if (!parseBaselineLine(lines[i], i, parsedHex)) { + std::fprintf(stderr, + "laige-replay: malformed baseline line %llu: " + "'%s' (the contract is ' ': the " + "line index, one space, 16 lowercase hex " + "digits)\n", + static_cast(i), + lines[i].c_str()); + exitCode = 2; + break; + } + if (std::strcmp(parsedHex, + (formatHex16(repHex, hashes[i]), repHex)) != 0) { + hashDiff = true; + firstDiff = i; + std::strncpy(baseHex, parsedHex, 16); + baseHex[16] = '\0'; + break; + } + } + } + if (exitCode == 0 && (lengthMismatch || hashDiff)) { + if (hashDiff) { + std::fprintf(stderr, + "laige-replay: hash mismatch at tick %llu " + "(first divergence)\n" + " baseline: %llu %s\n" + " replay: %llu %s\n", + static_cast(firstDiff), + static_cast(firstDiff), + baseHex, static_cast(firstDiff), + repHex); + result = "mismatch"; + } else { + std::fprintf(stderr, + "laige-replay: baseline stream length " + "mismatch: the baseline has %llu lines, the " + "replay produces %llu (first missing/extra at " + "tick %llu)\n", + static_cast(lines.size()), + static_cast(hashes.size()), + static_cast(firstDiff)); + result = "length_mismatch"; + } + exitCode = 1; + } + } + } + } + + // The summary (stderr — stdout carries only the hash lines). + std::fprintf(stderr, "laige-replay frames=%llu hash_lines=%llu " + "result=%s\n", + static_cast(frameCount), + static_cast(hashes.size()), result.c_str()); + + // The ordered shutdown (CONC-006). + engine.shutdown(); + return exitCode; +}