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..02f0ecc 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** | | --- @@ -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. | --- 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();