From 817ebe9f40827dfd80f6ab5082b37a92119a20cb Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 16 Sep 2026 15:53:42 +0200 Subject: [PATCH 1/2] [M1-DET-02] Replay recorder (versioned log format, ReplayRecorder, engine + CLI wiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1-DET-02 (roadmap/M1-heartbeat.md, FR-1.4/FR-11.3, PRD Appendix A, ADR 0002, ARCH-007, SCALE-005): the replay RECORDING half (replay execution — world.state_hash + the laige-replay runner — is M1-DET-03). - src/laige-sim/include/laige/sim/replay.h + replay.cpp: the versioned replay log format (v1, magic LGRP, little-endian; header = format version + the ADR 0002 replay identity — seed, tick rate, component schema hash, math backend id, config hash — + per-tick frames as opaque byte blobs + the trailer frameCount + FNV-1a fileHash), the ReplayRecorder (opt-in, atomic temp+rename, size-bounded, strict tick sequence, sticky failure), the parseReplay/loadReplay readers (every structural violation a MalformedInput — never a crash), and the identity hashes (word-stream FNV-1a 64, big-endian per u64 — the house convention). - src/laige-sim/engine.{h,cpp}: Engine::startReplayRecording (once, after all registration, before the run; debug builds only — NDEBUG rejects with a record_disabled warn), one zero-length frame per completed tick in the loop's onTick hook (M1: no input system yet), a mid-run recording failure STOPS the run (run_headless returns the Status, no partial log), finalization in the successful run (record_finished), abandonment in the ordered shutdown (record_aborted); replayRecordingActive/replayBytesWritten accessors. - tools/run/laige-run.cpp: --replay wired (it was the M1-HEAD-01 stub): the run is recorded and the log atomically published on a clean run; debug builds only; failure exits 2 (start) / 1 (mid-run). - tools/fuzz: the replay_parse target (the parser's malformed-input surface, TEST-005/NFR-8.7) + the fuzz_replay_parse CTest entry (1000 deterministic runs; the corpus includes a valid v1 log); laige-fuzz now links laige-sim. - tests/laige-sim/replay_record_tests.cpp (22 tests, CTest entry replay_record, TSan property list): the round trip (record -> parse -> identical bytes, incl. PRNG payloads and the 1 MiB frame boundary), the full malformed table (every truncation cut, bad magic/version, length overrun, tick sequence, trailer count, fileHash, trailing garbage), the recorder contract (atomic publish, no partial file, the size limit at the exact boundary, sticky failure, move semantics), the identity hashes (registration-order stability, per-field sensitivity), and the engine integration (8-tick run -> 8 empty frames + matching identity; the failure stop with record_failed/record_aborted; double-start and stopped-engine failures). - CMake: replay.cpp into laige-sim; replay_record entry; the fuzz wiring. laige-api.json regenerated (630 symbols, 20 headers). - Docs (DOC-007, same change): new docs/api/replay.md (format spec, API, engine + CLI integration, Performance section); engine.md, concepts/determinism.md, README.md, compatibility/README.md (the replay log is the first persistent engine format), testing.md, tools/README.md, src/laige-sim/README.md updated. - Roadmap: M1-DET-02 box checked; progress board M1 15/25, total 35. Verify (local): ctest -R replay_record green (22/22); full ctest 57/57 on build (Debug GCC), build-asan (ASan+UBSan, leak-free), and build-tsan (TSan halt_on_error, incl. replay_record + fuzz_replay_parse); laige-fuzz replay_parse/json_parse 1000 runs each clean; tools/laige-include-lint OK; laige-api regenerated + api-real-tree green. --- docs/README.md | 25 +- docs/api/engine.md | 60 +- docs/api/replay.md | 305 ++++++++ docs/compatibility/README.md | 8 +- docs/concepts/determinism.md | 14 +- docs/testing.md | 12 +- laige-api.json | 85 +- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 4 +- src/laige-sim/CMakeLists.txt | 9 +- src/laige-sim/README.md | 19 +- src/laige-sim/engine.cpp | 193 ++++- src/laige-sim/include/laige/sim/engine.h | 98 +++ src/laige-sim/include/laige/sim/replay.h | 469 +++++++++++ src/laige-sim/replay.cpp | 578 ++++++++++++++ tests/laige-sim/CMakeLists.txt | 48 +- tests/laige-sim/replay_record_tests.cpp | 942 +++++++++++++++++++++++ tools/README.md | 16 +- tools/fuzz/CMakeLists.txt | 13 +- tools/fuzz/laige-fuzz.cpp | 85 +- tools/run/laige-run.cpp | 56 +- 21 files changed, 2940 insertions(+), 101 deletions(-) create mode 100644 docs/api/replay.md create mode 100644 src/laige-sim/include/laige/sim/replay.h create mode 100644 src/laige-sim/replay.cpp create mode 100644 tests/laige-sim/replay_record_tests.cpp diff --git a/docs/README.md b/docs/README.md index bbcb6ed..f49518f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,11 @@ state; M1-HEAD-01: the headless engine run — `Engine` (config → world → systems → loop) and the `laige-run` binary; M1-DET-01: deterministic mode — the SimMath-only sim guarantee (the G-R8 compile-time trait + the CI source scan), the per-system -PRNG substreams, and the `seed`/`determinism` config keys). +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`). 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. @@ -100,6 +104,12 @@ 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`). - [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, @@ -156,8 +166,9 @@ still to land. - [Compatibility](compatibility/README.md) — P0 platforms and compilers (PRD §6; the CI matrix), the current machine-readable - formats (`budgets.json`, `laige-api.json`, `deps.lock`), and the - migration-guide status (none yet — pre-1.0, no breaking changes). + formats (`budgets.json`, `laige-api.json`, `deps.lock`, and the + version 1 replay log format, M1-DET-02), and the migration-guide + status (none yet — pre-1.0, no breaking changes). ## Testing @@ -179,8 +190,9 @@ still to land. foundations land in M1, render observability in M2). - `benchmarks/baselines/` — no measured baselines yet; the first lands with M0-EXIT-01. -- `compatibility/` — no persistent data formats or migration guides yet - (they land with M1 replay and M6/M7 networking). +- `compatibility/` — no **migration guides** yet (they land with the + first breaking public-API change); the version 1 replay log format + has landed with M1-DET-02 (see [compatibility/README.md](compatibility/README.md)). - Per-module API docs for the remaining M1+ modules (`laige-render`, `laige-assets`, `laige-net`, `laige-server`, `laige-script`, `laige-editor`) — they land with their modules. (`laige-sim` has @@ -196,7 +208,8 @@ still to land. [game_loop.md](api/game_loop.md), [presentation.md](api/presentation.md), [engine.md](api/engine.md), - [determinism.md](api/determinism.md).) + [determinism.md](api/determinism.md), + [replay.md](api/replay.md).) ## Related diff --git a/docs/api/engine.md b/docs/api/engine.md index 20e06c7..d95351d 100644 --- a/docs/api/engine.md +++ b/docs/api/engine.md @@ -213,8 +213,44 @@ two consecutive runs; seed divergence). The full scope statement — what is deterministic, the per-backend scopes, what is not yet — is [concepts/determinism.md](../concepts/determinism.md). Cross-build/platform determinism is **M1-DET-04** (the detcheck -matrix); replay execution and state hashing of full input streams is -**M1-DET-02** (the `--replay` flag is its stub today). +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**. + +## Replay recording (M1-DET-02) + +The engine records replays **opt-in** (see +[api/replay.md](replay.md) for the full format and recorder contract): + +```cpp +Status Engine::startReplayRecording(std::string_view path, + std::uint64_t maxBytes) noexcept; +bool Engine::replayRecordingActive() const noexcept; +std::uint64_t Engine::replayBytesWritten() const noexcept; +``` + +- **When** — once, **after all component/system registration, before + `run_headless`**: the replay identity (seed, tick rate, component + schema hash, math backend id, config hash — ADR 0002) is captured + from the live world + config at call time. +- **What** — one zero-length frame per **completed** tick (M1: no + input system yet; the frame bytes are the future input blob, + M3-INPUT-03), written to `path + ".tmp"` and published at `path` + only on a successful bounded run (atomic temp+rename). +- **Failures** — a recording failure mid-run **stops the run**: + `run_headless` returns the recorder's `Status` (no partial log at + the final path); the ordered shutdown still runs. A cap below + header+trailer (56) or a frame over 1 MiB is an `InvalidArgument` at + the call/write; a total-size breach is `BudgetExhausted`. +- **Debug builds only** — `NDEBUG` makes the call an `InvalidArgument` + with a `replay/record_disabled` warn. +- **Cost** — disabled: one null check per tick; enabled: one bounded + stdio write per completed tick (the explicit, opt-in cost — + PERF-002/003). +- **Structured events** (subsystem `replay`): `record_started`, + `record_finished`, `record_failed`, `record_aborted`, + `record_already_started`, `record_start_failed`, `record_disabled`. ## `laige-run` (the CLI) @@ -226,9 +262,13 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG] 1 MiB max; over-bound → `MalformedInput`; read error → `IoError`). - `--ticks N` — the bounded run target (decimal digits only; default 0 = the server form). -- `--replay LOG` — **stubbed** for M1-DET-02: accepted, ignored, and - announced with one `replay/replay_deferred` warn (the flag is - reserved so game-tool scripts can be written now). +- `--replay LOG` — **records the run** (M1-DET-02; it was the + M1-HEAD-01 stub): opt-in, **debug builds only** (release builds + reject it with `InvalidArgument` + a `replay/record_disabled` + warn), default size cap 128 MiB, atomic publish at `LOG` on a + clean run. A start or mid-run recording failure exits `2` (start) + or `1` (mid-run — the `status=` line carries the error name) with + no partial log at `LOG`. - `--help` / `-h` — usage, exit 0. **Exit codes:** `0` = the run completed; `1` = the engine run failed @@ -285,6 +325,12 @@ double-shutdown idempotency the step verifies — and exits. - **The config is validated at `create`, not at run** — a hand-built `EngineConfig` bypassing the JSON path is still validated (same codes), so there is no unvalidated path. +- **Start replay recording after all registration, before the + run, and only in debug builds** — the identity is captured at call + time (a later registration makes the recorded schema hash stale), + a second start fails `InvalidArgument`, and `NDEBUG` builds reject + the call by contract (see the Replay recording section above and + [api/replay.md](replay.md)). ## Testing and CI @@ -298,6 +344,10 @@ double-shutdown idempotency the step verifies — and exits. 10 000 slots): must exit 0 and print `status=ok` on every P0 OS job; TIMEOUT 300 s (≈16.7 s nominal); the TSan job sets `TSAN_OPTIONS=halt_on_error=1`. +- `ctest -R replay_record` — the M1-DET-02 replay suite (the format + round trip, the malformed-input table, the recorder contract, the + identity hashes, the engine's per-tick recording + failure stop); + `ctest -R fuzz_replay_parse` covers the parser's fuzz surface. - The include-graph lint (`tools/laige-include-lint`) guarantees the headless path carries no GPU/window symbols (ARCH-003): `laige-run` links only `laige-sim` → `laige-core`. diff --git a/docs/api/replay.md b/docs/api/replay.md new file mode 100644 index 0000000..1b3e7f3 --- /dev/null +++ b/docs/api/replay.md @@ -0,0 +1,305 @@ +# 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. + +Public header: `src/laige-sim/include/laige/sim/replay.h` (the full +contract: format layout, identity hashes, the error tables, the +performance notes); implementation: `src/laige-sim/replay.cpp`. +Engine wiring: `src/laige-sim/engine.cpp` (`Engine::startReplayRecording`, +the per-tick write in the loop's `onTick` hook, finalization in +`run_headless`, abandonment in `shutdown`). CLI: `tools/run/laige-run.cpp` +(the `--replay` flag). Unit suite: `ctest -R replay_record` +(`tests/laige-sim/replay_record_tests.cpp`); fuzz: `ctest -R +fuzz_replay_parse` (the parser's malformed-input surface, TEST-005). + +```cpp +laige::EngineConfig config; +config.tickRateHz = 60; +config.seed = 42; +laige::Engine engine = laige::Engine::create(config).value(); + +// Game code registers on the world (as always, before the run)... +engine.world()->registerComponent(); +engine.world()->registerSystem(MySystem_Def); + +// ...then opts into replay recording, after all registration: +const laige::Status rec = + engine.startReplayRecording("/tmp/run.log", + laige::kDefaultReplaySizeLimit); +if (rec.isError()) { /* actionable: rec.error() + rec.errorText() */ } + +const laige::Status status = engine.run_headless(10'000); +// The log appears at "/tmp/run.log" only after the run completes +// successfully (atomic temp+rename); on a failed run there is no +// partial log at the final path. +``` + +## The log format (version 1, SCALE-005) + +The format is **versioned** (ARCH-007): a reader rejects unsupported +versions explicitly, and the header's `formatVersion` is the single +version gate. All integers are **little-endian** on every platform +(SCALE-005: byte order specified, not assumed). Layout: + +``` +header (kReplayHeaderSize = 40 bytes, fixed): + offset 0 magic "LGRP" 4 bytes + offset 4 formatVersion u16 (= kReplayFormatVersion = 1) + offset 6 reserved u16 (must be 0) + offset 8 seed u64 (the run's master seed) + offset 16 tickRateHz u32 + offset 20 componentSchemaHash u64 (ADR 0002 replay identity) + offset 28 mathBackendId u32 (the SimMathBackend value) + offset 32 configHash u64 (the EngineConfig encoding) +frame records (n of them, n == the trailer's frameCount): + offset 0 tick u64 (1, 2, 3, ... — strictly + sequential from 1) + offset 8 byteLength u32 (<= kMaxReplayFrameBytes = 1 MiB) + offset 12 data byteLength bytes (the input frame; + M1 frames are zero-length — + the input data shape lands + with M3-INPUT-03) +trailer (kReplayTrailerSize = 16 bytes, fixed, last in the file): + offset 0 frameCount u64 + offset 8 fileHash u64 (FNV-1a 64, byte-stream, + over every byte before the + trailer) +``` + +The header's five identity fields are the **replay identity** of ADR +0002: seed, tick rate, component schema hash, math backend id, and +config hash. A log is only replayable by a run whose identity matches +(exact equality of all five) — the runner's check lands with +M1-DET-03. The `fileHash` is the canonical byte-stream FNV-1a 64 +(offset basis `0xcbf29ce484222325`, prime `0x100000001b3` — fnv.org) +over header + frames: it catches truncation and bit rot, and makes the +log self-verifying. + +**Malformed-input behavior** (SCALE-005: the parser's malformed-input +behavior is specified — every violation below is a `MalformedInput` +`Status`, never a crash and never a silent skip, CORE-008/ARCH-007): + +| violation | result | +|---|---| +| size < header, null data with size > 0 | `MalformedInput` | +| bad magic | `MalformedInput` | +| unsupported `formatVersion` | `MalformedInput` (ARCH-007: explicit reject) | +| non-zero reserved field | `MalformedInput` | +| frame length > `kMaxReplayFrameBytes` | `MalformedInput` (checked before any overrun read) | +| frame record/payload extends past the body | `MalformedInput` | +| tick not `previous + 1` (first must be 1) | `MalformedInput` | +| trailer frameCount ≠ frames parsed | `MalformedInput` | +| trailer fileHash ≠ computed hash | `MalformedInput` | +| trailing bytes past the trailer | `MalformedInput` | + +## The identity hashes (ADR 0002) + +Both identity hashes are **word-stream FNV-1a 64, big-endian byte order +per u64 word** (the house convention: the determinism state hashes, the +Prng golden vectors, `laige-detcheck`) — pure integers, no addresses, +no wall clock (ARCH-010): + +- **`componentSchemaHash(const World&)`** — over + `[componentCount, then per registered type in id order: id, size, + alignment]`. It is a function of the *registration order* (ids are + dense in registration order): two worlds that registered the same + types in the same order hash identically; a different order hashes + differently. O(types), stack-only (769 words max — no allocation). +- **`configHash(const EngineConfig&)`** — over + `[tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, + determinism.enabled, determinism.math]`. The tag word identifies this + provisional encoding; M1-CFG-01 refines the config schema and this + encoding with it, under the format's versioning. +- **`makeReplayIdentity(const World&, const EngineConfig&)`** — + assembles the header's `ReplayIdentity` from the two hashes plus the + config's seed/tick rate/backend. + +These are the *replay identity*, not the sim state: M1-DET-03's +`world.state_hash` hashes the live sim state (components, PRNG state, +tick) on top of the identity the header already carries. + +## The recorder (`ReplayRecorder`) + +Write side, move-only: `create(identity, path, maxBytes)` → +`writeFrame(tick, data, len)` per completed tick → `finish()`. + +- **Atomic** — the recorder writes `path + ".tmp"` (same filesystem as + `path`, so the final `rename` is atomic) and publishes `path` only + on a successful `finish()`. An interrupted or failed recorder leaves + **no file at the final path** (the temp is removed by the + destructor); a rename failure leaves the temp for inspection (the + complete data is in it — the caller's Error log names it). +- **Size-bounded** — `maxBytes` bounds header + frames + trailer + together (`0` means `kDefaultReplaySizeLimit`, 128 MiB). A write that + would exceed the cap is a `BudgetExhausted` `Status` (CORE-008: no + unbounded growth, PERF-008/SCALE-003); a cap below + `kMinReplaySizeLimit` (header + trailer = 56) is rejected at + `create` as `InvalidArgument` (a complete log could never fit). + `finish()` on a cap that cannot fit the trailer is the same + `BudgetExhausted`. +- **Strict tick sequence** — frames must arrive as tick 1, 2, 3, ... + (the engine's `onTick` hook hands the recorder the completed tick + count); any other tick is an `InvalidArgument`. +- **Sticky failure** — the first failure sets the recorder's state; + every later `writeFrame`/`finish` returns the same `Status` (no + partial recovery, no second failure of a different code). +- **Error table** — empty path / cap below minimum / bad tick / + frame over `kMaxReplayFrameBytes` → `InvalidArgument`; cap breach → + `BudgetExhausted`; open/write/rename I/O → `IoError`. + +## The readers (`parseReplay`, `loadReplay`) + +- **`parseReplay(const uint8_t*, size)`** — the in-memory parser; the + full malformed-input table above. O(size), one output allocation per + frame (the `ReplayLog`'s frames); no per-byte allocation. +- **`loadReplay(path, maxBytes = kDefaultReplaySizeLimit)`** — the file + wrapper: a bounded read (the ADR 0003 JSON-bound precedent — a file + larger than `maxBytes` is a `MalformedInput`, not a truncated + parse), then `parseReplay`. Missing/unreadable file → `IoError`; + empty path → `MalformedInput`. + +`ReplayLog` is the parsed value: `identity` (the header's five fields) +plus `frames` (`tick` + the payload bytes). The frame payload is an +**opaque byte blob** by design — M1 records zero-length frames (there +is no input system yet); the input data shape lands with M3-INPUT-03, +and the format version is the migration point (ARCH-007). + +## Engine integration (`Engine::startReplayRecording`) + +``` +Status Engine::startReplayRecording(std::string_view path, + std::uint64_t maxBytes) noexcept; +bool Engine::replayRecordingActive() const noexcept; +std::uint64_t Engine::replayBytesWritten() const noexcept; +``` + +- **Opt-in** — recording is off by default; the disabled cost is one + null check per tick (LOG-003/PERF-003: no formatting, no + allocation, no I/O when off). +- **Phase** — call it **once, after all component/system + registration, before `run_headless`**: the identity is captured from + the live world + config at call time, so a registration after the + call makes the recorded identity stale (the schedule-stale + precedent, M1-SYS-02). +- **Per tick** — the engine's loop `onTick` hook writes one + zero-length frame per completed tick (M1: no input yet; the frame + bytes are the future input blob). +- **Failure stops the run** — a recorder failure mid-run is not + swallowed: `run_headless` returns the recorder's `Status` (at most + one frame of extra ticks, the loop's bounded frame contract), the + log is **not** published (no partial file at the final path), and + the ordered shutdown still runs. +- **Finalization** — on a successful bounded run, `run_headless` + calls `finish()` (trailer + atomic rename) and logs + `replay/record_finished` (Info: path, bytes, frames); a failed run's + shutdown logs `replay/record_aborted` (Warn: path, bytes) and + discards the temp. +- **Debug builds only** — in release builds (`NDEBUG`) the call is an + `InvalidArgument` with a `replay/record_disabled` warn (recording + is a development tool; the format and the engine wiring exist in + every build, the opt-in does not). +- **Structured events** (AGENTS §14, subsystem `replay`): + `record_started` (Info: path, size_limit, seed, math_backend, + config_hash, schema_hash), `record_finished` (Info), + `record_failed` (Error: path, tick, error), `record_aborted` + (Warn), `record_already_started` (Warn), `record_start_failed` + (Warn), `record_disabled` (Warn, release builds). + +## `laige-run --replay` (the CLI) + +``` +laige-run --headless CONFIG.json [--ticks N] [--replay LOG] +``` + +`--replay LOG` now records the run (it was the M1-HEAD-01 stub): + +- The engine's identity is captured at start (laige-run registers no + game components of its own — the built-in registration is complete at + `create`), the run records one zero-length frame per completed tick, + and on a clean bounded run the log is atomically published at `LOG`. +- **Debug builds only**: in a release build the flag fails with + `InvalidArgument` (`replay/record_disabled`), the same contract as + the engine call. +- **Failure is exit 2** (the flag's contract, like a config error): + `laige-run: replay: {error text}` on stderr, the run does not + happen. A mid-run recording failure exits `1` (the run failed — the + `status=` summary line carries the error name) with no partial log + at `LOG`. + +## Performance (PERF-002/003, LOG-003) + +- **Disabled** — one null check per tick in the engine's onTick hook; + no allocation, no logging, no I/O. +- **Enabled** — one bounded stdio write per completed tick (the 12-byte + record; M1 frames carry no payload), one FNV-1a extension over the + record bytes, one size check. The write is to a page-cache-backed + local file (PERF-002: the recording is an explicit, bounded, opt-in + cost — the API makes it visible, it is never hidden). `create` is + cold (one open + one 40-byte header write); `finish` is cold (one + 16-byte trailer write + one rename). +- **Bounds** — total log size ≤ `maxBytes` (128 MiB default); a single + frame ≤ 1 MiB; the reader's read is bounded by `maxBytes` before + parsing. +- **Traps** — recording a long run at the default cap publishes the + log only if it fits; a `BudgetExhausted` mid-run stops the run (by + design — a truncated log would be useless). + +## Misuse warnings + +- **Start recording after all registration, before the run** — the + identity is captured at call time; registering components after it + makes the recorded schema hash stale (the log would be rejected by + the M1-DET-03 runner's identity check). +- **One recording per run** — a second `startReplayRecording` on the + same engine is an `InvalidArgument` plus a `record_already_started` + warn. +- **Release builds cannot record** — `NDEBUG` is the contract + (debug-only tooling); use a debug build for capture. +- **Do not hand-write frame ticks** — `writeFrame` expects the strict + 1, 2, 3, ... sequence; a hand-built tick violates the format. +- **The cap is total, not per-frame** — header + all frames + trailer + must fit; size your cap against the expected tick count + (≈ `40 + 12 × ticks + 16` for M1 zero-length frames). + +## Testing and CI + +- `ctest -R replay_record` — the step's Verify: the format round trip + (record → parse → identical bytes, including the PRNG-payload case + and the 1 MiB frame boundary), the full malformed-input table + (every truncation cut of a valid log, bad magic/version, length + overrun, tick sequence, trailer count, fileHash, trailing garbage), + the recorder contract (atomic publish, no partial file on + interruption, the size limit at the exact boundary, sticky failure, + move semantics), the identity hashes (registration-order stability, + per-field sensitivity), and the engine integration (8-tick run → 8 + empty frames + matching identity; the mid-run failure stop with the + `record_failed`/`record_aborted` events; double-start and + stopped-engine failures). +- `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). + +## Related + +- [api/engine.md](engine.md) — `Engine`, the run contract, the + `startReplayRecording` wiring, the `laige-run` CLI. +- [concepts/determinism.md](../concepts/determinism.md) — the + determinism scope, the replay identity (ADR 0002), the + SimMath-only rule. +- [api/determinism.md](determinism.md) — the G-R8 trait and the + `SimMathBackend` ids. +- [ADR 0002 — Deterministic math strategy](../decisions/0002-deterministic-math.md) + — replay identity: inputs + seed + backend + config. +- [testing.md](../testing.md) — the fuzz-runner and seed conventions + this suite follows. diff --git a/docs/compatibility/README.md b/docs/compatibility/README.md index 58783b5..d0b3570 100644 --- a/docs/compatibility/README.md +++ b/docs/compatibility/README.md @@ -25,15 +25,17 @@ Platforms, compilers, formats, and migration guides (AGENTS §13). ## Formats -No persistent, networked, or replay data formats exist yet (M0 is -foundations only; they arrive with M1 replay and M6/M7 networking). The -machine-readable files that do exist, each with its schema documented: +The first persistent engine format has landed with M1-DET-02 (the +version 1 replay log); networked formats arrive with M6/M7 +networking. The machine-readable files that exist, each with its +schema documented: | File | Schema | Document | |---|---|---| | `budgets.json` | version 1, strict validation (ARCH-007) | [api/budget_harness.md](../api/budget_harness.md) ("budgets.json schema") | | `laige-api.json` | manifest version 1, deterministic (byte-identical regeneration is the CI drift check) | the `tools/api/laige-api.cpp` header (M0-TOOL-01) | | `deps.lock` | one entry per vendored dependency, integrity-checked at configure time | [ADR 0004](../decisions/0004-google-test-vendoring.md) | +| replay logs (`*.log`, written by the `ReplayRecorder`) | version 1 (magic `LGRP`, `formatVersion` u16), little-endian, strict validation: unsupported version / truncation / hash mismatch → `MalformedInput` (ARCH-007, SCALE-005) | [api/replay.md](../api/replay.md) ("The log format") | When a persistent format lands it MUST ship versioned, with a reader that rejects or migrates unsupported data explicitly (ARCH-007) and a format diff --git a/docs/concepts/determinism.md b/docs/concepts/determinism.md index 3fe0418..12a5e5a 100644 --- a/docs/concepts/determinism.md +++ b/docs/concepts/determinism.md @@ -42,10 +42,15 @@ 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* (feeding a recorded input stream back through the - sim) is M1-DET-02; the `laige-run --replay` flag is a stub until then. +- 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 + `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; `laige::Prng` deliberately has no state getters. + M1-DET-03 (its state words join `world.state_hash`); + `laige::Prng` deliberately has no state getters. ## Why only SimMath ops (G-R8) @@ -227,5 +232,8 @@ struct EngineConfig { backend policies. - [api/engine.md](../api/engine.md) — the `seed`/`determinism` config keys and the backend selection. +- [api/replay.md](../api/replay.md) — the replay recording + (M1-DET-02): the versioned log format, the recorder, the replay + identity hashes. - [api/detcheck.md](../api/detcheck.md) — the replay-comparison tool (M1-DET-04 runs it against the two backends). diff --git a/docs/testing.md b/docs/testing.md index 909c63a..9907935 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -81,12 +81,17 @@ target may return any `Status`; the run fails only on process death 1000 runs per target — the PRD §14 "every commit (bounded)" lane. In the ASan tree (`build-asan`) the runs are instrumented, so a crash or UB fails the job loudly (NFR-8.7). +- **Targets so far:** `json_parse` (the JSON parser, M0-CORE-07) and + `replay_parse` (the replay log parser, M1-DET-02 — the + malformed-input surface of the version 1 replay format; the corpus + includes a valid v1 log as a mutate/truncate base). Both run the + bounded lane above in every P0 job. - **Nightly long runs (PRD §14 "nightly (long)"):** the canonical form is `./build/bin/laige-fuzz --runs=1000000 [--seed=HEX]`. The scheduled nightly lane is documented here but not yet wired: it lands - with the first M1 fuzz target (asset import / network packets, PRD §14 - fuzz row), when a long run protects more than the parser. Until then - the bounded lane above is the complete fuzz cadence in M0. + with the first M1 fuzz target whose long run protects more than the + parser (asset import / network packets, PRD §14 fuzz row). Until then + the bounded lane above is the complete fuzz cadence in M1. - **Seed handling:** fixed default seed `0x1F055EED` ("one-fuzz-seed"), overridable with `--seed=` (0x-prefixed hex or decimal). This is the same default seed the test suites use (below) — one documented @@ -175,6 +180,7 @@ promised) at the scope ARCH-010 requires. |---|---| | Seeded-random KAT suite | `ctest --test-dir build -R test_infra --output-on-failure` | | Bounded fuzz, `json_parse` | `./build/bin/laige-fuzz json_parse --runs=1000` | +| Bounded fuzz, `replay_parse` | `./build/bin/laige-fuzz replay_parse --runs=1000` | | Fuzz with an explicit seed | `./build/bin/laige-fuzz json_parse --runs=1000 --seed=0x12345678` | | Nightly long run (documented form) | `./build/bin/laige-fuzz json_parse --runs=1000000` | | Reproduce a randomized test's stream | `LAIGE_TEST_SEED=0x… ctest --test-dir build --output-on-failure` | diff --git a/laige-api.json b/laige-api.json index 9f8c056..2ed3280 100644 --- a/laige-api.json +++ b/laige-api.json @@ -20,6 +20,7 @@ "src/laige-sim/include/laige/sim/game_loop.h", "src/laige-sim/include/laige/sim/presentation.h", "src/laige-sim/include/laige/sim/query.h", + "src/laige-sim/include/laige/sim/replay.h", "src/laige-sim/include/laige/sim/system.h" ], "symbols": [ @@ -434,27 +435,30 @@ {"name": "laige::DeterminismConfig::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 186, "signature": "bool enabled{true}", "summary": "Deterministic mode on/off (S-7: deterministic by default). M1 semantics in the header preamble \"Determinism mode semantics\".", "budget": null, "experimental": false}, {"name": "laige::DeterminismConfig::math", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 188, "signature": "SimMathBackend math{SimMathBackend::FixedPoint16_16}", "summary": "The SimMath backend the deterministic run uses (ADR 0002).", "budget": null, "experimental": false}, {"name": "LAIGE_DETERMINISM_SAFE", "kind": "macro", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 299, "signature": "#define LAIGE_DETERMINISM_SAFE(Type, ...)", "summary": "Mark Type as a determinism-safe storage/component type (G-R8, S-7): declare that Type's members are exactly the listed member types (every member; order is irrelevant — the list is a set of types). The mark specializes the trait with the verified member list:", "budget": null, "experimental": false}, - {"name": "laige::kDefaultSimulationSeed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 260, "signature": "inline constexpr std::uint64_t kDefaultSimulationSeed = 0", "summary": "The default master simulation seed (M1-DET-01; CORE-005). 0 is a valid master seed: the Prng's state is nonzero for every 64-bit seed (the xorshift128+ state transform — laige/prng.h), so no special invalid seed is needed.", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 266, "signature": "struct EngineConfig", "summary": "The typed headless-engine configuration (M1-HEAD-01; the provisional config surface — see the header preamble \"The config surface\"). A plain value: the engine copies it into the EngineConfig echo read back through config().", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 269, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20-120 validated at Engine::create; default kDefaultTickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 274, "signature": "std::uint32_t entityCapacity{0}", "summary": "The declared scene budget (G-R3): the World's entity capacity. 0 = an empty scene (a valid world that creates no entities — entity creation on it fails with BudgetExhausted; the game declares its budget, the engine does not guess one).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 277, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget (0 disables the guardrail; default kDefaultChurnPerFrameBudget).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 287, "signature": "std::uint64_t seed{kDefaultSimulationSeed}", "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 kDefaultSimulationSeed (0 — a valid master seed: the Prng's state is nonzero for every 64-bit seed, prng.h). The programmatic path accepts the full 64 bits; the JSON config path is bounded to exact integers in 0..2^53 (the ADR 0003 number policy — parseEngineConfig's documented limit).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::determinism", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 293, "signature": "DeterminismConfig determinism{}", "summary": "The determinism block (M1-DET-01; ADR 0002): the mode flag and the selected SimMath backend. See the header preamble \"The config surface\" for the JSON keys and \"Built-in components and the determinism scope\" for the semantics; the full promised scope is docs/concepts/determinism.md.", "budget": null, "experimental": false}, - {"name": "laige::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 330, "signature": "[[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept", "summary": "Load the headless-engine configuration from a parsed JSON document (the provisional M1-HEAD-01 config surface; M1-CFG-01 owns the full declarative schema — see the header preamble for the keys, the defaults, and the rejection table). The document must be a top-level object; every accepted key is optional (defaults above).", "budget": "O(document keys); cold path, warn fields allocate only when a key is rejected.", "experimental": false}, - {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 438, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, - {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 456, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget, seed, determinism mode), and register the built-in component matching the configured SimMath backend (Position2DFpx16 default, Position2DFp32 for float_pinned_32 — M1-DET-01; the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, - {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 462, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 466, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 492, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, - {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 500, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, - {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 504, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 510, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 515, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 516, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 517, "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": 518, "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": 522, "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::kDefaultSimulationSeed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 308, "signature": "inline constexpr std::uint64_t kDefaultSimulationSeed = 0", "summary": "The default master simulation seed (M1-DET-01; CORE-005). 0 is a valid master seed: the Prng's state is nonzero for every 64-bit seed (the xorshift128+ state transform — laige/prng.h), so no special invalid seed is needed.", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 314, "signature": "struct EngineConfig", "summary": "The typed headless-engine configuration (M1-HEAD-01; the provisional config surface — see the header preamble \"The config surface\"). A plain value: the engine copies it into the EngineConfig echo read back through config().", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 317, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20-120 validated at Engine::create; default kDefaultTickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 322, "signature": "std::uint32_t entityCapacity{0}", "summary": "The declared scene budget (G-R3): the World's entity capacity. 0 = an empty scene (a valid world that creates no entities — entity creation on it fails with BudgetExhausted; the game declares its budget, the engine does not guess one).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 325, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget (0 disables the guardrail; default kDefaultChurnPerFrameBudget).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 335, "signature": "std::uint64_t seed{kDefaultSimulationSeed}", "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 kDefaultSimulationSeed (0 — a valid master seed: the Prng's state is nonzero for every 64-bit seed, prng.h). The programmatic path accepts the full 64 bits; the JSON config path is bounded to exact integers in 0..2^53 (the ADR 0003 number policy — parseEngineConfig's documented limit).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::determinism", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 341, "signature": "DeterminismConfig determinism{}", "summary": "The determinism block (M1-DET-01; ADR 0002): the mode flag and the selected SimMath backend. See the header preamble \"The config surface\" for the JSON keys and \"Built-in components and the determinism scope\" for the semantics; the full promised scope is docs/concepts/determinism.md.", "budget": null, "experimental": false}, + {"name": "laige::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 378, "signature": "[[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept", "summary": "Load the headless-engine configuration from a parsed JSON document (the provisional M1-HEAD-01 config surface; M1-CFG-01 owns the full declarative schema — see the header preamble for the keys, the defaults, and the rejection table). The document must be a top-level object; every accepted key is optional (defaults above).", "budget": "O(document keys); cold path, warn fields allocate only when a key is rejected.", "experimental": false}, + {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 486, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, + {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 504, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget, seed, determinism mode), and register the built-in component matching the configured SimMath backend (Position2DFpx16 default, Position2DFp32 for float_pinned_32 — M1-DET-01; the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, + {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 510, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 514, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 540, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, + {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 548, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, + {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 552, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 558, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, + {"name": "laige::Engine::startReplayRecording", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 593, "signature": "[[nodiscard]] Status startReplayRecording(std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Start the opt-in replay recording of the upcoming run (M1-DET-02; see the header preamble \"Replay recording\" and docs/api/replay.md for the full contract). DEBUG BUILDS ONLY: a release build rejects the call with InvalidArgument plus the structured replay/record_disabled warn (CORE-008: never silent).", "budget": "cold path: one file open + one 40-byte header write; no per-tick cost while the engine is not recording.", "experimental": false}, + {"name": "laige::Engine::replayRecordingActive", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 598, "signature": "[[nodiscard]] bool replayRecordingActive() const noexcept", "summary": "True while a replay recording is active (started and not yet finalized or abandoned). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::replayBytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 602, "signature": "[[nodiscard]] std::uint64_t replayBytesWritten() const noexcept", "summary": "The bytes the active recording has written (header + frame bytes; 0 when not recording). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 607, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 608, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"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}, @@ -576,6 +580,45 @@ {"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::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 ea21031..7c4ee6b 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -172,7 +172,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R determinism_mode` green; determinism doc published; trait-check compiles-fail test (compile-check test) passes; sim-TU source scan green in CI. - **Size:** ~250 lines + tests -- [ ] **M1-DET-02 · Replay recorder** +- [x] **M1-DET-02 · Replay recorder** - **Refs:** FR-1.4, FR-11.3; PRD Appendix A (replay = input log + seed) - **Depends:** M1-DET-01, M0-CORE-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index e6643be..69da44c 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 14 | 🚧 in progress (M1-DET-01) | +| M1 | 25 | 15 | 🚧 in progress (M1-DET-02) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **34** | | +| **Total** | **193** | **35** | | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 9715951..ac9fcec 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -53,9 +53,16 @@ # shutdown; the public types and contract live in # include/laige/sim/engine.h) plus the provisional JSON config surface # (parseEngineConfig — M1-CFG-01 owns the full declarative schema). +# M1-DET-02 adds replay.cpp: the replay recording (ReplayRecorder — +# the versioned log format, the atomic temp+rename writer, the +# parseReplay/loadReplay readers, and the replay-identity hashes; the +# public types and contract live in include/laige/sim/replay.h) plus +# 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). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp - engine.cpp) + engine.cpp replay.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 80a28bd..7a67648 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -113,6 +113,23 @@ trait API in [tests/laige-sim](../tests/laige-sim) (CTest entries `determinism_mode` + `trait_compile_*`; lint fixtures in [tests/tools](../tests/tools)). -The profiler, replay (M1-DET-02), PRNG state introspection +M1-DET-02 landed replay recording — the versioned replay log +format (header: format version + the ADR 0002 replay identity — +seed, tick rate, component schema hash, math backend id, config +hash — + per-tick frames as opaque byte blobs + the trailer's +FNV-1a fileHash; SCALE-005), the `ReplayRecorder` (opt-in, atomic +temp+rename, size-bounded), the `parseReplay`/`loadReplay` readers, +the `componentSchemaHash`/`configHash`/`makeReplayIdentity` +identity hashes (`include/laige/sim/replay.h`, `replay.cpp`), the +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 +[docs/api/replay.md](../docs/api/replay.md), tests under +[tests/laige-sim](../tests/laige-sim) (CTest entries +`replay_record` + `fuzz_replay_parse`). +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/engine.cpp b/src/laige-sim/engine.cpp index 2daf0f3..685c347 100644 --- a/src/laige-sim/engine.cpp +++ b/src/laige-sim/engine.cpp @@ -1,17 +1,20 @@ // laige-sim headless engine run (M1-HEAD-01; FR-1.6, ARCH-003, -// AC-6.2, CONC-006). +// AC-6.2, CONC-006) + the opt-in replay recording (M1-DET-02). // // Implementation of the Engine, EngineConfig, and parseEngineConfig // declared in include/laige/sim/engine.h — see that header for the // full contract (the lifecycle, the run contract, the ordered // shutdown, the provisional config surface, the determinism scope, -// the performance notes) and docs/api/engine.md for the API document -// and the laige-run CLI contract. +// the replay recording, the performance notes) and docs/api/engine.md +// for the API document and the laige-run CLI contract. // // Hot-path cost (per headless frame): one clock read, one bounded // GameLoop::frame() dispatch, one snapshot onRenderFrame, one sleep — // no allocation and no logging on the healthy path (PERF-003, // LOG-003; the per-frame breakdown in engine.h "Performance"). +// Replay recording (M1-DET-02, opt-in debug builds only) adds one +// bounded stdio write per completed tick when enabled, and one null +// check per completed tick when disabled. #include "laige/sim/engine.h" // the Engine contract (this header) @@ -38,6 +41,7 @@ inline constexpr std::int64_t kNanosecondsPerSecond = 1000000000LL; // The stable subsystem names (LOG-001). inline constexpr const char* kEngineSubsystem = "engine"; inline constexpr const char* kConfigSubsystem = "config"; +inline constexpr const char* kReplaySubsystem = "replay"; // The headless clock source (M1-LOOP-01): the monotonic steady_clock // as nanoseconds since its epoch (the windowed clock arrives with @@ -109,6 +113,45 @@ inline constexpr const char* kDeterminismMathInvalidMessage = "set determinism.math to one of the two backend ids | " "docs/api/engine.md"; +// M1-DET-02: the replay recording messages (NFR-13.3 5-field grammar; +// the dynamic values are structured fields, never message text). +#if defined(NDEBUG) +// The debug-only message lives only in release builds (the +// guardrails.cpp pattern: a debug-build-never-seen message must not +// trip -Wunused-const-variable in debug trees). +inline constexpr const char* kRecordDisabledMessage = + "record_disabled | replay recording is a debug-build feature | " + "this binary was built without debug support (NDEBUG defined — " + "release build) | rebuild with CMAKE_BUILD_TYPE=Debug, or remove " + "the --replay flag | docs/api/replay.md"; +#endif + +inline constexpr const char* kRecordAlreadyStartedMessage = + "record_already_started | startReplayRecording was called twice | " + "one engine records at most one replay per run | call " + "startReplayRecording once, after all registration and before " + "run_headless | docs/api/replay.md"; + +inline constexpr const char* kRecordStartFailedMessage = + "record_start_failed | the replay recorder could not be started | " + "the temporary file could not be created (disk full, bad path), or " + "the size limit is below kMinReplaySizeLimit | check the path and " + "disk space, or raise maxBytes | docs/api/replay.md"; + +inline constexpr const char* kRecordFailedMessage = + "record_failed | replay recording failed and the run stopped | the " + "total size limit was reached, or the log file could not be " + "written | raise the size limit or free disk space; the run " + "returned the failure Status and no partial log remains at the " + "final path | docs/api/replay.md"; + +inline constexpr const char* kRecordAbortedMessage = + "record_aborted | the replay recording ended without finalization " + "| the run failed before the log was finalized (or the engine was " + "shut down pre-run) | the temporary file was removed (no partial " + "replay on disk); re-run the scenario with recording | " + "docs/api/replay.md"; + // The JSON seed bound: the largest value a JSON number can hold // exactly (doubles are exact integers to 2^53 — ADR 0003). The // programmatic EngineConfig.seed has no such bound (full uint64). @@ -374,6 +417,24 @@ void Engine::onTickHookDispatch(std::uint64_t tick) noexcept { // snapshot is still a no-op, never a crash (the handle's // hasSnapshot() guard — the never-crash contract, CORE-008). if (snapshot_.hasSnapshot()) snapshot_.onTick(snapshot_.context, tick); + // M1-DET-02: one recorded input frame per completed tick (the hook + // fires with the completed tick count — game_loop.h). M1 frames are + // ZERO-LENGTH: no input system exists yet (M3-INPUT-03 defines the + // payload shape and source); the frame record still carries the + // tick and the length field, so the log is forward-ready. A write + // failure STOPS THE RUN: the sticky replayFail_ makes runFrames + // return it, and the engine's shutdown still happens (CONC-006). + if (replayRecorder_ != nullptr && !replayFail_.isError()) { + const Status writeStatus = replayRecorder_->writeFrame(tick, nullptr, 0); + if (writeStatus.isError()) { + replayFail_ = writeStatus; + LAIGE_LOG_ERROR(kReplaySubsystem, "record_failed", kRecordFailedMessage, + laige::log::field("path", replayRecorder_->path()), + laige::log::field("tick", tick), + laige::log::field("error", + laige::errorName(writeStatus.error()))); + } + } } // --------------------------------------------------------------------------- @@ -453,6 +514,35 @@ Status Engine::run_headless(std::uint64_t maxTicks, runStatus = loopResult.error(); } } + // M1-DET-02: a SUCCESSFUL run with an active recording finalizes + // the recorder (flush + trailer + atomic temp-to-final rename) + // before the shutdown — the log appears at the final path only + // then. A mid-run recording failure (replayFail_) or a failed frame + // skips the finalization: the recorder's destructor (in shutdown) + // removes the temp file, and no partial log is ever left at the + // final path (replay.h "Recorder contract"). + if (replayRecorder_ != nullptr && !replayFail_.isError() && runStatus.ok()) { + const Status finishStatus = replayRecorder_->finish(); + if (finishStatus.ok()) { + LAIGE_LOG_INFO(kReplaySubsystem, "record_finished", + "Replay log written (atomic temp + rename)", + laige::log::field("path", replayRecorder_->path()), + laige::log::field("bytes", + replayRecorder_->bytesWritten()), + laige::log::field("frames", + replayRecorder_->frameCount())); + } else { + // Finalization failure (rename error, or the cap leaves no room + // for the trailer): the run reports it — never silent + // (CORE-008). The rename-failure case leaves the temp file for + // inspection (replay.h documents that). + LAIGE_LOG_ERROR(kReplaySubsystem, "record_failed", kRecordFailedMessage, + laige::log::field("path", replayRecorder_->path()), + laige::log::field("error", + laige::errorName(finishStatus.error()))); + runStatus = finishStatus; + } + } // The loop's accounting BEFORE it is destroyed in shutdown (the // profiler feed; zeros when the loop never existed). lastStats_ = (loop_ != nullptr) ? loop_->stats() : GameLoopStats{}; @@ -479,10 +569,15 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { const std::int64_t startNs = loop_->startReferenceNs(); const std::int64_t rate = static_cast(loop_->tickRateHz()); for (;;) { + // M1-DET-02: a replay-recording failure stops the run (at most + // one frame's worth of ticks runs after the failing write — the + // hook's sticky error is observed here and at the frame boundary). + if (replayFail_.isError()) return replayFail_; if (maxTicks != 0 && loop_->currentTick() >= maxTicks) break; const std::int64_t now = steadyNowNs(); const Status frameStatus = loop_->frame(); if (frameStatus.isError()) return frameStatus; + if (replayFail_.isError()) return replayFail_; // The frame's clock reading goes to the presentation state // (presentation.h wiring: the engine reads the frame clock once // per frame and passes it to the snapshot). The snapshot exists @@ -526,6 +621,20 @@ void Engine::shutdown() noexcept { // 1. systems: the loop stops first — no frame can start after this // point (the system phase is over). loop_.reset(); + // M1-DET-02: an UNFINISHED recording is abandoned here (CONC-006: + // every owned job is released in the ordered teardown). The + // recorder's destructor removes the temp file — no partial log is + // ever left at the final path. The structured warn makes the + // abandonment visible (CORE-008: never silent); the successful-run + // path finalized the recorder in run_headless, so this fires only + // for a failed run or a pre-run teardown. + if (replayRecorder_ != nullptr && !replayRecorder_->finished()) { + LAIGE_LOG_WARN(kReplaySubsystem, "record_aborted", kRecordAbortedMessage, + laige::log::field("path", replayRecorder_->path()), + laige::log::field("bytes", replayRecorder_->bytesWritten())); + } + replayRecorder_.reset(); + replayFail_ = Status{}; // 2. world: every live entity is destroyed (the per-entity // component data is released with its rows; the registries // survive — the entity.h clear() contract). @@ -557,6 +666,71 @@ bool Engine::isShutDown() const noexcept { return shutDown_; } GameLoopStats Engine::stats() const noexcept { return lastStats_; } +// --------------------------------------------------------------------------- +// Replay recording (M1-DET-02; the contract in engine.h "Replay +// recording" and docs/api/replay.md) +// --------------------------------------------------------------------------- + +Status Engine::startReplayRecording(std::string_view path, + std::uint64_t maxBytes) noexcept { +#if defined(NDEBUG) + // Replay recording is a debug-build feature (M1-DET-02 scope): + // release builds reject it with a structured warn (CORE-008: never + // silent). The recorder itself is compiled out of this branch. + LAIGE_LOG_WARN(kReplaySubsystem, "record_disabled", kRecordDisabledMessage); + return Status(ErrorCode::InvalidArgument); +#else + // A stopped engine (shutdown or moved-from) is a no-op failure + // without logging (the stopped-state precedent — the GameLoop's + // moved-out frame()). + if (shutDown_ || world_ == nullptr) { + return Status(ErrorCode::InvalidArgument); + } + if (replayRecorder_ != nullptr) { + LAIGE_LOG_WARN(kReplaySubsystem, "record_already_started", + kRecordAlreadyStartedMessage); + return Status(ErrorCode::InvalidArgument); + } + // The replay identity (ADR 0002) is captured at recording start: + // the component registry must be complete by now (the caller's + // registration phase — engine.h "Replay recording"). + const ReplayIdentity identity = makeReplayIdentity(*world_, config_); + Result created = + ReplayRecorder::create(identity, path, maxBytes); + if (created.isError()) { + LAIGE_LOG_WARN(kReplaySubsystem, "record_start_failed", + kRecordStartFailedMessage, + laige::log::field("path", path), + laige::log::field("error", + laige::errorName(created.error()))); + return Status(created.error()); + } + replayRecorder_ = + std::make_unique(std::move(created).takeValue()); + LAIGE_LOG_INFO(kReplaySubsystem, "record_started", + "Replay recording started (opt-in, M1-DET-02)", + laige::log::field("path", path), + laige::log::field("size_limit", + maxBytes == 0 ? kDefaultReplaySizeLimit + : maxBytes), + laige::log::field("seed", config_.seed), + laige::log::field("math_backend", + static_cast(config_.determinism.math)), + laige::log::field("config_hash", identity.configHash), + laige::log::field("schema_hash", + identity.componentSchemaHash)); + return Status{}; +#endif +} + +bool Engine::replayRecordingActive() const noexcept { + return replayRecorder_ != nullptr && !replayRecorder_->finished(); +} + +std::uint64_t Engine::replayBytesWritten() const noexcept { + return replayRecorder_ != nullptr ? replayRecorder_->bytesWritten() : 0; +} + Engine::Engine(Engine&& other) noexcept : world_(std::move(other.world_)), loop_(std::move(other.loop_)), @@ -564,16 +738,23 @@ Engine::Engine(Engine&& other) noexcept schedule_(other.schedule_), config_(other.config_), lastStats_(other.lastStats_), + replayRecorder_(std::move(other.replayRecorder_)), + replayFail_(other.replayFail_), shutDown_(other.shutDown_) { // The source becomes a STOPPED engine (the GameLoop moved-out - // precedent): nothing left to release, nothing to flush. + // precedent): nothing left to release, nothing to flush. Its + // recording (if any) is TRANSFERRED, not abandoned — the world it + // recorded is the same moved world (the recorder's identity still + // describes it). other.shutDown_ = true; } Engine& Engine::operator=(Engine&& other) noexcept { if (this != &other) { // Release this engine's current state first (ordered; a no-op - // when already stopped). + // when already stopped). An active recording on THIS engine is + // abandoned by that shutdown (the structured record_aborted warn) + // before it is replaced. shutdown(); world_ = std::move(other.world_); loop_ = std::move(other.loop_); @@ -581,6 +762,8 @@ Engine& Engine::operator=(Engine&& other) noexcept { schedule_ = other.schedule_; config_ = other.config_; lastStats_ = other.lastStats_; + replayRecorder_ = std::move(other.replayRecorder_); + replayFail_ = other.replayFail_; shutDown_ = other.shutDown_; other.shutDown_ = true; } diff --git a/src/laige-sim/include/laige/sim/engine.h b/src/laige-sim/include/laige/sim/engine.h index f77dfbe..0b96921 100644 --- a/src/laige-sim/include/laige/sim/engine.h +++ b/src/laige-sim/include/laige/sim/engine.h @@ -185,6 +185,39 @@ // enter authoritative state. // // --------------------------------------------------------------------------- +// Replay recording (M1-DET-02; laige/sim/replay.h) +// --------------------------------------------------------------------------- +// +// Opt-in, DEBUG-BUILDS-ONLY recording of the run's replay log (FR-1.4, +// PRD Appendix A: replay = the input log + the seed). The engine owns +// at most one ReplayRecorder per run: +// +// startReplayRecording(path, maxBytes) called after all +// component/system registration (the component schema hash is +// part of the replay identity — replay.h) and before +// run_headless. Captures the identity (ADR 0002) from +// (world, config) and opens the recorder's temp file. +// +// run_headless one recorded input frame per COMPLETED tick, +// written from the GameLoop's onTick hook (game_loop.h): M1 +// frames are zero-length (no input system exists yet — M3-INPUT-03 +// defines the payload shape and source). A recording failure +// (size limit reached, write error) STOPS THE RUN: run_headless +// returns the failure Status, and the ordered shutdown still +// happens (CONC-006). +// +// a successful run finalizes the recorder (flush + trailer + +// atomic temp-to-final rename) before the shutdown; the log +// appears at `path` only then — a failed run or a pre-run +// teardown leaves NO partial log at the final path (the temp +// file is removed; the engine emits replay/record_aborted). +// +// The engine emits the structured replay/* events (LOG-001/002): +// record_started / record_finished (Info), record_failed (Error), +// record_aborted and record_already_started (Warn), record_disabled +// (Warn — release builds only). The recorder itself logs nothing. +// +// --------------------------------------------------------------------------- // Ownership, threading // --------------------------------------------------------------------------- // @@ -206,6 +239,13 @@ // up to frameBudgetTicks system dispatches — PERF-002 bounded), one // snapshot onRenderFrame (a few integer ops), and one sleep. No // allocation and no logging on the healthy path (PERF-003, LOG-003). +// Replay recording (M1-DET-02, opt-in debug feature): the DISABLED +// path pays one null check per completed tick and nothing else +// (DBG-004); the ENABLED path adds one bounded stdio write per +// completed tick (the 12-byte frame record into stdio's 8 KiB buffer +// — a flush to the OS only every ~680 zero-length frames) and the +// cold finish (flush + trailer + rename). Recording is never on the +// default run path. // The run's setup path allocates exactly three times, all one-shot // (verified per-frame-zero by the M1-HEAD-01 zero-allocation test): // the GameLoop object, the PresentationSnapshot object, and the @@ -235,11 +275,18 @@ // deterministic contract. // - run_headless after shutdown (or on a moved-from engine) is a // no-op failure: recreate the engine, do not reuse it. +// - Replay recording is opt-in and debug-builds-only: call +// startReplayRecording ONCE, after all component/system +// registration and before run_headless (the component schema hash +// in the identity is captured at recording start). A second call +// fails (replay/record_already_started); release builds reject +// the call entirely (replay/record_disabled). #pragma once #include #include +#include #include "laige/errors.h" #include "laige/fpx16_16.h" @@ -248,6 +295,7 @@ #include "laige/sim/entity.h" // World, kDefaultChurnPerFrameBudget #include "laige/sim/game_loop.h" // GameLoop, GameLoopStats, tick-rate constants #include "laige/sim/presentation.h" // Position2D, PresentationSnapshot +#include "laige/sim/replay.h" // ReplayRecorder (M1-DET-02) namespace laige { @@ -509,6 +557,50 @@ class Engine { // allocation, no side effects (the profiler feed, M1-PROF-01). [[nodiscard]] GameLoopStats stats() const noexcept; + // Start the opt-in replay recording of the upcoming run (M1-DET-02; + // see the header preamble "Replay recording" and docs/api/replay.md + // for the full contract). DEBUG BUILDS ONLY: a release build + // rejects the call with InvalidArgument plus the structured + // replay/record_disabled warn (CORE-008: never silent). + // + // Call after all component/system registration (the component + // schema hash is part of the identity, captured at recording + // start) and before run_headless. + // + // path == the log's FINAL path (the recorder writes atomically: + // a temp file `path + ".tmp"` + rename — the final path + // appears only on a successful finish) + // maxBytes == the total log cap, header + frames + trailer + // (0 = kDefaultReplaySizeLimit; below + // kMinReplaySizeLimit the cap cannot hold a complete + // log) + // + // stopped engine (already shut down) -> InvalidArgument (no log — + // the stopped-state + // precedent) + // already recording -> InvalidArgument + warn + // (replay/record_already_ + // started) + // recorder start failure -> the failure Status (the + // temp-open / header-write + // IoError) + warn (replay/ + // record_start_failed) + // + // A recording failure mid-run stops the run: run_headless returns + // the failure Status (BudgetExhausted at the size limit, IoError on + // a write failure) and no partial log remains at `path`. + // @budget cold path: one file open + one 40-byte header write; no per-tick cost while the engine is not recording. + [[nodiscard]] Status startReplayRecording(std::string_view path, + std::uint64_t maxBytes) noexcept; + + // True while a replay recording is active (started and not yet + // finalized or abandoned). O(1), no side effects. + [[nodiscard]] bool replayRecordingActive() const noexcept; + + // The bytes the active recording has written (header + frame + // bytes; 0 when not recording). O(1), no side effects. + [[nodiscard]] std::uint64_t replayBytesWritten() const noexcept; + // Move transfers the owned state; the source becomes a STOPPED // engine (world() nullptr, run_headless fails, shutdown is a no-op // — the GameLoop moved-out precedent). @@ -555,6 +647,12 @@ class Engine { // The last run's loop accounting (set on every run completion, // including a failed one — before the loop is destroyed). GameLoopStats lastStats_{}; + // Replay recording (M1-DET-02): the active recorder (nullptr when + // not recording — the default path pays one null check per + // completed tick and nothing else) and the sticky failure that + // stopped the run (ok while nothing failed). + std::unique_ptr replayRecorder_; + Status replayFail_{}; // True after shutdown() has run (or on a moved-from engine). bool shutDown_{false}; }; diff --git a/src/laige-sim/include/laige/sim/replay.h b/src/laige-sim/include/laige/sim/replay.h new file mode 100644 index 0000000..65688e9 --- /dev/null +++ b/src/laige-sim/include/laige/sim/replay.h @@ -0,0 +1,469 @@ +// laige-sim replay recording (M1-DET-02). +// +// FR-1.4 (determinism mode), FR-11.3 (replay), PRD Appendix A (replay = +// the input log + the seed); ARCH-007 (persistent and replay data MUST +// be versioned; readers MUST reject or migrate unsupported data +// explicitly); SCALE-005 (serialization formats specify byte order, +// bounds, version, compatibility, and malformed-input behavior); +// ADR 0002 (the backend id is part of replay identity: replay = inputs +// + seed + math backend + config hash); ARCH-010 (the deterministic +// 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 +// 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. +// +// ReplayIdentity The replay identity (ADR 0002) as a plain value. +// ReplayFrame One recorded input frame: the completed tick +// number plus the opaque byte blob. +// ReplayLog A parsed replay log: the identity plus the +// frames in tick order. +// ReplayRecorder The write side: create() -> writeFrame() per +// completed tick -> finish(). Atomic (temp file + +// rename), size-bounded (no unbounded growth), and +// a partial log is never left at the final path. +// parseReplay The byte-level reader (memory form). +// loadReplay The file reader (bounded read + parseReplay). +// componentSchemaHash +// The component registry's deterministic hash. +// configHash The provisional EngineConfig's deterministic hash. +// makeReplayIdentity +// The full identity from (world, config). +// +// --------------------------------------------------------------------------- +// The log format (version 1; SCALE-005: byte order, bounds, version, +// compatibility, malformed-input behavior) +// --------------------------------------------------------------------------- +// +// A replay log is one flat byte stream. EVERY integer is LITTLE-ENDIAN +// (host-endianness-independent, SCALE-005): +// +// header (kReplayHeaderSize = 40 bytes, fixed): +// offset 0 magic "LGRP" (4 bytes) +// offset 4 formatVersion u16 (= kReplayFormatVersion = 1) +// offset 6 reserved u16 (= 0) +// offset 8 seed u64 +// offset 16 tickRateHz u32 +// offset 20 componentSchemaHash u64 +// offset 28 mathBackendId u32 (the laige::SimMathBackend value) +// offset 32 configHash u64 +// +// frame (kReplayFrameRecordOverhead = 12 bytes + byteLength, one per +// completed tick, in tick order): +// offset 0 tick u64 (= 1 + the number of frames before it — +// the strict 1, 2, 3, ... sequence) +// offset 8 byteLength u32 (<= kMaxReplayFrameBytes) +// offset 12 frame bytes (byteLength of them) +// +// trailer (kReplayTrailerSize = 16 bytes, fixed, last in the file): +// offset 0 frameCount u64 (= the number of frames in the body) +// offset 8 fileHash u64 (FNV-1a 64 over EVERY byte before the +// trailer — the canonical byte-stream +// FNV-1a: offset basis +// 0xcbf29ce484222325, prime +// 0x100000001b3, fnv.org) +// +// Versioning (ARCH-007): readers accept formatVersion == 1 and reject +// every other version explicitly (MalformedInput — the "reject" branch +// of ARCH-007; a migration path, if one is ever needed, lands with the +// format step that raises the version). +// +// Bounds (SCALE-005): a frame's byteLength is u32 but is capped at +// kMaxReplayFrameBytes (1 MiB) — M1 carries zero-length frames (no +// input system exists yet; M3-INPUT-03 defines the payload shape), and +// the cap keeps the format's frame records bounded long before the +// 32-bit field's limit. The TOTAL log is bounded by the size limit the +// recorder was created with (kDefaultReplaySizeLimit when 0) — the cap +// covers header + frames + trailer, and an unbounded log is therefore +// unrepresentable in the write path (PERF-008 / SCALE-003: the growth +// is bounded, and the breach is a BudgetExhausted error, never silent). +// The reader applies its own bound (loadReplay's maxBytes, default +// kDefaultReplaySizeLimit): an oversized file is a MalformedInput. +// +// Malformed-input behavior (SCALE-005, ARCH-007, CORE-008): every +// structural violation — bad magic, unsupported version, non-zero +// reserved field, truncated frame or trailer, out-of-sequence tick, +// frame length above kMaxReplayFrameBytes, a frame overlapping the +// trailer, trailer frameCount mismatch, fileHash mismatch, trailing +// garbage — is a MalformedInput Status (never a crash, never a silent +// skip). File open/read failures are IoError. +// +// --------------------------------------------------------------------------- +// The replay identity (ADR 0002) +// --------------------------------------------------------------------------- +// +// replay identity = inputs + seed + math backend + config hash. The +// log's header records the identity WITHOUT the inputs (the frames +// ARE the inputs, per tick): +// +// seed the master simulation seed (config.seed) +// tickRateHz the simulation tick rate (config.tickRateHz) +// componentSchemaHash FNV-1a 64 over the component registry: the +// word stream [count, then per type id in +// ascending order: id, size, alignment] +// mathBackendId the SimMathBackend value (0 = fpx16_16, +// 1 = fp32_pinned — ADR 0002: the backend id is +// part of replay identity; cross-backend +// replays are not bit-exact and not supported) +// configHash FNV-1a 64 over the provisional EngineConfig's +// canonical field encoding (tag word 1 — the +// M1-HEAD-01 surface; M1-CFG-01 refines the +// schema, and with it this encoding, under the +// format's versioning) +// +// Both hashes are pure-integer FNV-1a over u64 word streams, +// big-endian byte order per word (the house hash convention — +// determinism_tests, prng_tests, laige-detcheck): no addresses, no +// unordered containers, no platform state enter the words (ARCH-010). +// A replay recorded under identity X is bit-exact only when replayed +// under X (M1-DET-03 compares the identity at load; a mismatch is a +// rejected replay, never a silent divergence). +// +// --------------------------------------------------------------------------- +// Recorder contract (write side) +// --------------------------------------------------------------------------- +// +// ReplayRecorder::create(identity, path, maxBytes) +// Opens the temp file `path + ".tmp"` (same filesystem as `path`), +// writes the header, and returns the recorder. The final `path` +// appears only on finish() (atomic temp + rename — no partial log +// is ever visible at the final path): +// +// path empty -> InvalidArgument +// maxBytes == 0 -> kDefaultReplaySizeLimit +// maxBytes < kMinReplaySizeLimit -> InvalidArgument (below the +// (header + trailer: no complete log could ever fit) +// temp open / header write failure -> IoError +// +// writeFrame(tick, data, len) one call per completed tick, in order: +// +// tick != frameCount + 1 -> InvalidArgument (strict +// 1, 2, 3, ... sequence — the +// engine's GameLoop onTick hook +// hands it the completed tick +// count, game_loop.h) +// len > kMaxReplayFrameBytes -> InvalidArgument +// bytesWritten + 12 + len > maxBytes +// -> BudgetExhausted (the size +// limit; the recorder is now +// failed — every later call +// returns the same error) +// write failure -> IoError (the recorder is now +// failed) +// +// M1 frames are ZERO-LENGTH (no input system exists yet — the +// engine writes nullptr/0 per completed tick; M3-INPUT-03 defines +// the payload shape and source). The frame record itself still +// carries the tick and the length field, so a non-empty blob is +// legal today and round-trips (the format is forward-ready). +// +// finish() flushes, writes the trailer, closes, and renames the +// temp file onto `path` (atomic: POSIX std::rename; MSVC +// MoveFileExA(MOVEFILE_REPLACE_EXISTING) — std::rename fails when +// the destination exists there, the CPP-009 platform boundary): +// +// already finished / failed -> the sticky Status +// trailer would exceed maxBytes -> BudgetExhausted (temp removed) +// flush / close failure -> IoError (temp removed) +// rename failure -> IoError (the temp file is +// LEFT for inspection — the +// complete data is in it; the +// caller's Error log names it) +// +// Lifetime: move-only. The destructor of an UNFINISHED recorder +// removes the temp file (silent cleanup — the failure was already +// reported through the Status the caller holds; the engine's +// shutdown() adds the structured replay/record_aborted warn). +// bytesWritten() counts header + frame bytes (the trailer is +// counted on finish); frameCount() the written frames. +// +// --------------------------------------------------------------------------- +// Threading, failure, performance +// --------------------------------------------------------------------------- +// +// Single-owner, the engine's owner thread (CONC-001; the recorder is +// driven from the GameLoop's per-tick hook — no internal state is +// shared across threads). All operations return Status / Result; the +// recorder logs nothing itself (the engine emits the structured +// replay/* events — LOG-001/002). +// +// Performance: create() is cold (one open + one 40-byte write). +// writeFrame() costs one 12-byte stdio write plus the payload copy — +// stdio's 8 KiB buffer flushes to the OS only every ~680 zero-length +// frames, so the per-tick cost is a bounded memcpy, not a syscall. +// finish() is cold (flush + 16-byte trailer + rename). The recorder +// allocates nothing per call (the stdio buffer is the one setup +// allocation, owned by the FILE). REPLAY RECORDING IS AN OPT-IN DEBUG +// FEATURE (the laige-run --replay flag; debug builds only): the +// default run path pays one null check per completed tick and no file +// work (DBG-004: the disabled cost is negligible). +// +// Misuse warnings: +// - Call Engine::startReplayRecording AFTER all component/system +// registration and BEFORE run_headless: the component schema hash +// (part of the identity) is captured at recording start — a +// component registered afterwards is missing from it, and the +// recorded log's identity would silently disagree with the world +// that ran (M1-DET-03's identity check is what catches that). +// - One recording per engine run; the engine rejects a second +// startReplayRecording (replay/record_already_started). +// - The final path must be on a filesystem that supports atomic +// rename (POSIX rename / Win32 MoveFileEx — true of every P0 +// platform's local filesystems; network shares that lack it +// surface as the documented rename-failure IoError). + +#pragma once + +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/result.h" +#include "laige/sim/component.h" // ComponentInfo (componentSchemaHash) +#include "laige/sim/determinism.h" // SimMathBackend (the identity's backend id) +#include "laige/sim/entity.h" // World (componentSchemaHash, makeReplayIdentity) + +namespace laige { + +struct EngineConfig; // declared in laige/sim/engine.h; only a const + // reference is used (makeReplayIdentity, configHash) + +// ----------------------------------------------------------------------- +// Format constants (SCALE-005; CORE-005) +// ----------------------------------------------------------------------- + +// The log's magic (the first 4 bytes: "LGRP" — Laige GRePlay). +inline constexpr std::uint8_t kReplayMagic[4] = {'L', 'G', 'R', 'P'}; + +// The supported format version (ARCH-007: readers accept 1, reject +// everything else explicitly). +inline constexpr std::uint16_t kReplayFormatVersion = 1; + +// The fixed header size in bytes (see the header layout above). +inline constexpr std::size_t kReplayHeaderSize = 40; + +// The per-frame fixed record size in bytes (tick u64 + length u32). +inline constexpr std::size_t kReplayFrameRecordOverhead = 12; + +// The fixed trailer size in bytes (frameCount u64 + fileHash u64). +inline constexpr std::size_t kReplayTrailerSize = 16; + +// The smallest size limit that can ever hold a complete log +// (header + trailer, zero frames). +inline constexpr std::uint64_t kMinReplaySizeLimit = 56; + +// 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). +inline constexpr std::uint32_t kMaxReplayFrameBytes = 1u << 20; + +// 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. +inline constexpr std::uint64_t kDefaultReplaySizeLimit = 128ull << 20; + +// ----------------------------------------------------------------------- +// The replay identity (ADR 0002) +// ----------------------------------------------------------------------- + +// 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. +struct ReplayIdentity { + // The master simulation seed (config.seed). + std::uint64_t seed{}; + // The simulation tick rate in hertz (config.tickRateHz). + std::uint32_t tickRateHz{}; + // FNV-1a 64 over the component registry (see the header preamble + // "The replay identity"). + std::uint64_t componentSchemaHash{}; + // The laige::SimMathBackend value (0 = FixedPoint16_16, 1 = + // FloatPinned32 — ADR 0002's backend ids). + std::uint32_t mathBackendId{}; + // FNV-1a 64 over the provisional EngineConfig field encoding. + std::uint64_t configHash{}; +}; + +// 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). +struct ReplayFrame { + // The completed tick this frame belongs to (1-based, in order). + std::uint64_t tick{}; + // The frame's opaque input bytes (empty in M1). + std::vector data; +}; + +// 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). +struct ReplayLog { + // The log's replay identity (the header). + ReplayIdentity identity{}; + // Every recorded frame, in tick order (empty when the log has no + // frames — legal: a zero-tick run). + std::vector frames; +}; + +// ----------------------------------------------------------------------- +// The recorder (write side) +// ----------------------------------------------------------------------- + +// 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). +class ReplayRecorder { + public: + // 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). + [[nodiscard]] static Result + create(const ReplayIdentity& identity, std::string_view path, + std::uint64_t maxBytes) noexcept; + + ReplayRecorder(const ReplayRecorder&) = delete; + ReplayRecorder& operator=(const ReplayRecorder&) = delete; + // Move transfers the open file; the source becomes finished (a + // finished recorder does nothing — the moved-out GameLoop + // precedent). + ReplayRecorder(ReplayRecorder&& other) noexcept; + ReplayRecorder& operator=(ReplayRecorder&& other) noexcept; + // An unfinished recorder removes its temp file (the final path is + // never touched; the failure was already reported through a Status). + ~ReplayRecorder() noexcept; + + // 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). + [[nodiscard]] Status writeFrame(std::uint64_t tick, + const std::uint8_t* data, + std::size_t len) noexcept; + + // 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). + [[nodiscard]] Status finish() noexcept; + + // 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. + [[nodiscard]] Status status() const noexcept; + + // True once finish() succeeded. O(1), no side effects. + [[nodiscard]] bool finished() const noexcept; + + // Bytes written so far (header + frame bytes; the trailer is + // counted when finish() writes it). O(1), no side effects. + [[nodiscard]] std::uint64_t bytesWritten() const noexcept; + + // Frames recorded so far. O(1), no side effects. + [[nodiscard]] std::uint64_t frameCount() const noexcept; + + // The FINAL path (the rename destination; the temp file is + // `path() + ".tmp"`). Valid for the recorder's lifetime. + [[nodiscard]] const char* path() const noexcept; + + private: + // The factory path (create): constructed only with an open temp + // file and a written header (API-008: no empty state). + ReplayRecorder(std::string path, std::string tmpPath, + std::uint64_t maxBytes, std::FILE* file, + const ReplayIdentity& identity); + + // The final path (the rename destination). + std::string path_{}; + // The temp file path (path_ + ".tmp"; same filesystem). + std::string tmpPath_{}; + // The total size cap (header + frames + trailer). + std::uint64_t maxBytes_{}; + // The identity written into the header (echoed by parseReplay). + ReplayIdentity identity_{}; + // The open temp file (nullptr after finish / on a moved-from + // recorder). Owned by the recorder (closed in finish or the + // destructor). + std::FILE* file_{nullptr}; + // Bytes written so far (header + frames; the trailer on finish). + std::uint64_t bytesWritten_{}; + // Frames recorded so far. + std::uint64_t frameCount_{}; + // Running FNV-1a 64 over every byte written so far (the trailer's + // fileHash covers exactly these). + std::uint64_t fileHash_{}; + // The sticky failure (ok until the first error). + Status error_{}; + // True once finish() succeeded (or on a moved-from recorder). + bool finished_{false}; +}; + +// ----------------------------------------------------------------------- +// The reader (parse side) +// ----------------------------------------------------------------------- + +// 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). +// +// data == nullptr && size != 0 -> MalformedInput +// @budget O(size) time, O(total frame bytes) allocation. +[[nodiscard]] Result +parseReplay(const std::uint8_t* data, std::size_t size) noexcept; + +// 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. +// +// path empty -> MalformedInput +// open / read failure -> IoError +// size > maxBytes -> MalformedInput +// @budget O(file size) time, O(file size) allocation (the bounded read). +[[nodiscard]] Result +loadReplay(std::string_view path, + std::uint64_t maxBytes = kDefaultReplaySizeLimit) noexcept; + +// ----------------------------------------------------------------------- +// The identity computation +// ----------------------------------------------------------------------- + +// 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. +[[nodiscard]] std::uint64_t componentSchemaHash(const World& world) noexcept; + +// 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. +[[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept; + +// 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. +[[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, + const EngineConfig& config) noexcept; + +} // namespace laige diff --git a/src/laige-sim/replay.cpp b/src/laige-sim/replay.cpp new file mode 100644 index 0000000..626c46b --- /dev/null +++ b/src/laige-sim/replay.cpp @@ -0,0 +1,578 @@ +// laige-sim replay recording (M1-DET-02). +// +// Implementation of the types declared in +// include/laige/sim/replay.h — see that header for the format spec +// (SCALE-005), the recorder contract, the identity hashes, and the +// performance notes, and docs/api/replay.md for the API document. +// +// House hash conventions (the docs/testing.md / determinism_tests / +// laige-detcheck convention): FNV-1a 64 — offset basis +// 0xcbf29ce484222325, prime 0x100000001b3 (fnv.org). Word-stream +// hashes (componentSchemaHash, configHash) run big-endian byte order +// per u64 word; the log's trailer fileHash is the canonical +// byte-stream FNV-1a over the file's raw bytes. +// +// Platform boundary (CPP-009, the logging.cpp / laige-run.cpp +// precedent): MSVC's CRT deprecates plain fopen (C4996, fatal under +// the engine's /WX policy) and opens it with _SH_SECURE when used via +// fopen_s (denying re-open); _fsopen(..., _SH_DENYNO) is the plain- +// fopen sharing semantics every other supported compiler provides. +// std::rename fails on MSVC when the destination exists, so the +// atomic finalization uses MoveFileExA(MOVEFILE_REPLACE_EXISTING). + +#include "laige/sim/replay.h" // the contract (this header) + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for _fsopen +#include // MoveFileExA / MOVEFILE_REPLACE_EXISTING +#endif + +#include "laige/sim/engine.h" // EngineConfig (configHash, makeReplayIdentity) + +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; + +// Canonical byte-stream FNV-1a 64 over raw bytes (the trailer's +// fileHash). +std::uint64_t fnv1a64Bytes(const std::uint8_t* bytes, std::size_t count) { + std::uint64_t h = kFnvOffsetBasis; + for (std::size_t i = 0; i < count; ++i) { + h ^= static_cast(bytes[i]); + h *= kFnvPrime; + } + return h; +} + +// Continue a running byte-stream FNV-1a 64 (from an existing hash) +// over further bytes — FNV-1a is a streaming hash: the state carries +// over, so the recorder's running hash folds the payload in order +// after the record bytes. +std::uint64_t fnv1a64BytesExtend(std::uint64_t h, const std::uint8_t* bytes, + std::size_t count) { + for (std::size_t i = 0; i < count; ++i) { + h ^= static_cast(bytes[i]); + h *= kFnvPrime; + } + return h; +} + +// Word-stream FNV-1a 64 over u64 words, big-endian byte order per word +// (the house convention: determinism_tests, prng_tests, detcheck). +std::uint64_t fnv1a64Words(const std::uint64_t* words, std::size_t count) { + std::uint64_t h = kFnvOffsetBasis; + for (std::size_t i = 0; i < count; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (words[i] >> shift) & 0xFFull; + h *= kFnvPrime; + } + } + return h; +} + +// Little-endian encoders (SCALE-005: the format is little-endian on +// every platform, so the encoding is explicit). +void encodeU16le(std::uint8_t* out, std::uint16_t value) { + out[0] = static_cast(value & 0xFFu); + out[1] = static_cast((value >> 8) & 0xFFu); +} +void encodeU32le(std::uint8_t* out, std::uint32_t value) { + out[0] = static_cast(value & 0xFFu); + out[1] = static_cast((value >> 8) & 0xFFu); + out[2] = static_cast((value >> 16) & 0xFFu); + out[3] = static_cast((value >> 24) & 0xFFu); +} +void encodeU64le(std::uint8_t* out, std::uint64_t value) { + for (int i = 0; i < 8; ++i) { + out[i] = static_cast((value >> (8 * i)) & 0xFFu); + } +} +// Little-endian decoders (the bytes are validated in range by the +// caller's structure checks before use). +std::uint16_t decodeU16le(const std::uint8_t* in) { + return static_cast(in[0]) | + static_cast(in[1]) << 8; +} +std::uint32_t decodeU32le(const std::uint8_t* in) { + return static_cast(in[0]) | + static_cast(in[1]) << 8 | + static_cast(in[2]) << 16 | + static_cast(in[3]) << 24; +} +std::uint64_t decodeU64le(const std::uint8_t* in) { + std::uint64_t v = 0; + for (int i = 7; i >= 0; --i) { + v = (v << 8) | in[i]; + } + return v; +} + +// Encode the identity header into kReplayHeaderSize bytes (freshly +// initialized). +void encodeHeader(const ReplayIdentity& identity, std::uint8_t* header) { + static_assert(sizeof(kReplayMagic) == 4, "the magic is 4 bytes"); + std::memcpy(header, kReplayMagic, sizeof(kReplayMagic)); + encodeU16le(header + 4, kReplayFormatVersion); + encodeU16le(header + 6, 0); // reserved + encodeU64le(header + 8, identity.seed); + encodeU32le(header + 16, identity.tickRateHz); + encodeU64le(header + 20, identity.componentSchemaHash); + encodeU32le(header + 28, identity.mathBackendId); + encodeU64le(header + 32, identity.configHash); +} + +// The portable binary open (the laige-run.cpp openConfigFile +// precedent; see the translation-unit preamble). +std::FILE* openBinaryFile(const std::string& path, const char* mode) { +#if defined(_MSC_VER) + return ::_fsopen(path.c_str(), mode, _SH_DENYNO); +#else + return std::fopen(path.c_str(), mode); +#endif +} + +// The atomic finalization rename (see the translation-unit preamble): +// POSIX std::rename atomically replaces an existing destination; MSVC +// needs MoveFileExA with MOVEFILE_REPLACE_EXISTING. +bool atomicReplace(const std::string& from, const std::string& to) { +#if defined(_MSC_VER) + return ::MoveFileExA(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING) != 0; +#else + return std::rename(from.c_str(), to.c_str()) == 0; +#endif +} + +// The tag word of the provisional EngineConfig field encoding +// (M1-HEAD-01 surface; M1-CFG-01 refines it — the tag identifies this +// encoding in the hash word stream). +inline constexpr std::uint64_t kConfigHashTag = 1; + +} // namespace + +// ----------------------------------------------------------------------- +// ReplayRecorder +// ----------------------------------------------------------------------- + +ReplayRecorder::ReplayRecorder(std::string path, std::string tmpPath, + std::uint64_t maxBytes, std::FILE* file, + const ReplayIdentity& identity) + : path_(std::move(path)), + tmpPath_(std::move(tmpPath)), + maxBytes_(maxBytes), + identity_(identity), + file_(file) {} + +Result +ReplayRecorder::create(const ReplayIdentity& identity, std::string_view path, + std::uint64_t maxBytes) noexcept { + if (path.empty()) { + return Result(ErrorCode::InvalidArgument); + } + // 0 means the default cap (the loadReplay/create documented default). + const std::uint64_t cap = + maxBytes == 0 ? kDefaultReplaySizeLimit : maxBytes; + if (cap < kMinReplaySizeLimit) { + // Below header + trailer: no complete log could ever fit — the + // limit is outside the documented domain (API-008). + return Result(ErrorCode::InvalidArgument); + } + // The temp file lives next to the final path (same filesystem — + // the rename is atomic). + const std::string tmpPath = std::string(path) + ".tmp"; + std::FILE* file = openBinaryFile(tmpPath, "wb"); + if (file == nullptr) { + return Result(ErrorCode::IoError); + } + // The header (kReplayHeaderSize bytes, freshly initialized — the + // encode writes every byte of it). + std::uint8_t header[kReplayHeaderSize] = {}; + encodeHeader(identity, header); + if (std::fwrite(header, 1, sizeof(header), file) != sizeof(header)) { + std::fclose(file); + (void)std::remove(tmpPath.c_str()); + return Result(ErrorCode::IoError); + } + ReplayRecorder recorder(std::string(path), tmpPath, cap, file, identity); + recorder.bytesWritten_ = kReplayHeaderSize; + recorder.fileHash_ = fnv1a64Bytes(header, sizeof(header)); + return Result::success(std::move(recorder)); +} + +ReplayRecorder::ReplayRecorder(ReplayRecorder&& other) noexcept + : path_(std::move(other.path_)), + tmpPath_(std::move(other.tmpPath_)), + maxBytes_(other.maxBytes_), + identity_(other.identity_), + file_(other.file_), + bytesWritten_(other.bytesWritten_), + frameCount_(other.frameCount_), + fileHash_(other.fileHash_), + error_(other.error_), + finished_(other.finished_) { + // The source loses the open file and becomes finished: its + // destructor cleans nothing (the moved-out GameLoop precedent). + other.file_ = nullptr; + other.finished_ = true; +} + +ReplayRecorder& ReplayRecorder::operator=(ReplayRecorder&& other) noexcept { + if (this != &other) { + // Release this recorder's current state first (a no-op when it + // is already finished; otherwise the temp file is removed — the + // moved-into recorder takes over the file). + if (!finished_ && file_ != nullptr) { + std::fclose(file_); + } + if (!finished_) { + (void)std::remove(tmpPath_.c_str()); + } + path_ = std::move(other.path_); + tmpPath_ = std::move(other.tmpPath_); + maxBytes_ = other.maxBytes_; + identity_ = other.identity_; + file_ = other.file_; + bytesWritten_ = other.bytesWritten_; + frameCount_ = other.frameCount_; + fileHash_ = other.fileHash_; + error_ = other.error_; + finished_ = other.finished_; + other.file_ = nullptr; + other.finished_ = true; + } + return *this; +} + +ReplayRecorder::~ReplayRecorder() noexcept { + // Unfinished cleanup (silent — the failure was already reported + // through a Status the caller holds; the engine's shutdown() adds + // the structured replay/record_aborted warn): close the file and + // remove the temp file. The final path is never touched here. + if (!finished_) { + if (file_ != nullptr) { + std::fclose(file_); + } + (void)std::remove(tmpPath_.c_str()); + } + file_ = nullptr; +} + +Status ReplayRecorder::writeFrame(std::uint64_t tick, + const std::uint8_t* data, + std::size_t len) noexcept { + if (error_.isError()) { + return error_; // sticky: a failed recorder stays failed + } + if (finished_) { + error_ = Status(ErrorCode::InvalidArgument); + return error_; // a finished recorder accepts no frames + } + if (tick != frameCount_ + 1) { + // The strict 1, 2, 3, ... sequence (the engine's onTick hook + // hands it the completed tick count — game_loop.h). + error_ = Status(ErrorCode::InvalidArgument); + return error_; + } + if (len > kMaxReplayFrameBytes) { + error_ = Status(ErrorCode::InvalidArgument); + return error_; + } + if (bytesWritten_ + kReplayFrameRecordOverhead + len > maxBytes_) { + // The total size limit (header + frames + trailer) — the log's + // growth is bounded (PERF-008 / SCALE-003) and the breach is an + // explicit budget error (CORE-008). + error_ = Status(ErrorCode::BudgetExhausted); + return error_; + } + if (file_ == nullptr) { + // Unreachable (the recorder owns the file until finish); keep + // the function total rather than crash (CORE-008). + assert(file_ != nullptr && "writeFrame on a recorder without a file"); + error_ = Status(ErrorCode::IoError); + return error_; + } + // The 12-byte record (tick u64 + byteLength u32, little-endian) + // followed by the payload. + std::uint8_t record[kReplayFrameRecordOverhead] = {}; + encodeU64le(record, tick); + encodeU32le(record + 8, static_cast(len)); + std::size_t written = std::fwrite(record, 1, sizeof(record), file_); + if (written != sizeof(record) || + (len != 0 && std::fwrite(data, 1, len, file_) != len)) { + std::fclose(file_); + file_ = nullptr; + (void)std::remove(tmpPath_.c_str()); + error_ = Status(ErrorCode::IoError); + return error_; + } + const std::uint64_t frameBytes = + static_cast(kReplayFrameRecordOverhead) + len; + bytesWritten_ += frameBytes; + ++frameCount_; + // The running hash extends over the record bytes, then the payload + // (FNV-1a is streaming — the state carries over). + fileHash_ = fnv1a64BytesExtend(fileHash_, record, sizeof(record)); + if (len != 0) { + fileHash_ = fnv1a64BytesExtend(fileHash_, data, len); + } + return Status{}; +} + +Status ReplayRecorder::finish() noexcept { + if (error_.isError()) { + return error_; // sticky: a failed recorder cannot finalize + } + if (finished_) { + error_ = Status(ErrorCode::InvalidArgument); + return error_; // finish is one-shot + } + if (file_ == nullptr) { + // Unreachable (the recorder owns the file until finish); keep + // the function total rather than crash (CORE-008). + assert(file_ != nullptr && "finish on a recorder without a file"); + error_ = Status(ErrorCode::IoError); + return error_; + } + // The trailer must fit inside the total cap (header + frames + + // trailer all count). + if (bytesWritten_ + kReplayTrailerSize > maxBytes_) { + std::fclose(file_); + file_ = nullptr; + (void)std::remove(tmpPath_.c_str()); + error_ = Status(ErrorCode::BudgetExhausted); + return error_; + } + // The trailer: frameCount u64 + fileHash u64 (the FNV-1a 64 over + // every byte before the trailer — exactly the running hash). + std::uint8_t trailer[kReplayTrailerSize] = {}; + encodeU64le(trailer, frameCount_); + encodeU64le(trailer + 8, fileHash_); + if (std::fwrite(trailer, 1, sizeof(trailer), file_) != sizeof(trailer) || + std::fflush(file_) != 0) { + std::fclose(file_); + file_ = nullptr; + (void)std::remove(tmpPath_.c_str()); + error_ = Status(ErrorCode::IoError); + return error_; + } + bytesWritten_ += kReplayTrailerSize; + if (std::fclose(file_) != 0) { + file_ = nullptr; + // The data was flushed; the temp file is LEFT (the complete log + // is in it — the caller's Error log names it for inspection). + error_ = Status(ErrorCode::IoError); + return error_; + } + if (!atomicReplace(tmpPath_, path_)) { + // Rename failure: the temp file is LEFT for inspection (the + // complete data is in it). Documented in the header preamble. + error_ = Status(ErrorCode::IoError); + return error_; + } + finished_ = true; + return Status{}; +} + +Status ReplayRecorder::status() const noexcept { return error_; } + +bool ReplayRecorder::finished() const noexcept { return finished_; } + +std::uint64_t ReplayRecorder::bytesWritten() const noexcept { + return bytesWritten_; +} + +std::uint64_t ReplayRecorder::frameCount() const noexcept { + return frameCount_; +} + +const char* ReplayRecorder::path() const noexcept { return path_.c_str(); } + +// ----------------------------------------------------------------------- +// The reader (parse side) +// ----------------------------------------------------------------------- + +Result +parseReplay(const std::uint8_t* data, std::size_t size) noexcept { + // The structural violation table (header preamble): every branch + // below is a MalformedInput — never a crash, never a silent skip + // (SCALE-005, ARCH-007, CORE-008). + if (size != 0 && data == nullptr) { + return Result(ErrorCode::MalformedInput); + } + if (size < kReplayHeaderSize) { + return Result(ErrorCode::MalformedInput); + } + if (std::memcmp(data, kReplayMagic, sizeof(kReplayMagic)) != 0) { + return Result(ErrorCode::MalformedInput); + } + const std::uint16_t version = decodeU16le(data + 4); + if (version != kReplayFormatVersion) { + // ARCH-007: unsupported versions are rejected explicitly. + return Result(ErrorCode::MalformedInput); + } + if (decodeU16le(data + 6) != 0) { // reserved + return Result(ErrorCode::MalformedInput); + } + ReplayLog log; + log.identity.seed = decodeU64le(data + 8); + log.identity.tickRateHz = decodeU32le(data + 16); + log.identity.componentSchemaHash = decodeU64le(data + 20); + log.identity.mathBackendId = decodeU32le(data + 28); + log.identity.configHash = decodeU64le(data + 32); + + // The frame body: every frame must end at or before the trailer's + // start (size - kReplayTrailerSize); a frame header there with + // fewer than 12 body bytes left is a truncation. + const std::size_t bodyEnd = size >= kReplayTrailerSize + ? size - kReplayTrailerSize + : 0; + if (kReplayHeaderSize > bodyEnd) { + return Result(ErrorCode::MalformedInput); + } + std::size_t pos = kReplayHeaderSize; + std::uint64_t count = 0; + while (pos < bodyEnd) { + const std::size_t remaining = bodyEnd - pos; + if (remaining < kReplayFrameRecordOverhead) { + return Result(ErrorCode::MalformedInput); + } + const std::uint64_t tick = decodeU64le(data + pos); + const std::uint32_t len = decodeU32le(data + pos + 8); + if (tick != count + 1) { + return Result(ErrorCode::MalformedInput); + } + if (len > kMaxReplayFrameBytes) { + return Result(ErrorCode::MalformedInput); + } + if (remaining < kReplayFrameRecordOverhead + len) { + return Result(ErrorCode::MalformedInput); + } + ReplayFrame frame; + frame.tick = tick; + frame.data.assign(data + pos + kReplayFrameRecordOverhead, + data + pos + kReplayFrameRecordOverhead + len); + log.frames.push_back(std::move(frame)); + pos += kReplayFrameRecordOverhead + len; + ++count; + } + // The trailer: exactly the last 16 bytes (pos == bodyEnd). + if (pos != bodyEnd) { + return Result(ErrorCode::MalformedInput); + } + const std::uint64_t trailerCount = decodeU64le(data + pos); + const std::uint64_t trailerHash = decodeU64le(data + pos + 8); + if (trailerCount != count) { + return Result(ErrorCode::MalformedInput); + } + // The fileHash covers every byte before the trailer (the canonical + // byte-stream FNV-1a — the recorder's running hash). + if (fnv1a64Bytes(data, bodyEnd) != trailerHash) { + return Result(ErrorCode::MalformedInput); + } + return Result::success(std::move(log)); +} + +Result +loadReplay(std::string_view path, std::uint64_t maxBytes) noexcept { + if (path.empty()) { + return Result(ErrorCode::MalformedInput); + } + const std::uint64_t cap = + maxBytes == 0 ? kDefaultReplaySizeLimit : maxBytes; + std::FILE* file = openBinaryFile(std::string(path), "rb"); + if (file == nullptr) { + return Result(ErrorCode::IoError); + } + // The bounded read (the ADR 0003 JSON-bound precedent: an oversized + // file is a MalformedInput, not a truncated parse). + std::vector bytes; + std::uint8_t chunk[8192]; + std::uint64_t total = 0; + for (;;) { + const std::size_t n = std::fread(chunk, 1, sizeof(chunk), file); + if (n == 0) { + if (std::ferror(file)) { + std::fclose(file); + return Result(ErrorCode::IoError); + } + break; // clean EOF + } + total += n; + if (total > cap) { + std::fclose(file); + return Result(ErrorCode::MalformedInput); + } + bytes.insert(bytes.end(), chunk, chunk + n); + } + std::fclose(file); + return parseReplay(bytes.data(), bytes.size()); +} + +// ----------------------------------------------------------------------- +// The identity computation +// ----------------------------------------------------------------------- + +std::uint64_t componentSchemaHash(const World& world) noexcept { + // [componentCount, then per type id in ascending order: id, size, + // alignment] — at most 1 + 3 * kMaxComponentTypes words. The stack + // array keeps the hot setup path allocation-free (PERF-003); 769 + // words is 6 KiB, a setup-path one-shot. + std::uint64_t words[1 + 3 * kMaxComponentTypes]; + const std::uint32_t count = world.componentCount(); + words[0] = count; + std::size_t i = 1; + for (std::uint32_t id = 1; id <= count; ++id) { + // Dense ids 1..count are registered by the World's bookkeeping + // invariant (component.h: ids are assigned densely from 1 in + // registration order); the assert documents it, and result.h's + // house contract makes the unchecked value() legal here (debug + // asserts, release invariant). + const Result info = + world.componentInfo(ComponentTypeId{id}); + assert(info.ok() && "componentInfo failed for a dense registry id"); + const ComponentInfo& ci = info.value(); + words[i++] = id; + words[i++] = ci.size; + words[i++] = ci.alignment; + } + return fnv1a64Words(words, i); +} + +std::uint64_t configHash(const EngineConfig& config) noexcept { + // The provisional EngineConfig field encoding (tag word 1 — the + // M1-HEAD-01 surface; M1-CFG-01 refines the schema and this + // encoding with it, under the format's versioning). + std::uint64_t words[7]; + words[0] = kConfigHashTag; + words[1] = config.tickRateHz; + words[2] = config.entityCapacity; + words[3] = config.churnPerFrameBudget; + words[4] = config.seed; + words[5] = config.determinism.enabled ? 1ull : 0ull; + words[6] = static_cast(config.determinism.math); + return fnv1a64Words(words, 7); +} + +ReplayIdentity makeReplayIdentity(const World& world, + const EngineConfig& config) noexcept { + return ReplayIdentity{ + config.seed, + config.tickRateHz, + componentSchemaHash(world), + static_cast(config.determinism.math), + configHash(config)}; +} + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 975278a..4a8ceb8 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-LOOP-01/02 + M1-HEAD-01 + M1-DET-01/02): # 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 @@ -14,24 +14,29 @@ # prev/curr refresh, the anchored clamped alpha, sample_position), # and the headless engine run (config -> world -> systems -> loop, # the run_headless bounded run + loop accounting, the ordered -# idempotent CONC-006 shutdown, the provisional config surface). +# idempotent CONC-006 shutdown, the provisional config surface), and +# 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). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus # gtest_main. The unfiltered entry runs the whole module; the `entity`, # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, -# `system_timing`, `game_loop`, `presentation`, `engine`, and -# `determinism_mode` 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, and M1-DET-01 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`, -# and `ctest -R determinism_mode`), selecting exactly the suites -# below from the shared executable. +# `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. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp @@ -43,7 +48,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp game_loop_tests.cpp presentation_tests.cpp engine_tests.cpp - determinism_tests.cpp) + determinism_tests.cpp + replay_record_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, @@ -205,6 +211,17 @@ add_test(NAME determinism_mode COMMAND laige-sim_tests --gtest_filter=DeterminismMode.*:DeterminismEngine.*:DeterminismConfigParse.*) +# M1-DET-02: replay recording (FR-1.4, PRD Appendix A, ARCH-007, +# SCALE-005). The step's Verify command is `ctest -R replay_record`; +# this entry selects exactly the Replay* suites from the shared +# laige-sim_tests executable (the log format's round-trip + +# malformed-input table, the recorder's atomic write + size limit, +# the identity hashes, and the engine's per-tick empty-frame +# recording + mid-run failure stop). +add_test(NAME replay_record + COMMAND laige-sim_tests + --gtest_filter=Replay*) + # 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 @@ -267,6 +284,7 @@ if(LAIGE_TSAN) # so ctest fails loudly on any TSan report. 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 PROPERTIES + system_timing game_loop presentation engine determinism_mode + replay_record PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/replay_record_tests.cpp b/tests/laige-sim/replay_record_tests.cpp new file mode 100644 index 0000000..156d9c6 --- /dev/null +++ b/tests/laige-sim/replay_record_tests.cpp @@ -0,0 +1,942 @@ +// laige-sim replay recording suite (M1-DET-02). +// +// Step Verify scope (roadmap/M1-heartbeat.md, `ctest -R replay_record`): +// - round trip: record N ticks -> parse back -> identical bytes and +// fields (the step's "record N ticks -> parse back -> identical +// bytes" clause) +// - malformed log files (truncation, bad version, and the full +// SCALE-005 violation table) -> Status error, NEVER a crash +// - the recorder: atomic temp+rename (no partial log at the final +// path), the size limit (BudgetExhausted, no unbounded growth), +// the strict tick sequence +// - the replay identity (ADR 0002): pure-integer FNV-1a hashes, +// stable for (world, config) +// - the engine: one zero-length frame per completed tick (M1: no +// input system yet), a recording failure stops the run, the +// structured replay/* events +// +// Hash conventions: the identity hashes are word-stream FNV-1a 64, +// big-endian byte order per u64 (the house convention — +// determinism_tests, prng_tests, laige-detcheck); the log trailer's +// fileHash is the canonical byte-stream FNV-1a 64 (fnv.org), per the +// format spec in laige/sim/replay.h (SCALE-005). + +#include +#include +#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/determinism.h" +#include "laige/sim/engine.h" +#include "laige/sim/entity.h" +#include "laige/sim/replay.h" +#include "laige_test_seed.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "replay_record_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "replay_record_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_record_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +namespace { + +// The test's Prng substream id (docs/testing.md §4: one named id per +// randomized test file — "REPL"). +constexpr std::uint32_t kReplayTestSubstreamId = 0x52455041u; + +// FNV-1a 64 constants (fnv.org — the house convention). +constexpr std::uint64_t kFnvBasis = 0xcbf29ce484222325ull; +constexpr std::uint64_t kFnvPrime = 0x100000001b3ull; + +// Byte-stream FNV-1a 64 (the trailer fileHash convention). +std::uint64_t fnv1aBytes(const std::uint8_t* bytes, std::size_t count) { + std::uint64_t h = kFnvBasis; + for (std::size_t i = 0; i < count; ++i) { + h ^= static_cast(bytes[i]); + h *= kFnvPrime; + } + return h; +} + +// Little-endian appends (the format's byte order, SCALE-005). +void appendU16le(std::vector& out, std::uint16_t value) { + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); +} +void appendU32le(std::vector& out, std::uint32_t value) { + for (int i = 0; i < 4; ++i) { + out.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } +} +void appendU64le(std::vector& out, std::uint64_t value) { + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } +} + +// A test identity (the engine's fields are checked separately). +laige::ReplayIdentity testIdentity() { + laige::ReplayIdentity identity; + identity.seed = 42; + identity.tickRateHz = 60; + identity.componentSchemaHash = 0x0123456789ABCDEFull; + identity.mathBackendId = + static_cast(laige::SimMathBackend::FixedPoint16_16); + identity.configHash = 0xFEDCBA9876543210ull; + return identity; +} + +// Builds `n` frames with PRNG-drawn payloads (0 .. maxLen-1 bytes +// each) — the round-trip's non-trivial case (M1 engine frames are +// zero-length; the format must still carry arbitrary blobs). +std::vector makeTestFrames(std::size_t n, + std::size_t maxLen) { + laige::Prng rng = laige::testing::TestPrng(kReplayTestSubstreamId); + std::vector frames; + frames.reserve(n); + for (std::uint64_t tick = 1; tick <= n; ++tick) { + laige::ReplayFrame frame; + frame.tick = tick; + const std::size_t len = + maxLen == 0 ? 0 + : static_cast( + rng.next_range(0, static_cast(maxLen))); + frame.data.resize(len); + for (std::uint8_t& byte : frame.data) { + byte = static_cast(rng.next_u64()); + } + frames.push_back(std::move(frame)); + } + return frames; +} + +// Records `frames` to `path` through the public recorder (create -> +// writeFrame -> finish); returns the finish Status (the caller checks +// the frame writes through the recorder when a mid-way failure is +// expected). +laige::Status recordFrames(const std::string& path, + const laige::ReplayIdentity& identity, + const std::vector& frames, + std::uint64_t maxBytes) { + laige::Result created = + laige::ReplayRecorder::create(identity, path, maxBytes); + if (created.isError()) { + return laige::Status(created.error()); + } + laige::ReplayRecorder recorder = std::move(created).takeValue(); + for (const laige::ReplayFrame& frame : frames) { + const laige::Status w = recorder.writeFrame( + frame.tick, frame.data.data(), frame.data.size()); + if (w.isError()) { + return w; + } + } + return recorder.finish(); +} + +// A temp file path (gtest's temp dir; unique per test name). +std::string tempPath(const char* name) { + return std::string(::testing::TempDir()) + name; +} + +// One log event captured from the facade (the engine_tests.cpp +// 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; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + 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; +} + +} // namespace + +// --------------------------------------------------------------------------- +// ReplayFormat: the log format's round trip + the malformed-input table +// (SCALE-005, ARCH-007) +// --------------------------------------------------------------------------- + +TEST(ReplayFormat, RoundTripBytes) { + // Record 8 frames (PRNG payloads, up to 16 bytes) to a temp file, + // read the raw bytes back, parse, and compare every field — the + // step's "record N ticks -> parse back -> identical bytes" clause. + const std::string path = tempPath("replay_roundtrip.log"); + const std::vector frames = makeTestFrames(8, 16); + const laige::Status status = + recordFrames(path, testIdentity(), frames, 0 /* default cap */); + ASSERT_TRUE(status.ok()); + ASSERT_TRUE(std::filesystem::exists(path)); + + std::vector raw; + { + std::size_t size = static_cast(std::filesystem::file_size(path)); + raw.resize(size); + std::FILE* f = std::fopen(path.c_str(), "rb"); + ASSERT_NE(f, nullptr); + ASSERT_EQ(std::fread(raw.data(), 1, raw.size(), f), raw.size()); + std::fclose(f); + } + const laige::Result parsed = + laige::parseReplay(raw.data(), raw.size()); + ASSERT_TRUE(parsed.ok()); + const laige::ReplayLog& log = parsed.value(); + const laige::ReplayIdentity expected = testIdentity(); + EXPECT_EQ(log.identity.seed, expected.seed); + EXPECT_EQ(log.identity.tickRateHz, expected.tickRateHz); + EXPECT_EQ(log.identity.componentSchemaHash, expected.componentSchemaHash); + EXPECT_EQ(log.identity.mathBackendId, expected.mathBackendId); + EXPECT_EQ(log.identity.configHash, expected.configHash); + ASSERT_EQ(log.frames.size(), frames.size()); + for (std::size_t i = 0; i < frames.size(); ++i) { + EXPECT_EQ(log.frames[i].tick, frames[i].tick); + EXPECT_EQ(log.frames[i].data, frames[i].data); // identical bytes + } + // The trailer's fileHash matches the local canonical FNV-1a. + const std::size_t bodyEnd = raw.size() - laige::kReplayTrailerSize; + std::uint64_t trailerHash = 0; + for (int i = 7; i >= 0; --i) { + trailerHash = (trailerHash << 8) | raw[bodyEnd + 8 + i]; + } + EXPECT_EQ(fnv1aBytes(raw.data(), bodyEnd), trailerHash); + // Machine-greppable line (docs/testing.md §4). + std::printf("replay-roundtrip frames=%zu bytes=%zu filehash=0x%016llx\n", + log.frames.size(), raw.size(), + static_cast(trailerHash)); +} + +TEST(ReplayFormat, RoundTripFile) { + // The file form (loadReplay): the same round trip through the public + // file reader. + const std::string path = tempPath("replay_roundtrip_file.log"); + const std::vector frames = makeTestFrames(5, 8); + const laige::Status status = + recordFrames(path, testIdentity(), frames, 0); + ASSERT_TRUE(status.ok()); + const laige::Result parsed = + laige::loadReplay(path); + ASSERT_TRUE(parsed.ok()); + const laige::ReplayLog& log = parsed.value(); + ASSERT_EQ(log.frames.size(), frames.size()); + for (std::size_t i = 0; i < frames.size(); ++i) { + EXPECT_EQ(log.frames[i].tick, frames[i].tick); + EXPECT_EQ(log.frames[i].data, frames[i].data); + } + EXPECT_EQ(log.identity.seed, 42u); +} + +TEST(ReplayFormat, EncodingIsDeterministic) { + // The same frames recorded twice produce byte-identical logs (no + // timestamps, no addresses — the format is a pure function of + // identity + frames). + const std::string pathA = tempPath("replay_det_a.log"); + const std::string pathB = tempPath("replay_det_b.log"); + const std::vector frames = makeTestFrames(4, 12); + ASSERT_TRUE(recordFrames(pathA, testIdentity(), frames, 0).ok()); + ASSERT_TRUE(recordFrames(pathB, testIdentity(), frames, 0).ok()); + std::vector a, b; + a.resize(static_cast(std::filesystem::file_size(pathA))); + b.resize(static_cast(std::filesystem::file_size(pathB))); + std::FILE* fa = std::fopen(pathA.c_str(), "rb"); + std::FILE* fb = std::fopen(pathB.c_str(), "rb"); + ASSERT_NE(fa, nullptr); + ASSERT_NE(fb, nullptr); + ASSERT_EQ(std::fread(a.data(), 1, a.size(), fa), a.size()); + ASSERT_EQ(std::fread(b.data(), 1, b.size(), fb), b.size()); + std::fclose(fa); + std::fclose(fb); + EXPECT_EQ(a, b); +} + +TEST(ReplayFormat, ZeroFrameLog) { + // A zero-tick run records a header + trailer only — legal and + // round-trips. + const std::string path = tempPath("replay_zero.log"); + const laige::Status status = recordFrames(path, testIdentity(), {}, 0); + ASSERT_TRUE(status.ok()); + const laige::Result parsed = + laige::loadReplay(path); + ASSERT_TRUE(parsed.ok()); + EXPECT_TRUE(parsed.value().frames.empty()); + EXPECT_EQ(parsed.value().identity.seed, 42u); +} + +TEST(ReplayFormat, FrameAtTheCapRoundTrips) { + // A frame of EXACTLY kMaxReplayFrameBytes is legal and round-trips. + laige::ReplayFrame frame; + frame.tick = 1; + frame.data.assign(laige::kMaxReplayFrameBytes, 0xA5u); + const std::string path = tempPath("replay_maxframe.log"); + const std::vector frames{frame}; + ASSERT_TRUE(recordFrames(path, testIdentity(), frames, 0).ok()); + const laige::Result parsed = + laige::loadReplay(path); + ASSERT_TRUE(parsed.ok()); + ASSERT_EQ(parsed.value().frames.size(), 1u); + EXPECT_EQ(parsed.value().frames[0].data.size(), + static_cast(laige::kMaxReplayFrameBytes)); +} + +// Hand-builds a v1 log (bypassing the recorder) so the parser's +// individual violation branches can be exercised in isolation. +std::vector buildLog(std::uint16_t version, + const std::uint8_t* magic, + std::uint64_t seed, std::uint32_t rate, + std::uint64_t schemaHash, + std::uint32_t mathId, + std::uint64_t configHash, + std::vector>> + frames, + std::uint64_t trailerCount, + std::uint64_t trailerHashOverride) { + std::vector log; + log.insert(log.end(), magic, magic + 4); + appendU16le(log, version); + appendU16le(log, 0); // reserved + appendU64le(log, seed); + appendU32le(log, rate); + appendU64le(log, schemaHash); + appendU32le(log, mathId); + appendU64le(log, configHash); + for (const auto& [tick, data] : frames) { + appendU64le(log, tick); + appendU32le(log, static_cast(data.size())); + log.insert(log.end(), data.begin(), data.end()); + } + appendU64le(log, trailerCount); + if (trailerHashOverride != 0) { + appendU64le(log, trailerHashOverride); + } else { + appendU64le(log, fnv1aBytes(log.data(), log.size())); + } + return log; +} + +TEST(ReplayFormat, MalformedInputTable) { + // The full SCALE-005 violation table: every case is a MalformedInput + // (never a crash, never a silent skip — CORE-008, ARCH-007). + const std::uint8_t magic[4] = {'L', 'G', 'R', 'P'}; + const std::uint8_t badMagic[4] = {'L', 'G', 'R', 'Q'}; + const std::vector body(1, 0xABu); + auto expectMalformed = [&](const std::vector& log, + const char* name) { + const laige::Result r = + laige::parseReplay(log.data(), log.size()); + EXPECT_TRUE(r.isError()) << name; + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::MalformedInput) << name; + } + }; + + // 1. Truncation: EVERY cut of a valid log is malformed (never a + // crash). + { + const std::vector valid = buildLog( + laige::kReplayFormatVersion, magic, 42, 60, 7, 0, 8, + {{1, body}}, 1, 0); + for (std::size_t cut = 1; cut < valid.size(); ++cut) { + expectMalformed(std::vector(valid.begin(), + valid.begin() + + static_cast(cut)), + "truncated"); + } + } + // 2. Bad magic. + expectMalformed( + buildLog(laige::kReplayFormatVersion, badMagic, 42, 60, 7, 0, 8, {}, + 0, 0), + "bad_magic"); + // 3. Unsupported version. + expectMalformed( + buildLog(2, magic, 42, 60, 7, 0, 8, {}, 0, 0), "bad_version"); + // 4. Non-zero reserved field (built on a valid log, patched). + { + std::vector log = buildLog(laige::kReplayFormatVersion, + magic, 42, 60, 7, 0, 8, {}, 0, 0); + log[6] = 1; + expectMalformed(log, "reserved_nonzero"); + } + // 5. Frame length above kMaxReplayFrameBytes (a 0-length header + // lying about a 1 MiB + 1 payload: the length-field check must + // fire before any overrun read). + { + std::vector log = + buildLog(laige::kReplayFormatVersion, magic, 42, 60, 7, 0, 8, {}, 0, 0); + // Insert a frame record whose length field is kMaxReplayFrameBytes+1 + // (12 bytes of record, no payload). + std::vector patched; + patched.insert(patched.end(), log.begin(), + log.begin() + static_cast( + laige::kReplayHeaderSize)); + appendU64le(patched, 1); + appendU32le(patched, laige::kMaxReplayFrameBytes + 1); + patched.insert(patched.end(), log.end() - laige::kReplayTrailerSize, + log.end()); + expectMalformed(patched, "frame_length_over_cap"); + } + // 6. Out-of-sequence tick (frame 2 carries tick 3). + expectMalformed( + buildLog(laige::kReplayFormatVersion, magic, 42, 60, 7, 0, 8, + {{1, body}, {3, body}}, 2, 0), + "tick_sequence"); + // 7. Trailer frameCount mismatch (the hash is correct — the count + // branch fires on its own). + expectMalformed( + buildLog(laige::kReplayFormatVersion, magic, 42, 60, 7, 0, 8, {{1, body}}, + 2, 0), + "trailer_count_mismatch"); + // 8. fileHash mismatch (a flipped body byte; the hash branch fires). + { + std::vector log = buildLog(laige::kReplayFormatVersion, + magic, 42, 60, 7, 0, 8, {{1, body}}, + 1, 0); + log[laige::kReplayHeaderSize + 12] ^= 0xFFu; // flip the payload byte + expectMalformed(log, "hash_mismatch"); + } + // 9. Trailing garbage (append a byte past the trailer). + { + std::vector log = buildLog(laige::kReplayFormatVersion, + magic, 42, 60, 7, 0, 8, {}, 0, 0); + log.push_back(0u); + expectMalformed(log, "trailing_garbage"); + } + // 10. Empty input and null data. + expectMalformed({}, "empty_input"); + const laige::Result nullData = + laige::parseReplay(nullptr, 16); + ASSERT_TRUE(nullData.isError()); + EXPECT_EQ(nullData.error(), laige::ErrorCode::MalformedInput); +} + +TEST(ReplayFormat, LoadReplayFileErrors) { + // The file wrapper's error table: missing file -> IoError; empty + // path -> MalformedInput; an oversized file (size > cap) -> + // MalformedInput (the ADR 0003 bound precedent). + const laige::Result missing = + laige::loadReplay(tempPath("replay_missing.log")); + ASSERT_TRUE(missing.isError()); + EXPECT_EQ(missing.error(), laige::ErrorCode::IoError); + + const laige::Result emptyPath = + laige::loadReplay(""); + ASSERT_TRUE(emptyPath.isError()); + EXPECT_EQ(emptyPath.error(), laige::ErrorCode::MalformedInput); + + const std::string path = tempPath("replay_oversized.log"); + ASSERT_TRUE(recordFrames(path, testIdentity(), {}, 0).ok()); + const std::size_t size = + static_cast(std::filesystem::file_size(path)); + // cap = size - 1: the reader's total > cap check rejects (the + // ADR 0003 bound precedent — an oversized file is a MalformedInput, + // not a truncated parse). + const laige::Result oversize = + laige::loadReplay(path, size - 1); + ASSERT_TRUE(oversize.isError()); + EXPECT_EQ(oversize.error(), laige::ErrorCode::MalformedInput); +} + +// --------------------------------------------------------------------------- +// ReplayRecorder: atomicity, the size limit, the tick sequence +// --------------------------------------------------------------------------- + +TEST(ReplayRecorder, AtomicWrite) { + // A successful finish leaves the final file and removes the temp. + const std::string path = tempPath("replay_atomic.log"); + const std::string tmp = path + ".tmp"; + const std::vector frames = makeTestFrames(3, 4); + ASSERT_TRUE(recordFrames(path, testIdentity(), frames, 0).ok()); + EXPECT_TRUE(std::filesystem::exists(path)); + EXPECT_FALSE(std::filesystem::exists(tmp)); +} + +TEST(ReplayRecorder, InterruptedLeavesNoFile) { + // Destroying an unfinished recorder leaves NO file at the final + // path (the temp is removed — no partial replay on disk). + const std::string path = tempPath("replay_interrupted.log"); + const std::string tmp = path + ".tmp"; + { + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + ASSERT_TRUE(recorder.writeFrame(1, nullptr, 0).ok()); + // No finish(): the destructor cleans up. + } + EXPECT_FALSE(std::filesystem::exists(path)); + EXPECT_FALSE(std::filesystem::exists(tmp)); +} + +TEST(ReplayRecorder, SizeLimitStopsFrames) { + // cap = kMinReplaySizeLimit (header + trailer): frame 1 fits + // (40 + 12 = 52 <= 56), frame 2 exceeds the cap -> BudgetExhausted; + // the failure is sticky and finish cannot recover it. + const std::string path = tempPath("replay_sized.log"); + laige::Status second{}, again{}, finish{}; + bool finished = false; + { + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, + laige::kMinReplaySizeLimit); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + ASSERT_TRUE(recorder.writeFrame(1, nullptr, 0).ok()); + second = recorder.writeFrame(2, nullptr, 0); + // Sticky: every later call reports the same error. + again = recorder.writeFrame(2, nullptr, 0); + finish = recorder.finish(); + finished = recorder.finished(); + if (recorder.status().isError()) { + EXPECT_EQ(recorder.status().error(), laige::ErrorCode::BudgetExhausted); + } + } + ASSERT_TRUE(second.isError()); + EXPECT_EQ(second.error(), laige::ErrorCode::BudgetExhausted); + ASSERT_TRUE(again.isError()); + EXPECT_EQ(again.error(), second.error()); + ASSERT_TRUE(finish.isError()); + EXPECT_EQ(finish.error(), second.error()); + EXPECT_FALSE(finished); + // No partial log at the final path; the temp is removed by the + // destructor (now run). + EXPECT_FALSE(std::filesystem::exists(path)); + EXPECT_FALSE(std::filesystem::exists(path + ".tmp")); +} + +TEST(ReplayRecorder, SizeLimitAtFinish) { + // cap = 67 (header 40 + frame 1 record 12 + frame 2 record 12 + + // 3 payload bytes = 67): both frames fit exactly, but the trailer + // (16 bytes) would exceed the cap -> finish is a BudgetExhausted + // (the cap counts header + frames + trailer together). + const std::string path = tempPath("replay_finishcap.log"); + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 67); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + ASSERT_TRUE(recorder.writeFrame(1, nullptr, 0).ok()); + const std::uint8_t payload[3] = {0x01u, 0x02u, 0x03u}; + ASSERT_TRUE(recorder.writeFrame(2, payload, 3).ok()); + EXPECT_EQ(recorder.bytesWritten(), 67u); + const laige::Status finish = recorder.finish(); + ASSERT_TRUE(finish.isError()); + EXPECT_EQ(finish.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_FALSE(recorder.finished()); + EXPECT_FALSE(std::filesystem::exists(path)); + EXPECT_FALSE(std::filesystem::exists(path + ".tmp")); +} + +TEST(ReplayRecorder, TickSequence) { + // The strict 1, 2, 3, ... sequence: a first frame at tick 2 is an + // InvalidArgument; a repeated tick 1 is an InvalidArgument. + { + const std::string path = tempPath("replay_seq_a.log"); + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + const laige::Status s = recorder.writeFrame(2, nullptr, 0); + ASSERT_TRUE(s.isError()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + { + const std::string path = tempPath("replay_seq_b.log"); + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + ASSERT_TRUE(recorder.writeFrame(1, nullptr, 0).ok()); + const laige::Status s = recorder.writeFrame(1, nullptr, 0); + ASSERT_TRUE(s.isError()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } +} + +TEST(ReplayRecorder, WriteAfterFinishFails) { + // A finished recorder accepts no frames (InvalidArgument). + const std::string path = tempPath("replay_afterfinish.log"); + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder recorder = std::move(created).takeValue(); + ASSERT_TRUE(recorder.writeFrame(1, nullptr, 0).ok()); + ASSERT_TRUE(recorder.finish().ok()); + EXPECT_TRUE(recorder.finished()); + const laige::Status s = recorder.writeFrame(2, nullptr, 0); + ASSERT_TRUE(s.isError()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + const laige::Status finish = recorder.finish(); // finish is one-shot + ASSERT_TRUE(finish.isError()); + EXPECT_EQ(finish.error(), s.error()); +} + +TEST(ReplayRecorder, CreateValidation) { + // The create error table: empty path, a cap below the minimum + // (kMinReplaySizeLimit), and the default cap (0). + laige::Result emptyPath = + laige::ReplayRecorder::create(testIdentity(), "", 0); + ASSERT_TRUE(emptyPath.isError()); + EXPECT_EQ(emptyPath.error(), laige::ErrorCode::InvalidArgument); + + const std::string path = tempPath("replay_min.log"); + laige::Result tiny = + laige::ReplayRecorder::create(testIdentity(), path, + laige::kMinReplaySizeLimit - 1); + ASSERT_TRUE(tiny.isError()); + EXPECT_EQ(tiny.error(), laige::ErrorCode::InvalidArgument); + + // maxBytes == 0 means the default cap; the recorder then reports + // the header size as bytes written. + laige::Result ok = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(ok.ok()); + laige::ReplayRecorder recorder = std::move(ok).takeValue(); + EXPECT_EQ(recorder.bytesWritten(), laige::kReplayHeaderSize); + EXPECT_EQ(recorder.frameCount(), 0u); + EXPECT_STREQ(recorder.path(), path.c_str()); + EXPECT_TRUE(recorder.status().ok()); +} + +TEST(ReplayRecorder, MoveTransfersTheFile) { + // A moved-from recorder is finished (its destructor cleans nothing); + // the destination owns the file and can finish normally. + const std::string path = tempPath("replay_move.log"); + laige::Result created = + laige::ReplayRecorder::create(testIdentity(), path, 0); + ASSERT_TRUE(created.ok()); + laige::ReplayRecorder source = std::move(created).takeValue(); + ASSERT_TRUE(source.writeFrame(1, nullptr, 0).ok()); + laige::ReplayRecorder destination(std::move(source)); + EXPECT_TRUE(source.finished()); // the source is now inert + ASSERT_TRUE(destination.writeFrame(2, nullptr, 0).ok()); + ASSERT_TRUE(destination.finish().ok()); + EXPECT_TRUE(std::filesystem::exists(path)); +} + +// --------------------------------------------------------------------------- +// ReplayIdentity: the pure-integer FNV-1a hashes (ADR 0002, ARCH-010) +// --------------------------------------------------------------------------- + +// Test components (global scope: LAIGE_COMPONENT specializes the +// trait at global scope). Two distinct types, so the schema-hash +// ordering test can contrast registration orders. +struct ReplayTag { + std::int32_t value{}; +}; +LAIGE_COMPONENT(ReplayTag); + +struct ReplayTag2 { + std::int32_t value{}; + std::int32_t other{}; +}; +LAIGE_COMPONENT(ReplayTag2); + +TEST(ReplayIdentity, SchemaHashIsRegistrationOrder) { + // Two worlds that register the same types in the SAME order hash + // identically; a different registration order (different dense ids) + // hashes differently. No addresses enter the hash (ARCH-010). + laige::World::Options options; + options.capacity = 16; + laige::Result r1 = + laige::World::create(options); + laige::Result r2 = + laige::World::create(options); + laige::Result r3 = + laige::World::create(options); + ASSERT_TRUE(r1.ok() && r2.ok() && r3.ok()); + laige::World w1 = std::move(r1).takeValue(); + laige::World w2 = std::move(r2).takeValue(); + laige::World w3 = std::move(r3).takeValue(); + // w1, w2: A then B. w3: B then A. + ASSERT_TRUE(w1.registerComponent().ok()); + ASSERT_TRUE(w2.registerComponent().ok()); + ASSERT_TRUE(w1.registerComponent().ok()); + ASSERT_TRUE(w2.registerComponent().ok()); + ASSERT_TRUE(w3.registerComponent().ok()); + ASSERT_TRUE(w3.registerComponent().ok()); + + const std::uint64_t h1 = laige::componentSchemaHash(w1); + const std::uint64_t h2 = laige::componentSchemaHash(w2); + const std::uint64_t h3 = laige::componentSchemaHash(w3); + EXPECT_EQ(h1, h2); // same order -> same hash + EXPECT_NE(h1, h3); // different order -> different ids -> different + // Machine-greppable line (docs/testing.md §4). + std::printf("replay-identity schema-order h1=0x%016llx h3=0x%016llx\n", + static_cast(h1), + static_cast(h3)); +} + +TEST(ReplayIdentity, ConfigHashDiffersPerField) { + // The config hash is a pure function of the config's fields: same + // config -> same hash; any field change -> different hash. + laige::EngineConfig base; + base.tickRateHz = 60; + base.entityCapacity = 1024; + base.churnPerFrameBudget = 256; + base.seed = 7; + + laige::EngineConfig other = base; + EXPECT_EQ(laige::configHash(base), laige::configHash(other)); + + other.seed = 8; + EXPECT_NE(laige::configHash(base), laige::configHash(other)); + + other = base; + other.tickRateHz = 120; + EXPECT_NE(laige::configHash(base), laige::configHash(other)); + + other = base; + other.entityCapacity = 2048; + EXPECT_NE(laige::configHash(base), laige::configHash(other)); + + other = base; + other.determinism.enabled = false; + EXPECT_NE(laige::configHash(base), laige::configHash(other)); + + other = base; + other.determinism.math = laige::SimMathBackend::FloatPinned32; + EXPECT_NE(laige::configHash(base), laige::configHash(other)); +} + +TEST(ReplayIdentity, MakeIdentityEchoesTheFields) { + // makeReplayIdentity assembles (seed, tickRate, schemaHash, mathId, + // configHash) from (world, config). + laige::World::Options options; + options.capacity = 8; + options.seed = 99; + laige::Result wResult = + laige::World::create(options); + ASSERT_TRUE(wResult.ok()); + laige::World w = std::move(wResult).takeValue(); + ASSERT_TRUE(w.registerComponent().ok()); + + laige::EngineConfig config; + config.tickRateHz = 90; + config.entityCapacity = 8; + config.seed = 99; + config.determinism.enabled = true; + config.determinism.math = laige::SimMathBackend::FixedPoint16_16; + + const laige::ReplayIdentity identity = + laige::makeReplayIdentity(w, config); + EXPECT_EQ(identity.seed, 99u); + EXPECT_EQ(identity.tickRateHz, 90u); + EXPECT_EQ(identity.mathBackendId, + static_cast(laige::SimMathBackend::FixedPoint16_16)); + EXPECT_EQ(identity.componentSchemaHash, laige::componentSchemaHash(w)); + EXPECT_EQ(identity.configHash, laige::configHash(config)); +} + +// --------------------------------------------------------------------------- +// ReplayEngine: the engine's per-tick empty-frame recording +// (M1-DET-02: FR-1.4, PRD Appendix A, ADR 0002) +// --------------------------------------------------------------------------- + +TEST(ReplayEngine, RecordsEmptyFramesPerTick) { + // An engine run with recording ON records one zero-length frame per + // COMPLETED tick, and the log's identity matches the run's identity + // (seed, tick rate, schema hash, math backend, config hash). + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 8; + config.seed = 42; + + laige::Result created = + laige::Engine::create(config); + ASSERT_TRUE(created.ok()); + laige::Engine engine = std::move(created).takeValue(); + // Capture the expected identity BEFORE the run (the world is owned + // by the engine and released in the run's shutdown). + const laige::ReplayIdentity expected = + laige::makeReplayIdentity(*engine.world(), config); + + const std::string path = tempPath("replay_engine.log"); + ASSERT_TRUE(engine.startReplayRecording(path, 0).ok()); + EXPECT_TRUE(engine.replayRecordingActive()); + EXPECT_EQ(engine.replayBytesWritten(), laige::kReplayHeaderSize); + + ASSERT_TRUE(engine.run_headless(8, laige::kDefaultMaxCatchUpTicks).ok()); + EXPECT_EQ(engine.stats().ticks, 8u); + EXPECT_TRUE(engine.isShutDown()); + + // The log appears at the final path only after the successful run. + ASSERT_TRUE(std::filesystem::exists(path)); + const laige::Result parsed = + laige::loadReplay(path); + ASSERT_TRUE(parsed.ok()); + const laige::ReplayLog& log = parsed.value(); + EXPECT_EQ(log.identity.seed, expected.seed); + EXPECT_EQ(log.identity.tickRateHz, expected.tickRateHz); + EXPECT_EQ(log.identity.componentSchemaHash, expected.componentSchemaHash); + EXPECT_EQ(log.identity.mathBackendId, expected.mathBackendId); + EXPECT_EQ(log.identity.configHash, expected.configHash); + // 8 zero-length frames: header + 8 x 12-byte records + trailer. + ASSERT_EQ(log.frames.size(), 8u); + for (std::size_t i = 0; i < log.frames.size(); ++i) { + EXPECT_EQ(log.frames[i].tick, i + 1); + EXPECT_TRUE(log.frames[i].data.empty()); // M1: no input yet + } + // Machine-greppable line (docs/testing.md §4). + std::printf("replay-engine ticks=%u bytes=%zu schema=0x%016llx\n", + static_cast(engine.stats().ticks), + log.frames.size(), + static_cast(expected.componentSchemaHash)); +} + +TEST(ReplayEngine, RecordingFailureStopsTheRun) { + // A size-limit breach mid-run STOPS the run: run_headless returns + // the BudgetExhausted Status, no partial log is left at the final + // path, and the structured events land (replay/record_failed Error, + // replay/record_aborted Warn in the ordered shutdown). + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 8; + config.seed = 42; + + laige::Result created = + laige::Engine::create(config); + ASSERT_TRUE(created.ok()); + laige::Engine engine = std::move(created).takeValue(); + const std::string path = tempPath("replay_engine_fail.log"); + // cap = header + trailer: frame 1 fits, frame 2 breaches. + ASSERT_TRUE( + engine.startReplayRecording(path, laige::kMinReplaySizeLimit).ok()); + + MemorySink* sink = installCaptureSink(); + const laige::Status status = + engine.run_headless(8, laige::kDefaultMaxCatchUpTicks); + // Read the capture BEFORE restoreLogger (re-init retires the sink — + // the engine_tests.cpp pattern: sink assertions come first). + const std::size_t recordFailed = countEvents(*sink, "record_failed"); + const std::size_t recordAborted = countEvents(*sink, "record_aborted"); + restoreLogger(); + + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_TRUE(engine.isShutDown()); // the ordered shutdown still ran + EXPECT_FALSE(std::filesystem::exists(path)); // no partial log + EXPECT_FALSE(std::filesystem::exists(path + ".tmp")); + EXPECT_EQ(recordFailed, 1u); + EXPECT_EQ(recordAborted, 1u); +} + +TEST(ReplayEngine, DoubleStartFails) { + // One recording per run: the second startReplayRecording is an + // InvalidArgument plus the structured warn (replay/record_already_ + // started). + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 8; + laige::Result created = + laige::Engine::create(config); + ASSERT_TRUE(created.ok()); + laige::Engine engine = std::move(created).takeValue(); + + MemorySink* sink = installCaptureSink(); + const std::string path = tempPath("replay_double.log"); + ASSERT_TRUE(engine.startReplayRecording(path, 0).ok()); + const laige::Status second = engine.startReplayRecording(path, 0); + // Read the capture BEFORE restoreLogger (re-init retires the sink). + const std::size_t alreadyStarted = + countEvents(*sink, "record_already_started"); + restoreLogger(); + ASSERT_TRUE(second.isError()); + EXPECT_EQ(second.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(alreadyStarted, 1u); +} + +TEST(ReplayEngine, StartAfterRunFails) { + // A stopped engine (post-run) is a no-op failure without logging — + // the stopped-state precedent. + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 8; + laige::Result created = + laige::Engine::create(config); + ASSERT_TRUE(created.ok()); + laige::Engine engine = std::move(created).takeValue(); + ASSERT_TRUE(engine.run_headless(1, laige::kDefaultMaxCatchUpTicks).ok()); + + MemorySink* sink = installCaptureSink(); + const std::size_t entriesBefore = sink->entries.size(); + const laige::Status status = + engine.startReplayRecording(tempPath("replay_postrun.log"), 0); + // Read the capture BEFORE restoreLogger (re-init retires the sink). + const std::size_t entriesAfter = sink->entries.size(); + restoreLogger(); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(entriesAfter, entriesBefore); // no log (precedent) + EXPECT_FALSE(engine.replayRecordingActive()); + EXPECT_EQ(engine.replayBytesWritten(), 0u); +} diff --git a/tools/README.md b/tools/README.md index 4b35ab7..b7f72c2 100644 --- a/tools/README.md +++ b/tools/README.md @@ -8,15 +8,19 @@ Engine tools and CI scripts, each landing with its roadmap step: server form), and the ordered idempotent shutdown. Exit codes: `0` ok · `1` engine run failure · `2` usage/IO/config error; one machine-greppable summary line on stdout (`laige-run headless - ticks=… status=…`). `--replay` is the M1-DET-02 stub (accepted, - warned, ignored). Full contract in - [docs/api/engine.md](../docs/api/engine.md); the `laige_run_smoke` + ticks=… status=…`). `--replay` records the run (M1-DET-02: + opt-in, debug builds only, atomic publish, 128 MiB default cap). + Full contract in + [docs/api/engine.md](../docs/api/engine.md) and + [docs/api/replay.md](../docs/api/replay.md); the `laige_run_smoke` CTest entry (1000 ticks @ 60 Hz, every P0 OS job) is its CI form. - `laige-fuzz` — deterministic bounded fuzz runner (minimal form from - M0-CORE-07, in `tools/fuzz`: the `json_parse` target, `--runs`/`--seed`, + M0-CORE-07, in `tools/fuzz`: the `json_parse` and `replay_parse` + targets (M1-DET-02 added the replay log parser), `--runs`/`--seed`, built with `LAIGE_BUILD_TESTS=ON`, registered as the `fuzz_json_parse` - CTest entry — bounded fuzz in every commit, PRD §14; M0-TEST-01 - extends it: CI lane semantics, nightly long runs, seed documentation) + and `fuzz_replay_parse` CTest entries — bounded fuzz in every + commit, PRD §14; M0-TEST-01 extends it: CI lane semantics, nightly + long runs, seed documentation) - `laige-include-lint` — include-graph lint + vendored-dependency-count metric over `src/**` (M0-CI-03). Pure Python 3 stdlib; run it as `python3 tools/laige-include-lint [--root REPO_ROOT]`. Enforces the PRD diff --git a/tools/fuzz/CMakeLists.txt b/tools/fuzz/CMakeLists.txt index 4f92a4b..72ac911 100644 --- a/tools/fuzz/CMakeLists.txt +++ b/tools/fuzz/CMakeLists.txt @@ -7,7 +7,10 @@ add_executable(laige-fuzz laige-fuzz.cpp) laige_apply_engine_policy(laige-fuzz) -target_link_libraries(laige-fuzz PRIVATE laige-core) +# M1-DET-02: the replay_parse target exercises laige-sim's replay log +# parser — laige-sim joins the link (arrows only downward, PRD +# §10.1); laige-core's public headers come through it (CPP-010). +target_link_libraries(laige-fuzz PRIVATE laige-sim) # Bounded fuzz in every commit (PRD §14: "every commit (bounded), nightly # (long)"): 1000 deterministic inputs to the json_parse target (NFR-8.7, @@ -15,8 +18,16 @@ target_link_libraries(laige-fuzz PRIVATE laige-core) # the run is instrumented, so any crash/UB fails ctest loudly. add_test(NAME fuzz_json_parse COMMAND laige-fuzz json_parse --runs=1000) set_tests_properties(fuzz_json_parse PROPERTIES TIMEOUT 120) +# M1-DET-02: the replay log parser's malformed-input surface (SCALE-005 +# / ARCH-007) gets the same bounded-every-commit gate — 1000 +# deterministic inputs; the corpus includes a valid v1 replay log as +# a mutate/truncate base. +add_test(NAME fuzz_replay_parse COMMAND laige-fuzz replay_parse --runs=1000) +set_tests_properties(fuzz_replay_parse PROPERTIES TIMEOUT 120) if(LAIGE_TSAN) # Same fatal-race policy as the module test entries (NFR-8.2). set_tests_properties(fuzz_json_parse PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + set_tests_properties(fuzz_replay_parse PROPERTIES + ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tools/fuzz/laige-fuzz.cpp b/tools/fuzz/laige-fuzz.cpp index 384fa72..7c1ad4d 100644 --- a/tools/fuzz/laige-fuzz.cpp +++ b/tools/fuzz/laige-fuzz.cpp @@ -2,11 +2,13 @@ // form; M0-TEST-01 extends it: CI lane semantics, nightly long runs, // seed-handling documentation). // -// Roadmap step M0-CORE-07 lands the *minimal* runner this Verify gate -// requires: `laige-fuzz json_parse --runs=1000` clean under ASan -// (NFR-8.7: parsers are fuzzed in CI, bounded every commit — PRD §14). -// The `json_parse` target is registered below; later steps add their -// targets to kTargets. +// Roadmap step M0-CORE-07 landed the *minimal* runner this Verify +// gate requires: `laige-fuzz json_parse --runs=1000` clean under ASan +// (NFR-8.7: parsers are fuzzed in CI, bounded every commit — PRD +// §14). The `json_parse` and `replay_parse` targets are registered +// below (M1-DET-02 added the latter: the replay log parser's +// malformed-input surface, SCALE-005 / ARCH-007); later steps add +// their targets to kTargets. // // Design (deterministic by construction): // - Every input is generated from laige::Prng (M0-CORE-06): a fixed @@ -39,6 +41,7 @@ #include "laige/json.h" #include "laige/prng.h" +#include "laige/sim/replay.h" // parseReplay (M1-DET-02: the replay parser) namespace { @@ -46,6 +49,64 @@ constexpr std::uint64_t kDefaultSeed = 0x1F055EEDull; // "one-fuzz-seed" constexpr int kDefaultRuns = 1000; constexpr std::size_t kMaxInputBytes = 64; +// FNV-1a 64 constants (fnv.org — the same convention the replay +// parser checks, laige/sim/replay.h). The local encoder below needs +// only the byte-stream form. +constexpr std::uint64_t kFnvBasis = 0xcbf29ce484222325ull; +constexpr std::uint64_t kFnvPrime = 0x100000001b3ull; + +std::uint64_t fnv1aBytes(const std::uint8_t* bytes, std::size_t count) { + std::uint64_t h = kFnvBasis; + for (std::size_t i = 0; i < count; ++i) { + h ^= static_cast(bytes[i]); + h *= kFnvPrime; + } + return h; +} + +void pushU16le(std::vector& out, std::uint16_t value) { + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); +} +void pushU32le(std::vector& out, std::uint32_t value) { + for (int i = 0; i < 4; ++i) { + out.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } +} +void pushU64le(std::vector& out, std::uint64_t value) { + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } +} + +// A minimal VALID v1 replay log (header + one zero-length frame + +// trailer) — a corpus base for the mutate/truncate modes of the +// replay_parse target (NFR-8.7: the parser gets real-shape input, not +// only random bytes). Mirrors the format in laige/sim/replay.h +// (SCALE-005); the trailer's fileHash is the canonical byte-stream +// FNV-1a 64 the parser checks. +std::string buildValidReplayLog() { + std::vector log; + log.push_back('L'); + log.push_back('G'); + log.push_back('R'); + log.push_back('P'); // magic + pushU16le(log, 1); // formatVersion + pushU16le(log, 0); // reserved + pushU64le(log, 42); // seed + pushU32le(log, 60); // tickRateHz + pushU64le(log, 0); // componentSchemaHash + pushU32le(log, 0); // mathBackendId (FixedPoint16_16) + pushU64le(log, 0); // configHash + pushU64le(log, 1); // frame tick + pushU32le(log, 0); // frame byteLength (M1: zero-length frame) + // The trailer (frameCount + the fileHash over everything before it). + const std::uint64_t bodyHash = fnv1aBytes(log.data(), log.size()); + pushU64le(log, 1); // frameCount + pushU64le(log, bodyHash); + return std::string(log.begin(), log.end()); +} + // Valid-document corpus: the base for mutate/truncate inputs. Kept ASCII // (explicit bytes for the one non-ASCII document) so the corpus bytes are // identical on every platform (narrow \x escapes are not portable). @@ -74,6 +135,10 @@ const std::vector kCorpus = { "{\"a\":1,\"b\":null}", "{\"a\":{\"b\":[1,{\"c\":\"x\"}]}}", " \t\r\n [ 1 , 2 ] \r\n ", + // A valid v1 replay log (68 bytes) — the replay_parse target's + // mutate/truncate base (it is also fed to json_parse, where it + // is simply another invalid document). + buildValidReplayLog(), }; // --------------------------------------------------------------------------- @@ -91,8 +156,18 @@ void fuzzJsonParse(const std::uint8_t* data, std::size_t size) { (void)result; // any Status is acceptable; only a crash fails the run } +// M1-DET-02: the replay log parser (SCALE-005 / ARCH-007 — the +// malformed-input surface). Any MalformedInput/IoError outcome is +// acceptable; only a crash (or sanitizer report) fails the run. +void fuzzReplayParse(const std::uint8_t* data, std::size_t size) { + const laige::Result result = + laige::parseReplay(data, size); + (void)result; +} + const FuzzTarget kTargets[] = { {"json_parse", &fuzzJsonParse}, + {"replay_parse", &fuzzReplayParse}, }; void printUsage() { diff --git a/tools/run/laige-run.cpp b/tools/run/laige-run.cpp index d1d03fb..4569a13 100644 --- a/tools/run/laige-run.cpp +++ b/tools/run/laige-run.cpp @@ -17,11 +17,16 @@ // --ticks N run until N completed ticks (N = 0 or // omitted: the server form — run until the // process ends) -// --replay STUB (M1-DET-02): the flag is accepted so -// the CLI is stable from M1, and a -// structured warn explains that replay -// recording is not implemented yet -// (never silent — CORE-008) +// --replay REPLAY RECORDING (M1-DET-02): record the +// run's replay log (versioned format, +// laige/sim/replay.h) at — opt-in, +// DEBUG BUILDS ONLY (release builds exit 2 +// with the structured replay/record_ +// disabled warn). The log is written +// atomically (temp + rename) and appears +// at only when the run succeeds. +// Size limit: kDefaultReplaySizeLimit +// (128 MiB) // // Exit codes (documented, stable for CI grepping): // 0 the run completed (the requested ticks reached; the summary @@ -64,6 +69,7 @@ #include "laige/result.h" #include "laige/sim/engine.h" #include "laige/sim/game_loop.h" +#include "laige/sim/replay.h" namespace { @@ -72,15 +78,6 @@ namespace { // file is a MalformedInput, not a truncated parse. inline constexpr std::size_t kMaxConfigBytes = 1u << 20; -// NFR-13.3 5-field grammar for the replay stub (stable text; the log -// path is a structured field — LOG-005, never raw message text). -inline constexpr const char* kReplayDeferredMessage = - "replay_deferred | the --replay flag was accepted but replay " - "recording is not implemented | replay recording lands with " - "M1-DET-02 (M1-HEAD-01 wires only the flag, keeping the CLI " - "stable) | remove --replay, or wait for M1-DET-02 | " - "docs/api/engine.md"; - void printUsage(std::FILE* out) { std::fprintf(out, "Usage: laige-run --headless [--ticks N] " @@ -93,8 +90,13 @@ void printUsage(std::FILE* out) { " --ticks N run until N completed ticks (N = 0\n" " or omitted: the server form — run\n" " until the process ends)\n" - " --replay STUB (M1-DET-02): accepted; replay\n" - " recording is not implemented yet\n" + " --replay record the run's replay log at\n" + " (opt-in; DEBUG BUILDS ONLY —\n" + " release builds exit 2; written\n" + " atomically; appears at only\n" + " when the run succeeds; the size\n" + " limit is kDefaultReplaySizeLimit,\n" + " 128 MiB)\n" " --help, -h this help\n" "\n" "Exit codes: 0 = ok, 1 = engine run failure, 2 = usage / IO / " @@ -213,13 +215,6 @@ int main(int argc, char** argv) { return 2; } - // The replay stub (M1-DET-02): accepted, WARNED, ignored — never - // silent (CORE-008). - if (!replayPath.empty()) { - LAIGE_LOG_WARN("replay", "replay_deferred", kReplayDeferredMessage, - laige::log::field("log", replayPath)); - } - std::string document; const laige::Status readStatus = readConfigFile(configPath, &document); if (readStatus.isError()) { @@ -249,6 +244,21 @@ int main(int argc, char** argv) { return 2; } laige::Engine engine = std::move(engineResult).takeValue(); + // Replay recording (M1-DET-02): opt-in, debug builds only. The + // engine's built-in registration is complete at creation (laige-run + // registers no game components of its own), so the identity capture + // is at the right phase: after all registration, before the run. + // A failure here is an exit-2 usage/IO error (the flag's contract): + // the run did not happen. + if (!replayPath.empty()) { + const laige::Status replayStatus = engine.startReplayRecording( + replayPath, laige::kDefaultReplaySizeLimit); + if (replayStatus.isError()) { + std::fprintf(stderr, "laige-run: replay: %s\n", + laige::errorText(replayStatus.error())); + return 2; + } + } const laige::Status runStatus = engine.run_headless(maxTicks, laige::kDefaultMaxCatchUpTicks); const laige::GameLoopStats stats = engine.stats(); From 64116307059ed72656c6f1f6908580d485e22753 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 16 Sep 2026 15:55:39 +0200 Subject: [PATCH 2/2] [M1-DET-02] Roadmap: change log entry (M1-DET-01 -> M1-DET-02) --- roadmap/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/roadmap/README.md b/roadmap/README.md index 69da44c..02f0ecc 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -209,6 +209,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | | 2026-09-15 | M1-HEAD-01 | `e80ccc8` / PR #31 | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | | 2026-09-15 | M1-DET-01 | `56f2835` / PR #34 | Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/determinism.h` — `SimMathBackend` (`FixedPoint16_16` default / `FloatPinned32`), `DeterminismConfig {enabled, math}`, the G-R8 compile-time trait (`detail::IsDeterminismSafe`: false by default; true for integers, enums, `fpx16_16`, `float` (the fp32_pinned Scalar), the four `SimMath::Vec2/Vec3`; `double` intentionally never safe — no backend uses it), `detail::areDeterminismSafeMembers` (the &&-fold), and `LAIGE_DETERMINISM_SAFE(Type, Members...)` (declares the member list IS the storage; a non-safe member — e.g. `double` — is a compile error AT THE MARK SITE, a new `static_assert` in the specialization, before any system can use the component); the trait is enforced by a third `static_assert` in `World::registerSystem` (entity.h) folding `detail::IoComponentSafety>` over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: `World::Options` gains `seed`/`deterministic` (world state carried through create/move/assign; entity.cpp), `registerSystem` derives each system's substream `Prng::deriveSubstream(seed, systemId)` (id 0 = master, never assigned) into `detail::SystemRecord.rng` (`std::optional`), and `runSystems` hands the NON-const record's stream to `SystemContext.rng` (a new `Prng*` field, NSDMI — advanced in place during draws: the stream state IS the replay state); `EngineConfig` appends `seed` (full u64, `kDefaultSimulationSeed = 0`) + `DeterminismConfig determinism` (existing 3-member aggregate inits keep compiling); `parseEngineConfig` gains `seed` (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the `determinism` object (`enabled` bool, `math` ∈ the two ids; unknown nested key → one `config/unknown_key` warn, ignored — first failure wins across keys) with new rejection events `config/seed_invalid` / `config/determinism_invalid` / `config/determinism_enabled_invalid` / `config/determinism_math_invalid`; `Engine::create` forwards seed + mode to the world and registers the backend-matching built-in FIRST (`Position2DFpx16` / `Position2DFp32`) and builds the presentation snapshot for the same backend (type-erased `detail::PresentationHandle` — one setup allocation, the fnptr-deleter `unique_ptr` idiom, zero added allocations: the headless setup path stays exactly 3); `engine/run_started` gains `seed`/`determinism`/`math` fields (the `laige-run` CLI summary line is unchanged); `tools/laige-determinism-lint` (NEW; Python 3 stdlib, the `laige-include-lint` style) — the sim-source scan over `src/laige-sim/**`: D1a raw `float`/`double` type tokens, D1b float literals, D1c double literals, D2 `unordered_{map,set,multimap,multiset}`, D3 malformed exception markers; a char scanner strips `//`/`/* */` comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: `Float`/`fromFloat`/`next_float01` do not match); the documented false-positive policy = same-line `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own `float` registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: `tests/laige-sim/determinism_tests.cpp` (suites `DeterminismMode`/`DeterminismEngine`/`DeterminismConfigParse`; CTest `determinism_mode` = the step's Verify command) — a trivial moving-entity sim (two entities, `Position2DFpx16` + a marked `DetVel` component, a mover system doing one `ctx.rng->next_range(0,5)` draw per tick at a fixed position + `pos += vel` through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable `determinism-tick-stream` line); a different seed diverges; the system's draws equal an independently constructed `Prng::deriveSubstream(seed, id)` exactly (golden cross-check) and two systems' streams are independent; `deterministic == false` → `ctx.rng == nullptr`; backend selection (fp32 config → `Position2DFp32` duplicate-rejected / `Position2DFpx16` available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). `tests/laige-sim/compile_fail/` (4 fixtures + `expect-compile-result.cmake.in`, CTest `trait_compile_*`): the positive fixture compiles (exit 0); the three negatives (a `double` member, an unmarked user struct, a `double` in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). `tests/tools` gains the `determinism-lint-*` fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: `determinism-lint` job added to BOTH `.github/workflows/ci-pull.yml` and `ci.yml` (ubuntu-24.04, `python3 tools/laige-determinism-lint`). Docs (DOC-007, same change): NEW `docs/concepts/determinism.md` (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW `docs/api/determinism.md` (the trait API contract) + updates to `docs/api/engine.md` (the seed/determinism keys, backend selection, run_started fields, the determinism scope), `docs/api/system_registry.md` (the `ctx.rng` bullet + the G-R8 validation row), `docs/api/entity.md` (the `World::Options` seed/deterministic fields), `docs/api/sim_math.md` (the G-R8 enforcement note), `docs/testing.md` (the determinism test entries), `docs/concepts/README.md`, `docs/README.md`, `src/laige-sim/README.md`. `laige-api.json` regenerated (573 → 588 symbols; +15: `SimMathBackend` + 2, `DeterminismConfig` + 2, `LAIGE_DETERMINISM_SAFE`, `World::Options` + 2, `SystemContext::rng`, `EngineConfig` + 2, `kDefaultSimulationSeed`; `api-real-tree` green). Verified: `ctest -R determinism_mode` green (14/14), `ctest -R trait_compile` green (4/4), `ctest -R determinism-lint` green (3/3), `python3 tools/laige-include-lint` OK, `python3 tools/laige-determinism-lint` OK (17 files, 15 marked exceptions), full `ctest` 55/55 on `build` (Debug GCC 16.2.1) and 55/55 on `build-asan` (ASan+UBSan leak-free); zero new warnings under NFR-8.10. **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the `seed`/`determinism` keys sit on the PROVISIONAL `parseEngineConfig` surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. | +| 2026-09-16 | M1-DET-02 | `817ebe9` | Replay recorder (FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed — ADR 0002, ARCH-007, SCALE-005; M1-DET-02 scope, nothing else; replay EXECUTION — `world.state_hash` + the `laige-replay` runner — is M1-DET-03): the versioned replay LOG FORMAT (v1; `src/laige-sim/include/laige/sim/replay.h` + `replay.cpp`): 40-byte header — magic `LGRP`, `formatVersion` u16 (= 1, the single version gate — unsupported versions rejected explicitly, ARCH-007), reserved u16, then the ADR 0002 REPLAY IDENTITY: seed u64, tickRateHz u32, `componentSchemaHash` u64, `mathBackendId` u32, `configHash` u64 — + per-tick frame records (tick u64, strictly sequential from 1; byteLength u32 ≤ `kMaxReplayFrameBytes` = 1 MiB; the payload an OPAQUE byte blob — M1 frames are zero-length, the input data shape lands with M3-INPUT-03) + 16-byte trailer (frameCount u64 + `fileHash` u64 = canonical byte-stream FNV-1a 64 over every prior byte — truncation and bit rot self-detected); all integers little-endian on every platform (SCALE-005: byte order specified, not assumed); the PARSER is total over malformed input: every structural violation (size < header, null data, bad magic, unsupported version, non-zero reserved, frame length over cap — checked before any overrun read, payload past the body, tick not previous+1, trailer count mismatch, fileHash mismatch, trailing garbage) is a `MalformedInput` Status — never a crash, never a silent skip (CORE-008/TEST-005); the IDENTITY HASHES are pure integers (ARCH-010: no addresses, no wall clock) — word-stream FNV-1a 64, big-endian byte order per u64 (the house convention: determinism state hashes, Prng golden vectors, laige-detcheck): `componentSchemaHash(World)` over [componentCount, then per registered type in id order: id, size, alignment] (a function of the REGISTRATION order — same types same order → same hash; stack-only, 769 words max, no allocation), `configHash(EngineConfig)` over [tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math] (M1-CFG-01 refines the encoding with the schema, under the format's versioning), `makeReplayIdentity(World, EngineConfig)` assembles the header; the REPLAY RECORDER (`ReplayRecorder`, move-only: `create(identity, path, maxBytes)` → `writeFrame(tick, data, len)` → `finish()`): writes `path + ".tmp"` (same filesystem — the final `rename` is atomic; MSVC `MoveFileExA(MOVEFILE_REPLACE_EXISTING)`) and publishes `path` only on a successful finish — an interrupted/failed recorder leaves NO file at the final path (temp removed by the destructor; a rename failure leaves the temp for inspection, documented), SIZE-BOUNDED (maxBytes counts header + frames + trailer together; 0 = `kDefaultReplaySizeLimit` 128 MiB; a cap below `kMinReplaySizeLimit` = 56 rejected at create as `InvalidArgument`; a cap breach mid-run is `BudgetExhausted`, sticky — every later call returns the same Status; `finish()` on a cap that cannot fit the trailer is the same error), strict 1,2,3,... tick sequence (`InvalidArgument` otherwise), frame over 1 MiB `InvalidArgument`, empty path `InvalidArgument`, open/write/rename I/O `IoError`; the READER: `parseReplay(const uint8_t*, size)` (in-memory, O(size), one output allocation per frame) and `loadReplay(path, maxBytes = default)` (bounded read — a file larger than maxBytes is a `MalformedInput`, the ADR 0003 JSON-bound precedent; missing file `IoError`, empty path `MalformedInput`); the ENGINE WIRING (`engine.{h,cpp}`): `Engine::startReplayRecording(path, maxBytes)` — called ONCE, after all component/system registration, before `run_headless` (the identity is captured from the live world + config at call time — a later registration makes the recorded schema hash stale, the schedule-stale precedent, M1-SYS-02) — OPT-IN and DEBUG BUILDS ONLY (`NDEBUG` → `InvalidArgument` + one `replay/record_disabled` warn — recording is development tooling; the format and wiring exist in every build, the opt-in does not), disabled cost one null check per tick (PERF-003/LOG-003), enabled cost one bounded stdio write per completed tick (the explicit, opt-in, visible cost — PERF-002); the loop's `onTick` hook writes one ZERO-LENGTH frame per COMPLETED tick (M1: no input system yet — the frame bytes are the future input blob); a recording failure mid-run STOPS the run: `run_headless` returns the recorder's Status (≤ 1 frame of extra ticks, the loop's bounded frame contract), the log is NOT published (no partial file at the final path), and the ordered shutdown still runs (CONC-006); a successful bounded run finalizes (trailer + atomic rename) and logs `replay/record_finished` (Info: path, bytes, frames); a failed run's shutdown logs `replay/record_aborted` (Warn: path, bytes) and discards the temp; a second start is `InvalidArgument` + `replay/record_already_started` (Warn); a stopped engine's start is a no-op failure WITHOUT logging (the stopped-state precedent); accessors `replayRecordingActive()`/`replayBytesWritten()`; move ctor/assign transfer the active recorder (move-assign abandons the source's via shutdown); structured events, subsystem `replay` (NFR-13.3 5-field grammar, rate-limited where repeated): `record_started` (Info: path, size_limit, seed, math_backend, config_hash, schema_hash), `record_finished` (Info), `record_failed` (Error: path, tick, error), `record_aborted` (Warn), `record_already_started` (Warn), `record_start_failed` (Warn), `record_disabled` (Warn, release builds); the CLI (`tools/run/laige-run.cpp`): `--replay LOG` is now REAL (it was the M1-HEAD-01 stub — accepted, warned, ignored): laige-run registers no game components (the built-in registration is complete at `create`), so the identity capture is at the right phase; on a clean bounded run the log is atomically published at LOG; a start failure exits 2 (`laige-run: replay: {error text}`, the run did not happen), a mid-run failure exits 1 (the `status=` summary line carries the error name) with no partial log; usage text updated; the FUZZ TARGET (`tools/fuzz/laige-fuzz.cpp`): `replay_parse` (any Status ok — only a crash/sanitizer report fails the run, the runner contract) — the parser's malformed-input surface (TEST-005, NFR-8.7: the SCALE-005 parser fuzz requirement) — 1000 deterministic runs in `fuzz_replay_parse` (TIMEOUT 120, TSan `halt_on_error=1` in the TSan tree); the corpus gains a valid v1 replay log (68 bytes: header + one zero-length frame + a correct FNV-1a trailer, local encoder with the house FNV constants) as a mutate/truncate base (the runner's generate-mutate-truncate modes feed the parser real-shaped input); laige-fuzz now links `laige-sim` (arrows only downward, PRD §10.1; laige-core's headers come through it — CPP-010); the TEST SUITE (`tests/laige-sim/replay_record_tests.cpp`, 22 tests, CTest entry `replay_record` — the step's Verify command; added to the TSan property list; NFR-8.10 static_assert self-checks): `ReplayFormat` — the round trip (record 8 PRNG-payload frames → raw bytes → parse → every identity field + frame byte identical — the step's "record N ticks → parse back → identical bytes" clause; the `replay-roundtrip frames=… bytes=… filehash=…` machine-greppable line; also: file form via `loadReplay`, deterministic re-encoding byte-identical, the zero-frame log, the EXACT 1 MiB frame boundary, and the full malformed table (every truncation cut of a valid log, bad magic, unsupported version, non-zero reserved, a length field of 1 MiB+1 — the overrun-read-preventing branch, out-of-sequence tick, trailer count mismatch, a flipped body byte (fileHash branch), trailing garbage, empty input, null data)); `ReplayRecorder` — atomic publish (final file + no temp), interruption leaves NO file (temp removed by the destructor), the size limit at the EXACT boundary (cap 56: frame 1 fits 52 ≤ 56, frame 2 breaches 64 > 56 → `BudgetExhausted`; sticky failure; no partial file; machine-checked), the size limit AT FINISH (cap 67: both frames fit exactly, the 16-byte trailer cannot → `BudgetExhausted`), the tick sequence (first tick 2, repeated tick 1 → `InvalidArgument`), write-after-finish and the one-shot finish, create validation (empty path, cap 55 below the minimum, the 0 = default cap + header read-back), move transfers the file (source inert, destination finishes); `ReplayIdentity` — schema hash stable for the same registration order, different for a different order (`replay-identity schema-order h1=… h3=…` line), config hash per-field sensitivity (seed/tick rate/capacity/churn/enabled/math), `makeReplayIdentity` echoes seed/tick rate/math id + both hashes; `ReplayEngine` — 8-tick run with recording ON → 8 zero-length frames (ticks 1..8) + identity matching the pre-run world+config capture (the `replay-engine ticks=… bytes=… schema=…` line; 152 bytes = 40 + 8×12 + 16), the mid-run failure stop (cap 56 → `run_headless` returns `BudgetExhausted`, no partial log, engine shut down, sink: `record_failed` = 1 + `record_aborted` = 1 — sink read BEFORE `restoreLogger`, the engine_tests pattern), double start (`InvalidArgument` + `record_already_started` = 1), stopped-engine start (`InvalidArgument`, NO log — the stopped-state precedent, zero accessors); CMake (`src/laige-sim/CMakeLists.txt`, `tests/laige-sim/CMakeLists.txt`, `tools/fuzz/CMakeLists.txt`): `replay.cpp` into `LAIGE_SIM_SOURCES` (step comment), `replay_record_tests.cpp` into `LAIGE_SIM_TEST_SOURCES` + the `replay_record` entry (unquoted gtest filter `Replay*`, the M1 pattern) + the TSan property list, the fuzz target + entry; `laige-api.json` regenerated (630 symbols from 20 headers; `api-real-tree` green); DOCS in the same change (DOC-007): new `docs/api/replay.md` (the format spec — layout, the malformed-input table, the identity hashes, the recorder contract, the readers, the engine + CLI integration, the Performance section (disabled = one null check/tick; enabled = one bounded stdio write/tick; the 1 MiB frame + 128 MiB total bounds), misuse warnings, testing/CI) linked from `docs/README.md` (API list + the per-module list + the M1 progress summary + the compatibility bullet); `docs/api/engine.md` (new "Replay recording (M1-DET-02)" section, the M1-DET-02/03 scope split fixed, the `--replay` CLI bullet de-stubbed, the misuse bullet, the Testing+CI entries); `docs/concepts/determinism.md` ("What it is not (yet)": recording landed, execution = M1-DET-03, PRNG introspection joins M1-DET-03's `state_hash`, Related link); `docs/compatibility/README.md` (the replay log is the FIRST persistent engine format — added to the formats table: version 1, magic `LGRP`, strict validation); `docs/testing.md` (the `replay_parse` target + its command-table row); `tools/README.md` (`--replay` de-stubbed, the fuzz target list); `src/laige-sim/README.md` status updated; ROADMAP: M1-DET-02 box checked (`M1-heartbeat.md`), progress board M1 14→15 of 25, total 34→35 (this line's hash is the first commit of the two-commit change; the progress board update is in that commit). Local Verify: `ctest -R replay_record` green (22/22, Debug), full ctest 57/57 on `build` (Debug GCC), `build-asan` (ASan+UBSan — leak-free, the recorder's interrupted-state cleanup proven), and `build-tsan` (TSan `halt_on_error=1`, incl. `replay_record` + `fuzz_replay_parse`), `laige-fuzz replay_parse --runs=1000` + `json_parse --runs=1000` clean (default seed), `tools/laige-include-lint` OK (36 source files, 1/10 vendored deps), `laige-api` regenerated with `api-real-tree` green, zero new warnings under NFR-8.10. Untested paths: the MSVC `_fsopen`/`MoveFileExA` and AppleClang branches (CI-only), the release-build `record_disabled` branch (not exercisable in a Debug tree — the `NDEBUG` path is a compile-time constant), and a real > 128 MiB run (the size-limit branches are boundary-tested at 56/67 instead). Compat: additive only — the M1-HEAD-01 `--replay` stub behavior is replaced by the real recording (the flag's documented contract was always this step); no existing symbol or behavior changed. | ---