From d50ba5acda6983a99b1179794894defb6a917960 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 21 Sep 2026 14:19:57 +0200 Subject: [PATCH] [M1-CFG-01] Declarative game config: version 1 schema, loaders, overrides, hot reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New public header src/laige-sim/include/laige/sim/config.h (+ config.cpp): the FINAL version 1 declarative config schema — EngineConfig moved from engine.h (the five original members keep their order), the REQUIRED "version" key (gated before every other key, ARCH-007), the budgets / camera / asset_roots declared blocks, parseEngineConfig, loadGameConfig, EngineConfigOverride + applyConfigOverride (the FR-1.5 override merge), and the debug-only ConfigHotReloader (non-sim keys reload live; sim-affecting keys are refused with config/hot_reload_rejected). - engine.h/engine.cpp: the provisional config surface is folded into config.h (engine.h includes it; Engine::create re-validates the tick rate with the shared kConfigTickRateInvalidMessage); laige-run.cpp now uses loadGameConfig (exit codes unchanged). - The replay identity's configHash covers only the sim-affecting fields, so the encoding (tag 1) is unchanged and every committed baseline / replay log stays valid (replay.h/.cpp comments + docs updated). - docs/api/config.md (new): key table, versioning + migration, rejection table, override API, hot-reload contract, Performance, misuse warnings; engine.md / replay.md / determinism.md / testing.md / docs-README / sim-README cross-refs updated to the final surface. - Tests: new game_config CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path); engine_tests drops the migrated EngineConfigParse.*; determinism_tests' config docs gain "version": 1; fixtures gain "version": 1 (headless_smoke, samples/hello, the replay smoke fixtures). - laige-api.json regenerated (734 symbols; api-real-tree green). - Verify: ctest -R config green; full ctest 88/88 on build (Debug g++), build-asan, build-release (the hot_reload_disabled path), build-clang, build-tsan, build-shared; zero new warnings under NFR-8.10. Untested: the MSVC _fsopen branch (CI-only). - Roadmap: M1-CFG-01 box checked; board 20/25 (38 total); change log row + retroactive M1-DET-05 row (the M1-ECS-06 precedent). Closes roadmap/M1-heartbeat.md step M1-CFG-01. --- docs/README.md | 17 +- docs/api/config.md | 325 ++++++ docs/api/engine.md | 108 +- docs/api/replay.md | 10 +- docs/concepts/determinism.md | 29 +- docs/testing.md | 11 +- laige-api.json | 217 ++-- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 6 +- samples/hello/README.md | 11 +- samples/hello/config.json | 1 + src/laige-sim/CMakeLists.txt | 11 +- src/laige-sim/README.md | 22 +- src/laige-sim/config.cpp | 751 ++++++++++++ src/laige-sim/engine.cpp | 281 +---- src/laige-sim/include/laige/sim/config.h | 632 +++++++++++ src/laige-sim/include/laige/sim/engine.h | 141 +-- src/laige-sim/include/laige/sim/replay.h | 28 +- src/laige-sim/replay.cpp | 19 +- tests/laige-sim/CMakeLists.txt | 40 +- tests/laige-sim/determinism_tests.cpp | 26 +- tests/laige-sim/engine_tests.cpp | 126 +-- tests/laige-sim/fixtures/headless_smoke.json | 1 + tests/laige-sim/game_config_tests.cpp | 1005 +++++++++++++++++ tests/replay/fixtures/replay_smoke.json | 1 + .../fixtures/replay_smoke_budget128.json | 1 + .../fixtures/replay_smoke_wrongseed.json | 1 + tools/run/laige-run.cpp | 86 +- 28 files changed, 3108 insertions(+), 801 deletions(-) create mode 100644 docs/api/config.md create mode 100644 src/laige-sim/config.cpp create mode 100644 src/laige-sim/include/laige/sim/config.h create mode 100644 tests/laige-sim/game_config_tests.cpp diff --git a/docs/README.md b/docs/README.md index 0c01484..c4b49fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,11 @@ enforcement; M1-LOOP-01: the fixed-timestep game loop core; M1-LOOP-02: the per-tick presentation snapshot + interpolation state; M1-HEAD-01: the headless engine run — `Engine` (config → world → systems → loop) and the `laige-run` binary; +M1-CFG-01: the declarative game config — the version 1 +`config.json` schema (tick rate, budgets, camera defaults, asset +roots, determinism block), `loadGameConfig`, the programmatic +override merge, and the debug-only hot reload of non-simulation +keys (`laige/sim/config.h`); 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; @@ -112,9 +117,14 @@ still to land. - [Headless engine run](api/engine.md) — `laige::Engine` (config → world → systems → loop): `run_headless(maxTicks)` the bounded + server run forms, the ordered idempotent CONC-006 - shutdown, the provisional config surface (now including the - `seed`/`determinism` keys, M1-DET-01), the backend selection at - init, and the `laige-run` CLI (M1-HEAD-01; `laige-sim` + `tools/run`). + shutdown, the backend selection at init, and the `laige-run` + CLI (M1-HEAD-01; `laige-sim` + `tools/run`). +- [Declarative game config](api/config.md) — the version 1 + `config.json` schema (the required `version` key, tick rate, + budgets, camera defaults, asset roots, the determinism block), + `EngineConfig`, `loadGameConfig`, the `EngineConfigOverride` + merge, and the debug-only `ConfigHotReloader` (M1-CFG-01; + `laige-sim`). - [Determinism-safe storage](api/determinism.md) — the G-R8 compile-time trait: `SimMathBackend`, `DeterminismConfig`, `detail::IsDeterminismSafe`, and @@ -232,6 +242,7 @@ still to land. [game_loop.md](api/game_loop.md), [presentation.md](api/presentation.md), [engine.md](api/engine.md), + [config.md](api/config.md), [determinism.md](api/determinism.md), [replay.md](api/replay.md).) diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 0000000..cc0efbc --- /dev/null +++ b/docs/api/config.md @@ -0,0 +1,325 @@ +# Declarative game config (`EngineConfig`, M1-CFG-01) + +The declarative game config surface (M1-CFG-01; PRD FR-1.5, ARCH-007, +ADR 0002, ADR 0003, PRD §7.1/§10.2/§10.3; AGENTS API-006/008, +CORE-005/008, PERF-002/003, LOG-002): the versioned `config.json` +schema, the typed `EngineConfig` value the engine consumes, the file +loader, the programmatic override merge, and the debug-build-only +hot reload of non-simulation keys. + +Public header: `src/laige-sim/include/laige/sim/config.h` +(`EngineConfig`, `BudgetsConfig`, `CameraConfig`, +`kSupportedConfigVersion`, the range/default constants, +`parseEngineConfig`, `loadGameConfig`, `EngineConfigOverride`, +`applyConfigOverride`, `ConfigHotReloader`, the rejection-message +constants); implementation: `src/laige-sim/config.cpp`. Unit suite: +`ctest -R config` (the `game_config` entry — +`tests/laige-sim/game_config_tests.cpp`; the `config_json` entry is +the M0-CORE-07 parser it builds on). + +```cpp +// Load the game config from the version 1 JSON file (cold path): +const laige::Result loaded = + laige::loadGameConfig("config.json"); +// ... on success: +laige::EngineConfig config = std::move(loaded).takeValue(); + +// Programmatic override of a subset (FR-1.5): +laige::EngineConfigOverride override; +override.tickRateHz = 120; // the rest is kept from the loaded config +laige::EngineConfig merged = laige::applyConfigOverride(config, override).value(); + +laige::Engine engine = laige::Engine::create(merged).value(); +``` + +## The version 1 schema + +The document is a top-level **JSON object**. The `version` key is +**required** and must be the exact integer `1` +(`laige::kSupportedConfigVersion`); it is checked **before any other +key** (ARCH-007: readers reject or migrate unsupported data +explicitly — never a silent migration). Every other key is optional +(missing keys take the defaults below); an **unknown key at any level +is warned** (one `config/unknown_key` per key — forward-compatible +with future schema versions) and ignored. **First failure wins**: the +loader stops at the first rejected key in document order and returns +the error; no config is produced on failure (CORE-008). + +| key | type / range | default | hot-reloadable | +|---|---|---|---| +| `version` | exact integer, `== 1` | **required** | no | +| `tick_rate_hz` | exact integer, 20–120 | `kDefaultTickRateHz` (60) | no | +| `entity_budget` | exact integer, 0–65536 | `0` (empty scene — a valid world that creates no entities; entity creation on it fails `BudgetExhausted`) | no | +| `churn_per_frame_budget` | exact integer, 0–4294967295 | `kDefaultChurnPerFrameBudget` (256; `0` disables the G-R4 guardrail) | no | +| `seed` | exact integer, 0–2^53 | `kDefaultSimulationSeed` (0) | no | +| `determinism` | object (below) | `{enabled: true, math: "fixed_point_16_16"}` | no | +| `determinism.enabled` | bool | `true` | no | +| `determinism.math` | `"fixed_point_16_16"` \| `"float_pinned_32"` | `"fixed_point_16_16"` | no | +| `budgets` | object (below) | `{system_time_default_ms: 1.5, draw_calls_per_frame: 30, particles_per_frame: 4096}` | — | +| `budgets.system_time_default_ms` | finite number, > 0 (ms) | `kDefaultSystemTimeBudgetMs` (1.5) | no | +| `budgets.draw_calls_per_frame` | exact integer, 0–4294967295 | `kDefaultDrawCallsPerFrame` (30) | **yes** | +| `budgets.particles_per_frame` | exact integer, 0–4294967295 | `kDefaultParticlesPerFrame` (4096) | **yes** | +| `camera` | object (below) | `{fov_degrees: 45, zoom: 1, follow_lerp_per_sec: 8}` | — | +| `camera.fov_degrees` | finite number, 0 < v ≤ 180 | `kDefaultCameraFovDegrees` (45) | **yes** | +| `camera.zoom` | finite number, > 0 | `kDefaultCameraZoom` (1) | **yes** | +| `camera.follow_lerp_per_sec` | finite number, ≥ 0 | `kDefaultCameraFollowLerpPerSec` (8; `0` disables smoothing) | **yes** | +| `asset_roots` | array of non-empty strings | `[]` | **yes** | + +**Number policy (ADR 0003).** JSON numbers are `double`s. The exact- +integer keys are validated to be integral and in range; the bounded +2^53 exactness of doubles is the `seed` upper bound (a seed above +2^53 is not exactly representable and is rejected). Non-finite values +(a literal beyond double range parses to ±inf) are rejected by every +numeric check. + +**Declared presentation values.** The `budgets` and `camera` blocks +and `asset_roots` are **declared values in M1**: the scene entity +budget already has its consumer (the `World` capacity, G-R3) and +`budgets.system_time_default_ms` will govern the M1-PROF-02 per-system +budget report, but the draw/particle budgets (the M2 +render/particle consumers) and the camera defaults (the M2 camera +work) are stored and validated now and consumed later (FR-1.5: values +stored; the consumers land in M2). `asset_roots` is likewise declared +now; the asset pipeline (M2) resolves them — **existence is not +checked at config load** (a root may be created later; the pipeline +owns the check). + +## Versioning (ARCH-007) + +The config document is persistent data and is **versioned**. This +build accepts exactly version 1; rejection is explicit: + +| condition | error | event | +|---|---|---| +| `version` absent | `InvalidArgument` | `config/version_missing` (Warn) | +| `version` not an exact non-negative integer | `InvalidArgument` | `config/version_invalid` (Warn) | +| `version` ≠ 1 | `InvalidArgument` | `config/version_unsupported` (Warn) | + +Documents from the M1-HEAD-01 provisional surface (no `version` key) +are rejected by the `version_missing` rule. **Migration from the +provisional surface: add `"version": 1`** — every other key is +unchanged. The `laige-run` CLI surfaces a rejection as exit code 2 +with the NFR-13.3 5-field message on stderr (e.g. +`version_missing | the config document has no version key | ... | +add "version": 1 to the config (docs/api/config.md) | +docs/api/config.md`). + +**Replay identity (ADR 0002).** The replay identity's `configHash` +([api/replay.md](replay.md)) covers **only the simulation-affecting +fields** — `tick_rate_hz`, `entity_budget`, `churn_per_frame_budget`, +`seed`, and the `determinism` block. The declared presentation values +(budgets block, camera block, asset roots) never touch simulation and +are deliberately **excluded** from the identity: a replay recorded +under one set of those values replays bit-exact under another. The +hashed field encoding (its tag word `1` included) therefore does not +change with this step, and **every committed baseline and replay log +stays valid**. + +## Loading (`loadGameConfig`) + +`laige::loadGameConfig(std::string_view path)` reads the file, parses +it (the M0-CORE-07 bounded JSON parser), and validates it against the +schema above. It is a **cold path** (startup/dev-time): one bounded +read + one parse + one O(document) schema walk; it allocates +(document-sized buffer, the config's string storage) and never runs on +the sim tick path (PERF-002/003). + +| failure | `ErrorCode` | event | +|---|---|---| +| file missing / unreadable | `IoError` | `config/file_read_failed` (Warn) | +| file over the 1 MiB bound (ADR 0003) | `MalformedInput` | `config/file_read_failed` (Warn) | +| JSON grammar violation (truncated file, bad syntax) | `MalformedInput` | the ADR 0003 parser (no extra event) | +| schema rejection (any key above, version gate included) | `InvalidArgument` | the `config/_invalid` event below | + +The read is bounded at `1 MiB` (the ADR 0003 document bound): an +oversized file is a `MalformedInput`, not a truncated parse. + +## Rejection table + +First failure wins (document order); each rejection is one +rate-limited warn (LOG-004) with the NFR-13.3 5-field message (the +build-stable text is a named constant in `config.h`; dynamic values +are structured fields, never message text) plus the returned error. + +| event (Warn) | condition | +|---|---| +| `config/version_missing` | `version` key absent | +| `config/version_invalid` | `version` not an exact non-negative integer (non-number, fractional, negative, wrong type) | +| `config/version_unsupported` | `version` ≠ 1 | +| `config/not_an_object` | document is not a JSON object | +| `config/tick_rate_invalid` | not a number / not an exact integer / outside 20–120 | +| `config/entity_budget_invalid` | not a number / not an exact integer / outside 0–65536 | +| `config/churn_budget_invalid` | not a number / not an exact integer / < 0 | +| `config/seed_invalid` | not a number / not an exact integer / outside 0–2^53 | +| `config/determinism_invalid` | the block is not an object | +| `config/determinism_enabled_invalid` | not a JSON boolean | +| `config/determinism_math_invalid` | not a string, or not one of the two ADR 0002 backend ids | +| `config/budgets_invalid` | the block is not an object | +| `config/system_time_budget_invalid` | not a number / not finite / ≤ 0 | +| `config/draw_calls_budget_invalid` | not a number / not an exact integer / < 0 | +| `config/particles_budget_invalid` | not a number / not an exact integer / < 0 | +| `config/camera_invalid` | the block is not an object | +| `config/camera_fov_invalid` | not a number / not finite / outside (0, 180] | +| `config/camera_zoom_invalid` | not a number / not finite / ≤ 0 | +| `config/camera_follow_lerp_invalid` | not a number / not finite / < 0 | +| `config/asset_roots_invalid` | not an array, or an element is not a non-empty string | +| `config/unknown_key` | any key not in the schema (any level) — **warn only, ignored** (forward-compat) | + +All rejections map to `ErrorCode::InvalidArgument` (NFR-13.3 grammar: +`{codeId} | {what} | {why} | {fix} | {docAnchor}`, see +[errors.md](errors.md)). + +## The typed config (`EngineConfig`) + +`laige::EngineConfig` is a **plain value** (copied into +`Engine::create`; the `assetRoots` vector is its only heap member). +The five M1-HEAD-01 members keep their order — `{tickRateHz, +entityCapacity, churnPerFrameBudget, seed, determinism}` — followed +by the M1-CFG-01 additions `{budgets, camera, assetRoots}`, so +existing aggregate initializers compile unchanged. + +```cpp +laige::EngineConfig config; // every documented default +config.tickRateHz = 60; +config.entityCapacity = 4096; +config.churnPerFrameBudget = 256; +config.seed = 0x1F055EEDull; // programmatic: full 64 bits (no 2^53 JSON bound) +config.determinism.enabled = true; +config.determinism.math = laige::SimMathBackend::FixedPoint16_16; +config.budgets.systemTimeDefaultMs = 1.5; +config.budgets.drawCallsPerFrame = 30; +config.budgets.particlesPerFrame = 4096; +config.camera.fovDegrees = 45.0; +config.camera.zoom = 1.0; +config.camera.followLerpPerSec = 8.0; +config.assetRoots = {"assets/"}; +``` + +The **programmatic surface** accepts the full 64-bit `seed` (the JSON +surface is bounded to 2^53 by the ADR 0003 number policy). +`Engine::create` re-validates the typed config's tick rate (the struct +is public; the JSON path is not the only constructor) and reuses the +config surface's rejection (`config/tick_rate_invalid`, subsystem +`config`). + +**Simulation-affecting fields** (part of the replay identity's +`configHash`; hot-reload-refused): `tickRateHz`, `entityCapacity`, +`churnPerFrameBudget`, `seed`, `determinism`, and +`budgets.systemTimeDefaultMs` (it governs the G-R5 budget enforcement +during ticks). **Declared presentation fields** (validated, stored, no +M1 consumer; hot-reloadable in debug): the draw/particle budgets, +`camera.*`, `assetRoots`. + +## Overrides (`applyConfigOverride`) + +`laige::EngineConfigOverride` is a per-leaf `std::optional` struct +(one member per config leaf — the split `determinismEnabled` / +`determinismMath` mirror the JSON block's two keys). +`laige::applyConfigOverride(base, override)` merges the **subset**: + +- a `std::nullopt` field is kept from `base`; +- a set field replaces it and is **validated in its documented domain** + (the same `config/_invalid` events as the JSON path — e.g. a + set `tickRateHz` of 15 is `InvalidArgument`, a set `fovDegrees` of + 0 is `InvalidArgument`); +- **first set field that fails wins** (the struct's declaration + order); +- value semantics: `base` is untouched; a set `assetRoots` **replaces + the vector**. + +Cold path: O(1) plus the `assetRoots` copy when that field is set. +This is the FR-1.5 "runtime config overrides" merge — e.g. a test +harness that loads the shipped config and bumps one field. + +## Hot reload (`ConfigHotReloader`, debug builds only) + +`laige::ConfigHotReloader` watches the config file so a dev loop can +pick up **non-simulation** config changes without a restart (FR-1.5 +"hot-reload of non-simulation config in debug"). It is **move-only**, +single-owner-thread (the game's loop thread — the sim is +single-threaded, PRD §10.2), and creates **no thread** (CONC-005): +the caller drives the poll cadence (typically once per frame in a dev +loop — never on the sim tick path). + +```cpp +// In a dev loop (debug build): +auto reloader = laige::ConfigHotReloader::create("config.json"); +// ... each frame, with the live engine config: +if (!reloader->poll(config).ok()) { + // refused (sim-affecting key changed) or a transient read failure — + // see the events below; the engine keeps running with the old values +} +``` + +`create(path)` binds to the file and takes the **baseline**: the +current bytes plus a full validated parse (a read/parse/schema failure +returns the error, no reloader — CORE-008). `poll(config)` re-reads +the file and compares the bytes to the baseline (a byte comparison per +poll — robust to same-size/same-mtime-second edits): + +| poll outcome | returned | log | +|---|---|---| +| file unchanged | ok | — (no event) | +| changed, sim-affecting fields all equal to the baseline | ok | `config/hot_reload_applied` (**Info**) naming every changed key; the non-sim fields (camera.*, the draw/particle budgets, `asset_roots`) are applied to `config` **in place** and the baseline advances (the file is now authoritative for them — a programmatic override of those fields is replaced) | +| changed, any sim-affecting field differs | `InvalidArgument` | `config/hot_reload_rejected` (**Error**) naming the **first differing key** with its old/new values; `config` and the baseline are **untouched** — restart the engine to apply (FR-1.5) | +| file no longer reads (deleted, locked, over bound) | `IoError` | `config/hot_reload_read_failed` (Warn); `config` unchanged — retry on the next poll | +| file no longer validates (truncated mid-edit, bad version) | the loader's error (`MalformedInput` / `InvalidArgument`) | the loader's event; `config` unchanged | + +A change of **any** simulation-affecting key (`version`, +`tick_rate_hz`, `entity_budget`, `churn_per_frame_budget`, `seed`, +`determinism.enabled`, `determinism.math`, +`budgets.system_time_default_ms`) is refused **atomically**: even if +the same write also changes camera defaults, none of it is applied — +the live config never corresponds to a partial file state. + +A **moved-from** reloader's poll fails with `InvalidArgument` and no +log (the stopped-state precedent). + +**Release builds (NDEBUG)**: `create` and `poll` both reject with +`InvalidArgument` + `config/hot_reload_disabled` (Warn) — hot reload +is development tooling, the replay/`record_disabled` pattern. + +## Performance + +All config work is a **cold path** (PERF-002/003): + +- `loadGameConfig`: O(file bytes) once at startup + the parse. +- `parseEngineConfig`: O(document keys), one pass; no allocation except + the warn fields on a rejection. +- `applyConfigOverride`: O(1) plus the `assetRoots` copy when set. +- `ConfigHotReloader::poll`: one bounded file read + one byte + comparison per call (O(file bytes)); a detected change adds one + parse + one field comparison. Debug-only and opt-in — the reloader + exists only when constructed, so the disabled cost is zero + (DBG-004) — and it never runs on the sim tick path. + +## Misuse warnings + +- **Do not edit sim-affecting keys expecting a live effect.** The hot + reloader refuses them with `hot_reload_rejected`; the fix it names + is a restart. +- **Do not expect `asset_roots` existence checks at load.** Missing + roots are a config-time non-error; the M2 asset pipeline owns the + check. +- **Do not build `EngineConfig` by hand with out-of-range values and + expect the engine to correct them.** `Engine::create` validates the + tick rate and rejects; the other domains are the caller's + responsibility on the programmatic surface (the JSON/override paths + validate everything). +- **Do not hold a `ConfigHotReloader` across a config file rename.** + It watches a fixed path; create a new reloader after a rename + (the poll of the new path takes a new baseline). +- **Do not poll from multiple threads.** The reloader is + single-owner-thread (CONC-005). + +## Tests and CI + +`ctest -R config` (the step Verify command): the `game_config` entry +(this suite — the schema's every rejection domain, the version gate, +first-failure-wins, unknown-key forward-compat, the file loader's +bounded read, the override merge, and the hot-reload contract including +the release-disabled path) plus the `config_json` entry (the M0-CORE-07 +bounded JSON parser the schema layer builds on). The malformed-input +surface is the ADR 0003 parser's, fuzzed by the laige-core `json_parse` +fuzz target (TEST-005); the schema layer only sees well-formed +`JsonValue`s, so it needs no separate fuzz target. diff --git a/docs/api/engine.md b/docs/api/engine.md index 7336ad4..97f0399 100644 --- a/docs/api/engine.md +++ b/docs/api/engine.md @@ -9,10 +9,14 @@ it down cleanly. It is the first full-stack surface of the engine and the CI smoke-test target (`laige-run --headless`, this repo's `tools/run`). Public header: `src/laige-sim/include/laige/sim/engine.h` (`Engine`, -`EngineConfig`, `parseEngineConfig`, the range constants, the full -contract); implementation: `src/laige-sim/engine.cpp`. CLI: -`tools/run/laige-run.cpp` (target `laige-run`). Unit suite: -`ctest -R engine` (`tests/laige-sim/engine_tests.cpp`); smoke test: +the range constants, the full contract); implementation: +`src/laige-sim/engine.cpp`. The config surface (`EngineConfig`, +`loadGameConfig`, the override merge, the hot reloader) is +`src/laige-sim/include/laige/sim/config.h` — see +[api/config.md](config.md). CLI: `tools/run/laige-run.cpp` (target +`laige-run`). Unit suite: `ctest -R engine` +(`tests/laige-sim/engine_tests.cpp`) and `ctest -R config` +(`tests/laige-sim/game_config_tests.cpp`); smoke test: `ctest -R laige_run_smoke` (every P0 OS job, 1000 ticks @ 60 Hz). ```cpp @@ -68,69 +72,41 @@ leaks the world; the explicit call is the documented teardown (it retires the logging facade, which `Logger::init` re-arms for a later engine in the process). -## The config surface (provisional) - -`EngineConfig{tickRateHz, entityCapacity, churnPerFrameBudget, seed, -determinism}` and `parseEngineConfig(const JsonValue&)` are the -**provisional** config surface for M1-HEAD-01. M1-CFG-01 owns the final -versioned config schema (PRD §10, ARCH-007: persistent data MUST be -versioned); when M1-CFG-01 lands, the JSON parse moves behind its -versioned reader and this surface is folded into it. The provisional -keys: - -| key | type | range | default | -|---|---|---|---| -| `tick_rate_hz` | exact integer | 20–120 | `kDefaultTickRateHz` (60) | -| `entity_budget` | exact integer | 0–65536 | `0` (an empty scene — a valid world that creates no entities; entity creation on it fails `BudgetExhausted`) | -| `churn_per_frame_budget` | exact integer | 0–4294967295 | `kDefaultChurnPerFrameBudget` (256) | -| `seed` | exact integer | 0–2^53 (JSON) / 0–2^64−1 (struct) | `kDefaultSimulationSeed` (0) | -| `determinism` | object (below) | — | `{enabled: true, math: "fixed_point_16_16"}` | - -The `determinism` object (M1-DET-01; see -[concepts/determinism.md](../concepts/determinism.md) for the scope -and [api/determinism.md](determinism.md) for the types): - -| nested key | type | range | default | -|---|---|---|---| -| `determinism.enabled` | bool | — | `true` | -| `determinism.math` | string | `"fixed_point_16_16"` \| `"float_pinned_32"` | `"fixed_point_16_16"` | - +## The config surface (M1-CFG-01: the final versioned schema) + +`EngineConfig` and the JSON/file loaders now live in +[api/config.md](config.md) (`laige/sim/config.h`): the **version 1** +declarative schema (the REQUIRED `version` key, ARCH-007), the +`tick_rate_hz` / `entity_budget` / `churn_per_frame_budget` / `seed` +/ `determinism` keys, and the declared presentation blocks +(`budgets`, `camera`, `asset_roots` — stored now, consumed in M2). +The provisional M1-HEAD-01 surface was folded into it: the five +original `EngineConfig` members keep their order (existing aggregate +initializers compile unchanged), the provisional JSON keys carried +over, and the migration from a provisional document is adding +`"version": 1`. + +What the engine consumes directly: + +- `Engine::create(config)` re-validates the typed config's tick rate + (the config surface's rejection, `config/tick_rate_invalid`, + subsystem `config`) — the struct is public, so a hand-built + out-of-range config is rejected identically. - The **seed is part of replay identity** (ADR 0002) and is logged on - `engine/run_started`. In JSON it is bounded to `2^53` because ADR - 0003 stores numbers as doubles (exact to 2^53); the programmatic - `EngineConfig.seed` is the full `uint64_t`. A seed above the JSON - bound, a non-integer, or a negative is rejected - (`config/seed_invalid`). -- `enabled` selects deterministic mode (per-system PRNG substreams, - the replay promise); `false` is the documented escape hatch - (no substreams, `SystemContext.rng == nullptr`). `math` selects the - SimMath backend the engine registers (the built-in component and the - presentation snapshot). - -- **Unknown keys** are ignored with one rate-limited - `config/unknown_key` warn per key (forward-compatible with - M1-CFG-01's additions; LOG-004). -- **Rejections** (first failure wins, one rate-limited warn each): - `config/not_an_object` (document is not a JSON object), - `config/tick_rate_invalid` (absent/out of range/non-integer), - `config/entity_budget_invalid`, `config/churn_budget_invalid`, - `config/seed_invalid` (non-integer / out of range / above the 2^53 - JSON bound / wrong type), `config/determinism_invalid` (not an - object), `config/determinism_enabled_invalid` (not a bool), - `config/determinism_math_invalid` (not one of the two backend ids) — - each maps to `ErrorCode::InvalidArgument` (NFR-13.3 grammar: - `{codeId}|{what}|{why}|{fix}|{docAnchor}`, see `errors.md`). An - **unknown key inside `determinism`** is not a rejection: it warns - (`config/unknown_key`) and is ignored, like the top-level unknown-key - rule (forward-compat with M1-CFG-01). -- `Engine::create` re-validates the `EngineConfig` struct itself (the - struct is public; the JSON path is not the only constructor), so a - hand-built out-of-range config is rejected identically. - -`parseEngineConfig` is a cold path (O(document keys); it allocates -only for the warn fields when a key is rejected) and is the only -place the JSON document is read — the `EngineConfig` struct is the -value the engine consumes. + `engine/run_started`; `determinism.enabled` selects deterministic + mode and `determinism.math` the SimMath backend the engine + registers (see [concepts/determinism.md](../concepts/determinism.md) + for the scope and [api/determinism.md](determinism.md) for the + types). +- The replay identity's `configHash` covers only the + simulation-affecting fields — the declared presentation values + (budgets/camera/asset roots) are excluded, so the hash encoding is + unchanged by the final schema ([api/replay.md](replay.md)). + +The full key table, the versioning rules, the rejection table, the +`loadGameConfig` file loader, the `EngineConfigOverride` merge, and +the debug-only `ConfigHotReloader` are in +[api/config.md](config.md). ## The run contract (`run_headless`) diff --git a/docs/api/replay.md b/docs/api/replay.md index 020d32e..f144514 100644 --- a/docs/api/replay.md +++ b/docs/api/replay.md @@ -126,9 +126,13 @@ no wall clock (ARCH-010): 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. + determinism.enabled, determinism.math]`. The tag word identifies + this encoding. M1-CFG-01 grew the config schema (version, budgets, + camera, asset_roots), but the encoding covers **only the + simulation-affecting fields** — the declared presentation values + never touch simulation (ARCH-009) — so the encoding (tag 1) is + **unchanged** and every committed baseline/replay log stays valid + ([api/config.md](config.md)). - **`makeReplayIdentity(const World&, const EngineConfig&)`** — assembles the header's `ReplayIdentity` from the two hashes plus the config's seed/tick rate/backend. diff --git a/docs/concepts/determinism.md b/docs/concepts/determinism.md index c1527bf..796ba36 100644 --- a/docs/concepts/determinism.md +++ b/docs/concepts/determinism.md @@ -175,9 +175,10 @@ Disabling determinism is an escape hatch for non-deterministic prototypes, not a different math policy: the same SimMath ops, the same storage rules — only the PRNG and the replay promise are switched off. -## The config surface (provisional) +## The config surface (M1-CFG-01: the final versioned schema) -`EngineConfig` (laige/sim/engine.h) carries the two keys: +`EngineConfig` (laige/sim/config.h — moved there by M1-CFG-01) +carries the two keys: ```cpp struct EngineConfig { @@ -186,20 +187,28 @@ struct EngineConfig { std::uint32_t churnPerFrameBudget{...}; std::uint64_t seed{laige::kDefaultSimulationSeed}; // 0..2^64-1 (programmatic) DeterminismConfig determinism{}; // {enabled, math} + BudgetsConfig budgets{}; // M1-CFG-01 declared values + CameraConfig camera{}; // M1-CFG-01 declared values + std::vector assetRoots{}; // M1-CFG-01 declared values }; ``` - `DeterminismConfig { bool enabled{true}; SimMathBackend math{FixedPoint16_16}; }` with `SimMathBackend::FixedPoint16_16` (id `fixed_point_16_16`, the default) and `SimMathBackend::FloatPinned32` (id `float_pinned_32`). -- The JSON surface is **provisional** (M1-HEAD-01): `parseEngineConfig` - accepts `{"seed": 0..2^53, "determinism": {"enabled": bool, - "math": "fixed_point_16_16"|"float_pinned_32"}}`. The seed is bounded - to `2^53` in JSON because ADR 0003 stores numbers as doubles (exact to - 2^53); the programmatic `EngineConfig.seed` is the full `uint64_t`. - Unknown nested keys warn (`config/unknown_key`) and are ignored — the - forward-compat rule. **M1-CFG-01 owns the final config schema**; these - keys land on the provisional surface until then. +- The JSON surface is the **version 1 schema** (M1-CFG-01, + `laige::parseEngineConfig` / `laige::loadGameConfig` — see + [api/config.md](../api/config.md)): `{"version": 1, "seed": 0..2^53, + "determinism": {"enabled": bool, + "math": "fixed_point_16_16"|"float_pinned_32"}, ...}`. The seed is + bounded to `2^53` in JSON because ADR 0003 stores numbers as doubles + (exact to 2^53); the programmatic `EngineConfig.seed` is the full + `uint64_t`. Unknown keys (any level) warn (`config/unknown_key`) + and are ignored — the forward-compat rule. Both the seed and the + determinism block are simulation-affecting: they are part of the + replay identity's `configHash` and are hot-reload-refused (the + declared presentation values — budgets, camera, asset_roots — are + not). - The engine selects the backend once at init (compile-time dispatch, ADR 0002): it registers the matching built-in component (`Position2DFpx16` or `Position2DFp32`) first and builds the diff --git a/docs/testing.md b/docs/testing.md index 9907935..d264e91 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -150,10 +150,13 @@ the standard ctest suite in every P0 job (and both sanitizer trees): per-system PRNG substreams match `Prng::deriveSubstream` exactly and are independent; `deterministic == false` yields `SystemContext.rng == nullptr`; the engine selects the configured SimMath backend (built-in - component + presentation snapshot); and the provisional `seed` / - `determinism` config keys (defaults, valid values, the rejection - table). The machine-greppable `determinism-tick-stream` line lands in - the ctest output. + component + presentation snapshot); and the `seed` / `determinism` + config keys (defaults, valid values, the rejection table — the + version 1 schema's determinism block, M1-CFG-01). The + machine-greppable `determinism-tick-stream` line lands in the ctest + output. (The full config schema — version gate, budgets, camera, + asset roots, overrides, hot reload — is `ctest -R config`, + `tests/laige-sim/game_config_tests.cpp`.) - **`ctest -R trait_compile`** — the G-R8 trait compile-checks (`tests/laige-sim/compile_fail/`, generated `cmake -P` check scripts): one positive fixture (a marked determinism-safe component compiles) diff --git a/laige-api.json b/laige-api.json index b1c4e4e..7d641d1 100644 --- a/laige-api.json +++ b/laige-api.json @@ -14,6 +14,7 @@ "src/laige-core/include/laige/sim_math.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", + "src/laige-sim/include/laige/sim/config.h", "src/laige-sim/include/laige/sim/determinism.h", "src/laige-sim/include/laige/sim/engine.h", "src/laige-sim/include/laige/sim/entity.h", @@ -431,6 +432,82 @@ {"name": "laige::ComponentInfo::alignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 144, "signature": "std::uint32_t alignment{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxComponentTypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 149, "signature": "inline constexpr std::uint32_t kMaxComponentTypes = 256", "summary": "The engine-level cap on component types per world (CORE-005). See the preamble for the rationale and the ADR path to raise it.", "budget": null, "experimental": false}, {"name": "LAIGE_COMPONENT", "kind": "macro", "header": "src/laige-sim/include/laige/sim/component.h", "line": 196, "signature": "#define LAIGE_COMPONENT(Type)", "summary": "Mark T as a Laige component (FR-1.2; S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::kSupportedConfigVersion", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 230, "signature": "inline constexpr std::uint32_t kSupportedConfigVersion = 1", "summary": "The only config-document version this build accepts (ARCH-007: readers reject or migrate unsupported data explicitly — the rejection table in the header preamble).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultSimulationSeed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 234, "signature": "inline constexpr std::uint64_t kDefaultSimulationSeed = 0", "summary": "The default master simulation seed (M1-DET-01: the PRNG substreams derive from it; it is part of the replay identity, ADR 0002).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultSystemTimeBudgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 241, "signature": "inline constexpr double kDefaultSystemTimeBudgetMs = 1.5", "summary": "The per-system default time budget in milliseconds (G-R5). 1.5 ms is half of the 3.0 ms sim_tick_avg budget (budgets.json, PRD §8.1): at 60 Hz, a scene whose systems all take their defaults still fits the tick budget with margin. Declared value in M1 (no scheduler consumer yet — the per-system default lands with M1-PROF-02's budget report).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultDrawCallsPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 247, "signature": "inline constexpr std::uint32_t kDefaultDrawCallsPerFrame = 30", "summary": "The declared per-frame draw-call budget. 30 is the PRD §8.1 budget for the worst-case busy scene (50k visible sprites, 3 parallax layers, UI — budgets.json `sprites_50k_draw_calls`). Declared value in M1; the M2 batcher consumes it.", "budget": null, "experimental": false}, + {"name": "laige::kDefaultParticlesPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 253, "signature": "inline constexpr std::uint32_t kDefaultParticlesPerFrame = 4096", "summary": "The declared per-frame particle budget (FR-2.7: budgeted, pooled). No measured M1 consumer; 4096 is a power-of-two default sized for a 60 fps pool (the M2 particle system validates it against the platform).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultCameraFovDegrees", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 259, "signature": "inline constexpr double kDefaultCameraFovDegrees = 45.0", "summary": "The default perspective field of view in degrees (FR-2.4/FR-2.5). 45 degrees is the 2.5D default for the perspective projection modes; the isometric ortho presets (the PRD primary) use `zoom` instead. Declared value in M1; the M2 camera work consumes it.", "budget": null, "experimental": false}, + {"name": "laige::kDefaultCameraZoom", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 262, "signature": "inline constexpr double kDefaultCameraZoom = 1.0", "summary": "The default camera zoom (world-to-screen scale factor).", "budget": null, "experimental": false}, + {"name": "laige::kDefaultCameraFollowLerpPerSec", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 268, "signature": "inline constexpr double kDefaultCameraFollowLerpPerSec = 8.0", "summary": "The default smooth-follow rate in 1/s (FR-2.4 \"smooth follow\"): the per-frame follow factor is 1 - exp(-8*dt), about a 125 ms time constant at 60 Hz. Declared value in M1; the M2 camera work consumes it.", "budget": null, "experimental": false}, + {"name": "laige::BudgetsConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/config.h", "line": 274, "signature": "struct BudgetsConfig", "summary": "The declared budget block (M1-CFG-01; FR-1.5 \"budgets\").", "budget": null, "experimental": false}, + {"name": "laige::BudgetsConfig::systemTimeDefaultMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 276, "signature": "double systemTimeDefaultMs{kDefaultSystemTimeBudgetMs}", "summary": "The per-system default time budget in milliseconds (G-R5).", "budget": null, "experimental": false}, + {"name": "laige::BudgetsConfig::drawCallsPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 278, "signature": "std::uint32_t drawCallsPerFrame{kDefaultDrawCallsPerFrame}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetsConfig::particlesPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 281, "signature": "std::uint32_t particlesPerFrame{kDefaultParticlesPerFrame}", "summary": "The declared per-frame particle budget (the M2 particle system's budget).", "budget": null, "experimental": false}, + {"name": "laige::CameraConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/config.h", "line": 286, "signature": "struct CameraConfig", "summary": "The camera defaults (M1-CFG-01; FR-1.5 \"camera defaults\"). Stored now, consumed in M2 (the camera work) — see the header preamble.", "budget": null, "experimental": false}, + {"name": "laige::CameraConfig::fovDegrees", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 288, "signature": "double fovDegrees{kDefaultCameraFovDegrees}", "summary": "The perspective field of view in degrees (0 < fov <= 180).", "budget": null, "experimental": false}, + {"name": "laige::CameraConfig::zoom", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 290, "signature": "double zoom{kDefaultCameraZoom}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::CameraConfig::followLerpPerSec", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 292, "signature": "double followLerpPerSec{kDefaultCameraFollowLerpPerSec}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/config.h", "line": 305, "signature": "struct EngineConfig", "summary": "The typed game configuration the engine consumes (M1-CFG-01, the final schema; the five M1-HEAD-01 members keep their order — existing aggregate initializers compile unchanged).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 308, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in Hz (kMinTickRateHz..kMaxTickRateHz — 20..120; the GameLoop re-validates the same range, game_loop.h).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 311, "signature": "std::uint32_t entityCapacity{0}", "summary": "The scene's entity budget in entities (0..Entity::kMaxEntities — the G-R3 scene budget; 0 is the empty scene).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 314, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The per-frame component-churn budget (the G-R4 guardrail; 0 disables it).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 319, "signature": "std::uint64_t seed{kDefaultSimulationSeed}", "summary": "The master simulation seed (the PRNG substreams derive from it, M1-DET-01; part of the replay identity, ADR 0002). The programmatic surface accepts the full 64 bits (the JSON surface is bounded to 2^53 by the ADR 0003 number policy — see the parse table).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::determinism", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 321, "signature": "DeterminismConfig determinism{}", "summary": "The determinism block (M1-DET-01; ADR 0002).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::budgets", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 323, "signature": "BudgetsConfig budgets{}", "summary": "The declared budget block (FR-1.5).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::camera", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 325, "signature": "CameraConfig camera{}", "summary": "The camera defaults (FR-1.5; consumed in M2).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::assetRoots", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 329, "signature": "std::vector assetRoots", "summary": "The asset root paths (FR-1.5; the asset pipeline — M2 — resolves them). Existence is NOT checked at config load (a root may be created later; the pipeline owns the check).", "budget": null, "experimental": false}, + {"name": "laige::kConfigSubsystem", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 336, "signature": "inline constexpr const char* kConfigSubsystem = \"config\"", "summary": "The NFR-13.3 5-field rejection messages (build-stable; the doc anchor is docs/api/config.md — the schema's API document). engine.cpp's Engine::create tick-rate re-validation shares kConfigTickRateInvalidMessage (one text for one rejection, LOG-002).", "budget": null, "experimental": false}, + {"name": "laige::kConfigNotAnObjectMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 337, "signature": "inline constexpr const char* kConfigNotAnObjectMessage = \"not_an_object | the config document is not a JSON object | the \" \"game config must be a top-level object | wrap the config in a \" \"top-level object ({} plus \\\"version\\\": 1 for all defaults) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigVersionMissingMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 342, "signature": "inline constexpr const char* kConfigVersionMissingMessage = \"version_missing | the config document has no version key | the \" \"document predates the versioned schema (the M1-HEAD-01 provisional \" \"surface) or the key was removed | add \\\"version\\\": 1 to the config \" \"(docs/api/config.md) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigVersionInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 347, "signature": "inline constexpr const char* kConfigVersionInvalidMessage = \"version_invalid | the config version key is not an exact \" \"non-negative integer | version must be a JSON number holding an \" \"exact integer | set version to 1 | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigVersionUnsupportedMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 351, "signature": "inline constexpr const char* kConfigVersionUnsupportedMessage = \"version_unsupported | the config document version is not \" \"supported by this build | this build accepts version 1 only \" \"(ARCH-007) | rebuild with the engine version matching the \" \"document, or downgrade the document to version 1 | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigTickRateInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 357, "signature": "inline constexpr const char* kConfigTickRateInvalidMessage = \"tick_rate_invalid | the configured tick_rate_hz is invalid | the \" \"value must be an exact integer in the 20-120 Hz range | set \" \"tick_rate_hz to a value in 20-120 (the default is 60) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigEntityBudgetInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 362, "signature": "inline constexpr const char* kConfigEntityBudgetInvalidMessage = \"entity_budget_invalid | the configured entity_budget is invalid | \" \"the value must be an exact integer in 0-65536 (the 16-bit entity \" \"id space) | set entity_budget to a value in 0-65536 (the scene's \" \"declared budget, G-R3) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigChurnBudgetInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 367, "signature": "inline constexpr const char* kConfigChurnBudgetInvalidMessage = \"churn_budget_invalid | the configured churn_per_frame_budget is \" \"invalid | the value must be a non-negative exact integer | set \" \"churn_per_frame_budget to a non-negative integer (the default is \" \"256; 0 disables the G-R4 guardrail) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigSeedInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 372, "signature": "inline constexpr const char* kConfigSeedInvalidMessage = \"seed_invalid | the configured seed is invalid | the value must be \" \"an exact integer in 0-2^53 (the ADR 0003 JSON bound: doubles are \" \"exact to 2^53; the programmatic EngineConfig.seed accepts the \" \"full 64 bits) | set seed to an integer in 0-2^53 (the default is \" \"0) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigDeterminismInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 378, "signature": "inline constexpr const char* kConfigDeterminismInvalidMessage = \"determinism_invalid | the configured determinism block is invalid \" \"| the block must be a JSON object ({} for all defaults); the \" \"nested keys enabled (bool) and math (string) have their own \" \"checks | wrap the determinism block in an object | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigDeterminismEnabledInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 384, "signature": "inline constexpr const char* kConfigDeterminismEnabledInvalidMessage = \"determinism_enabled_invalid | the configured determinism.enabled \" \"is invalid | the value must be a JSON boolean (the default is \" \"true — deterministic by default, S-7) | set determinism.enabled to \" \"true or false | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigDeterminismMathInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 389, "signature": "inline constexpr const char* kConfigDeterminismMathInvalidMessage = \"determinism_math_invalid | the configured determinism.math is \" \"invalid | the value must be the string \\\"fixed_point_16_16\\\" \" \"(default) or \\\"float_pinned_32\\\" (the ADR 0002 backend ids) | set \" \"determinism.math to one of the two backend ids | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigBudgetsInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 395, "signature": "inline constexpr const char* kConfigBudgetsInvalidMessage = \"budgets_invalid | the configured budgets block is invalid | the \" \"block must be a JSON object ({} for all defaults); the nested \" \"keys have their own checks | wrap the budgets block in an object \" \"| docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigSystemTimeBudgetInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 400, "signature": "inline constexpr const char* kConfigSystemTimeBudgetInvalidMessage = \"system_time_budget_invalid | the configured budgets.\" \"system_time_default_ms is invalid | the value must be a finite \" \"number > 0 (milliseconds; the per-system default time budget, \" \"G-R5) | set system_time_default_ms to a positive finite value \" \"(the default is 1.5) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigDrawCallsBudgetInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 406, "signature": "inline constexpr const char* kConfigDrawCallsBudgetInvalidMessage = \"draw_calls_budget_invalid | the configured budgets.\" \"draw_calls_per_frame is invalid | the value must be a non-\" \"negative exact integer (the declared per-frame draw-call budget; \" \"the M2 batcher consumes it) | set draw_calls_per_frame to a non-\" \"negative integer (the default is 30) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigParticlesBudgetInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 412, "signature": "inline constexpr const char* kConfigParticlesBudgetInvalidMessage = \"particles_budget_invalid | the configured budgets.\" \"particles_per_frame is invalid | the value must be a non-\" \"negative exact integer (the declared per-frame particle budget; \" \"the M2 particle system consumes it) | set particles_per_frame to \" \"a non-negative integer (the default is 4096) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigCameraInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 419, "signature": "inline constexpr const char* kConfigCameraInvalidMessage = \"camera_invalid | the configured camera block is invalid | the \" \"block must be a JSON object ({} for all defaults); the nested \" \"keys have their own checks | wrap the camera block in an object | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigCameraFovInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 424, "signature": "inline constexpr const char* kConfigCameraFovInvalidMessage = \"camera_fov_invalid | the configured camera.fov_degrees is invalid \" \"| the value must be a finite number in (0, 180] degrees | set \" \"fov_degrees to a finite value in (0, 180] (the default is 45) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigCameraZoomInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 429, "signature": "inline constexpr const char* kConfigCameraZoomInvalidMessage = \"camera_zoom_invalid | the configured camera.zoom is invalid | the \" \"value must be a finite number > 0 | set zoom to a finite value > 0 \" \"(the default is 1) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigCameraFollowLerpInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 433, "signature": "inline constexpr const char* kConfigCameraFollowLerpInvalidMessage = \"camera_follow_lerp_invalid | the configured camera.\" \"follow_lerp_per_sec is invalid | the value must be a finite \" \"number >= 0 (the smooth-follow rate in 1/s, FR-2.4) | set \" \"follow_lerp_per_sec to a finite value >= 0 (the default is 8) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigAssetRootsInvalidMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 439, "signature": "inline constexpr const char* kConfigAssetRootsInvalidMessage = \"asset_roots_invalid | the configured asset_roots value is invalid \" \"| asset_roots must be a JSON array of non-empty strings | set \" \"asset_roots to an array of path strings ([] for none) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigUnknownKeyMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 444, "signature": "inline constexpr const char* kConfigUnknownKeyMessage = \"unknown_key | the config key is not part of the version 1 config \" \"schema | the key is not (yet) consumed by this build | remove the \" \"key, or wait for a later schema version (forward-compat) | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigFileReadFailedMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 449, "signature": "inline constexpr const char* kConfigFileReadFailedMessage = \"file_read_failed | the config file could not be read | the path \" \"does not exist, is not readable, or is over the 1 MiB bound (ADR \" \"0003) | check the path, permissions, and file size | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigHotReloadRejectedMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 457, "signature": "inline constexpr const char* kConfigHotReloadRejectedMessage = \"hot_reload_rejected | the config hot reload was refused: a \" \"simulation-affecting key changed | sim-affecting keys (tick rate, \" \"budgets, seed, determinism) change the simulation and require a \" \"restart (FR-1.5) | restart the engine with the new config; only \" \"non-simulation keys are hot-reloadable | docs/api/config.md\"", "summary": "The hot-reload messages (debug builds except the _disabled one, which is release-only — the guards keep both trees warning-free).", "budget": null, "experimental": false}, + {"name": "laige::kConfigHotReloadAppliedMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 463, "signature": "inline constexpr const char* kConfigHotReloadAppliedMessage = \"hot_reload_applied | the config file changed and its \" \"non-simulation keys were reloaded | the file's camera defaults, \" \"presentation budgets, and asset roots now apply to the live \" \"config | none (informational) | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigHotReloadReadFailedMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 468, "signature": "inline constexpr const char* kConfigHotReloadReadFailedMessage = \"hot_reload_read_failed | the config file could not be re-read \" \"during a hot-reload poll | the file was locked, deleted, or is \" \"over the 1 MiB bound (a transient editor state) | the previous \" \"config stays in effect; retry on the next poll | \" \"docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kConfigHotReloadDisabledMessage", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 476, "signature": "inline constexpr const char* kConfigHotReloadDisabledMessage = \"hot_reload_disabled | config hot reload is a debug-build feature \" \"| this binary was built without debug support (NDEBUG defined — \" \"release build) | rebuild with CMAKE_BUILD_TYPE=Debug, or restart \" \"the engine with the new config | docs/api/config.md\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/config.h", "line": 498, "signature": "[[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept", "summary": "Parses a version 1 config document into the typed config (the schema + rejection table in the header preamble; the full key table in docs/api/config.md).", "budget": null, "experimental": false}, + {"name": "laige::loadGameConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/config.h", "line": 511, "signature": "[[nodiscard]] Result loadGameConfig(std::string_view path) noexcept", "summary": "Reads the config file at `path`, parses it, and validates it against the version 1 schema. The read is bounded (kMaxConfigDocumentBytes = 1 MiB; an oversized file is MalformedInput, not a truncated parse).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride", "kind": "struct", "header": "src/laige-sim/include/laige/sim/config.h", "line": 522, "signature": "struct EngineConfigOverride", "summary": "A programmatic override of a SUBSET of an EngineConfig (FR-1.5 \"runtime config overrides\"). A std::nullopt field is kept from the base config; a set field replaces it and is validated in its documented domain (a set field outside the domain is InvalidArgument — the same config/_invalid events as the JSON path). First set field that fails wins (the declaration order below).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 527, "signature": "std::optional tickRateHz", "summary": "The simulation-affecting fields (the same domains as the JSON schema): 20..120 Hz; 0..65536 entities; >= 0; full 64-bit seed (no 2^53 JSON bound on the programmatic surface); the two backend enums.", "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 528, "signature": "std::optional entityCapacity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 529, "signature": "std::optional churnPerFrameBudget", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 530, "signature": "std::optional seed", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::determinismEnabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 531, "signature": "std::optional determinismEnabled", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::determinismMath", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 532, "signature": "std::optional determinismMath", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::systemTimeDefaultMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 535, "signature": "std::optional systemTimeDefaultMs", "summary": "The declared presentation fields: finite > 0 ms; >= 0; finite (0, 180] degrees; finite > 0; finite >= 0; non-empty strings.", "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::drawCallsPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 536, "signature": "std::optional drawCallsPerFrame", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::particlesPerFrame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 537, "signature": "std::optional particlesPerFrame", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::fovDegrees", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 538, "signature": "std::optional fovDegrees", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::zoom", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 539, "signature": "std::optional zoom", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::followLerpPerSec", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 540, "signature": "std::optional followLerpPerSec", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EngineConfigOverride::assetRoots", "kind": "variable", "header": "src/laige-sim/include/laige/sim/config.h", "line": 541, "signature": "std::optional> assetRoots", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::applyConfigOverride", "kind": "function", "header": "src/laige-sim/include/laige/sim/config.h", "line": 548, "signature": "[[nodiscard]] Result applyConfigOverride( const EngineConfig& base, const EngineConfigOverride& override) noexcept", "summary": "Merges `override` into a copy of `base` (value semantics: the base is untouched) and validates every set field (the header preamble, Loading and overrides). Returns the merged config, or the first rejected set field's error (no config on failure).", "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader", "kind": "class", "header": "src/laige-sim/include/laige/sim/config.h", "line": 602, "signature": "class ConfigHotReloader", "summary": "The debug-build-only config hot reloader (FR-1.5; the contract in the header preamble, Loading and overrides).", "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/config.h", "line": 606, "signature": "[[nodiscard]] static Result create( std::string_view path) noexcept", "summary": "Binds to the config file at `path` and takes the baseline. Cold path: one bounded file read + one parse + one schema validation.", "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::poll", "kind": "method", "header": "src/laige-sim/include/laige/sim/config.h", "line": 611, "signature": "[[nodiscard]] Status poll(EngineConfig& config) noexcept", "summary": "Polls the file once (see the class contract above). `config` is updated in place only on a successful non-sim reload.", "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::ConfigHotReloader", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/config.h", "line": 613, "signature": "ConfigHotReloader(ConfigHotReloader&&) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/config.h", "line": 614, "signature": "ConfigHotReloader& operator=(ConfigHotReloader&&) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::ConfigHotReloader", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/config.h", "line": 615, "signature": "ConfigHotReloader(const ConfigHotReloader&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ConfigHotReloader::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/config.h", "line": 616, "signature": "ConfigHotReloader& operator=(const ConfigHotReloader&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::SimMathBackend", "kind": "enum", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 165, "signature": "enum class SimMathBackend : std::uint8_t", "summary": "The SimMath backend a deterministic run uses (ADR 0002; the typed form of the config ids \"fixed_point_16_16\" / \"float_pinned_32\"). Selected once at engine init (Engine::create consumes it — the ADR's \"factory-selected at init\", no per-call dispatch).", "budget": null, "experimental": false}, {"name": "laige::SimMathBackend::FixedPoint16_16", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 171, "signature": "FixedPoint16_16", "summary": "The default backend: Q16.16 in int32_t storage, int64_t intermediates (laige/fpx16_16.h). Bit-exact across all builds, platforms, ISAs, and compilers (guaranteed by the C++20 language standard — ADR 0002); required for lockstep (AC-10.3) and authoritative MMO.", "budget": null, "experimental": false}, {"name": "laige::SimMathBackend::FloatPinned32", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 177, "signature": "FloatPinned32", "summary": "Opt-in IEEE float semantics: binary32 (`float`) with the pinned flag set (ADR 0002, laige/sim_math.h). Bit-exact across runs of the same build on the same platform/ISA; cross-ISA identity is the detcheck matrix's job (M1-DET-04) — a failing pair is declared unsupported for this backend.", "budget": null, "experimental": false}, @@ -438,30 +515,22 @@ {"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": 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::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 385, "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": 403, "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": 409, "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": 413, "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": 439, "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": 447, "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": 451, "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": 457, "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": 492, "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": 497, "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": 501, "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": 506, "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": 507, "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": 508, "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": 509, "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": 513, "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": 275, "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": 276, "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": 277, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, @@ -594,56 +663,56 @@ {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kReplayMagic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 251, "signature": "inline constexpr std::uint8_t kReplayMagic[4] = {'L', 'G', 'R', 'P'}", "summary": "The log's magic (the first 4 bytes: \"LGRP\" — Laige GRePlay).", "budget": null, "experimental": false}, - {"name": "laige::kReplayFormatVersion", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 255, "signature": "inline constexpr std::uint16_t kReplayFormatVersion = 1", "summary": "The supported format version (ARCH-007: readers accept 1, reject everything else explicitly).", "budget": null, "experimental": false}, - {"name": "laige::kReplayHeaderSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 258, "signature": "inline constexpr std::size_t kReplayHeaderSize = 40", "summary": "The fixed header size in bytes (see the header layout above).", "budget": null, "experimental": false}, - {"name": "laige::kReplayFrameRecordOverhead", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 261, "signature": "inline constexpr std::size_t kReplayFrameRecordOverhead = 12", "summary": "The per-frame fixed record size in bytes (tick u64 + length u32).", "budget": null, "experimental": false}, - {"name": "laige::kReplayTrailerSize", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 264, "signature": "inline constexpr std::size_t kReplayTrailerSize = 16", "summary": "The fixed trailer size in bytes (frameCount u64 + fileHash u64).", "budget": null, "experimental": false}, - {"name": "laige::kMinReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 268, "signature": "inline constexpr std::uint64_t kMinReplaySizeLimit = 56", "summary": "The smallest size limit that can ever hold a complete log (header + trailer, zero frames).", "budget": null, "experimental": false}, - {"name": "laige::kMaxReplayFrameBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 273, "signature": "inline constexpr std::uint32_t kMaxReplayFrameBytes = 1u << 20", "summary": "The maximum frame blob in bytes (1 MiB): the u32 length field's documented domain cap for M1 opaque frames (M3-INPUT-03 defines the payload shape; the cap stands until a format version raises it).", "budget": null, "experimental": false}, - {"name": "laige::kDefaultReplaySizeLimit", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 278, "signature": "inline constexpr std::uint64_t kDefaultReplaySizeLimit = 128ull << 20", "summary": "The default total log size limit (128 MiB = header + frames + trailer): about 11.6M zero-length frames, about 5.2 hours of 60 Hz simulation. 0 passed to create() / loadReplay means this.", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 287, "signature": "struct ReplayIdentity", "summary": "The replay identity: the header's five identity fields. A plain value (PERF-005); compared field-by-field at replay time (M1-DET-03) — a log is bit-exact only under its own identity.", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 289, "signature": "std::uint64_t seed{}", "summary": "The master simulation seed (config.seed).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 291, "signature": "std::uint32_t tickRateHz{}", "summary": "The simulation tick rate in hertz (config.tickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 294, "signature": "std::uint64_t componentSchemaHash{}", "summary": "FNV-1a 64 over the component registry (see the header preamble \"The replay identity\").", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 297, "signature": "std::uint32_t mathBackendId{}", "summary": "The laige::SimMathBackend value (0 = FixedPoint16_16, 1 = FloatPinned32 — ADR 0002's backend ids).", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentity::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 299, "signature": "std::uint64_t configHash{}", "summary": "FNV-1a 64 over the provisional EngineConfig field encoding.", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 305, "signature": "struct ReplayFrame", "summary": "One recorded input frame: the completed tick number (the strict 1, 2, 3, ... sequence) plus the opaque byte blob (M1: zero bytes — no input system exists yet; M3-INPUT-03 defines the payload shape).", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame::tick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 307, "signature": "std::uint64_t tick{}", "summary": "The completed tick this frame belongs to (1-based, in order).", "budget": null, "experimental": false}, - {"name": "laige::ReplayFrame::data", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 309, "signature": "std::vector data", "summary": "The frame's opaque input bytes (empty in M1).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 316, "signature": "struct ReplayLog", "summary": "A parsed replay log (the loadReplay / parseReplay result): the identity plus every frame in tick order. Cold-path value: the frame storage is owned (one allocation per frame blob; M1 blobs are empty, so M1 logs cost one vector each).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog::identity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 318, "signature": "ReplayIdentity identity{}", "summary": "The log's replay identity (the header).", "budget": null, "experimental": false}, - {"name": "laige::ReplayLog::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 321, "signature": "std::vector frames", "summary": "Every recorded frame, in tick order (empty when the log has no frames — legal: a zero-tick run).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder", "kind": "class", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 333, "signature": "class ReplayRecorder", "summary": "The replay log writer: create() -> writeFrame() per completed tick -> finish() (see the header preamble \"Recorder contract\" for the full error table). Move-only; the engine owns one per run (opt-in, debug builds only). The recorder logs nothing — the engine emits the structured replay/* events (LOG-001/002).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 341, "signature": "[[nodiscard]] static Result create(const ReplayIdentity& identity, std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Create the recorder for `path` (the FINAL path — the temp file `path + \".tmp\"` is the only thing created now): validates the bounds, opens the temp file, and writes the header carrying `identity`. Error table in the header preamble; @budget one file open + one 40-byte write (cold path); allocates the stdio buffer (one setup allocation, owned by the FILE).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 345, "signature": "ReplayRecorder(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 346, "signature": "ReplayRecorder& operator=(const ReplayRecorder&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::ReplayRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 350, "signature": "ReplayRecorder(ReplayRecorder&& other) noexcept", "summary": "Move transfers the open file; the source becomes finished (a finished recorder does nothing — the moved-out GameLoop precedent).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 351, "signature": "ReplayRecorder& operator=(ReplayRecorder&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::~ReplayRecorder", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 354, "signature": "~ReplayRecorder() noexcept", "summary": "An unfinished recorder removes its temp file (the final path is never touched; the failure was already reported through a Status).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::writeFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 360, "signature": "[[nodiscard]] Status writeFrame(std::uint64_t tick, const std::uint8_t* data, std::size_t len) noexcept", "summary": "Record one input frame for completed tick `tick` carrying `len` bytes at `data` (len == 0: data may be nullptr). Error table in the header preamble; @budget one 12-byte (+ len) stdio write, no allocation (the stdio buffer holds the data until it flushes).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::finish", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 368, "signature": "[[nodiscard]] Status finish() noexcept", "summary": "Finalize: flush, write the trailer, close, and atomically rename the temp file onto the final path. Error table in the header preamble; @budget one flush + one 16-byte write + one rename (cold path).", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::status", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 373, "signature": "[[nodiscard]] Status status() const noexcept", "summary": "The sticky failure Status (ok while nothing has failed; the last error otherwise — the caller reports it, the recorder does not log). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::finished", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 376, "signature": "[[nodiscard]] bool finished() const noexcept", "summary": "True once finish() succeeded. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::bytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 380, "signature": "[[nodiscard]] std::uint64_t bytesWritten() const noexcept", "summary": "Bytes written so far (header + frame bytes; the trailer is counted when finish() writes it). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::frameCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 383, "signature": "[[nodiscard]] std::uint64_t frameCount() const noexcept", "summary": "Frames recorded so far. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRecorder::path", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 387, "signature": "[[nodiscard]] const char* path() const noexcept", "summary": "The FINAL path (the rename destination; the temp file is `path() + \".tmp\"`). Valid for the recorder's lifetime.", "budget": null, "experimental": false}, - {"name": "laige::parseReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 434, "signature": "[[nodiscard]] Result parseReplay(const std::uint8_t* data, std::size_t size) noexcept", "summary": "Parse a replay log from memory (the byte-level reader; the loadReplay file wrapper calls it). Accepts formatVersion == 1 and rejects every structural violation with MalformedInput (never a crash — SCALE-005 / ARCH-007; the violation table in the header preamble). Cold path (the parser's only allocations are the parsed log's frame storage).", "budget": "O(size) time, O(total frame bytes) allocation.", "experimental": false}, - {"name": "laige::loadReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 446, "signature": "[[nodiscard]] Result loadReplay(std::string_view path, std::uint64_t maxBytes = kDefaultReplaySizeLimit) noexcept", "summary": "Load and parse a replay log from `path`: reads the WHOLE file (a bounded read — an oversized file, size > maxBytes, is a MalformedInput, the ADR 0003 JSON-bound precedent) and passes it to parseReplay. maxBytes == 0 means kDefaultReplaySizeLimit.", "budget": "O(file size) time, O(file size) allocation (the bounded read).", "experimental": false}, - {"name": "laige::componentSchemaHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 461, "signature": "[[nodiscard]] std::uint64_t componentSchemaHash(const World& world) noexcept", "summary": "FNV-1a 64 over the world's component registry: the word stream [componentCount, then per type id in ascending order: id, size, alignment] (the house word-stream hash convention — big-endian byte order per u64 word; no addresses enter the words, ARCH-010). O(n) in the registered types (setup path — the registry is fixed before the run), no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, - {"name": "laige::configHash", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 469, "signature": "[[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept", "summary": "FNV-1a 64 over the provisional EngineConfig's canonical field encoding (tag word 1 — the M1-HEAD-01 surface: tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math). M1-CFG-01 refines the schema, and this encoding with it (under the format's versioning). O(1), no allocation.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::makeReplayIdentity", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 475, "signature": "[[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, const EngineConfig& config) noexcept", "summary": "Assemble the full replay identity (ADR 0002) from the world's component registry and the engine config. O(n) in the registered types, no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, - {"name": "laige::ReplayIdentityDiff", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 502, "signature": "struct ReplayIdentityDiff", "summary": "The field-by-field result of replayIdentityDiff: one bit per identity field (ADR 0002); empty() == every field matches.", "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 503, "signature": "bool seed{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 504, "signature": "bool tickRateHz{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 505, "signature": "bool componentSchemaHash{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 506, "signature": "bool mathBackendId{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 507, "signature": "bool configHash{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ReplayIdentityDiff::empty", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 510, "signature": "[[nodiscard]] bool empty() const noexcept", "summary": "True when no field differs (the identities match).", "budget": null, "experimental": false}, - {"name": "laige::replayIdentityDiff", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 523, "signature": "[[nodiscard]] ReplayIdentityDiff replayIdentityDiff(const ReplayLog& log, const World& world, const EngineConfig& config) noexcept", "summary": "Compare a loaded log's replay identity against the identity the caller's (world, config) would produce (makeReplayIdentity). A non-empty result is a REJECTED REPLAY (never a silent divergence — the header preamble \"The replay identity\"): the log was recorded under a different identity and must not be replayed on this world. O(n) in the registered types, no allocation, no side effects.", "budget": "O(componentCount); no allocation.", "experimental": false}, - {"name": "laige::ReplayRunResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 531, "signature": "struct ReplayRunResult", "summary": "The result of runReplay: the per-tick state hashes (the hash line contract above): tickHashes[i] == the world's stateHash(i) after the replay — index 0 is the initial state, indices 1..frameCount one entry per completed tick.", "budget": null, "experimental": false}, - {"name": "laige::ReplayRunResult::tickHashes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 533, "signature": "std::vector tickHashes", "summary": "frameCount + 1 entries (tick 0 .. frameCount).", "budget": null, "experimental": false}, - {"name": "laige::runReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 580, "signature": "[[nodiscard]] Result runReplay(const ReplayLog& log, World& world, const EngineConfig& config) noexcept", "summary": "Replay `log` headlessly on `world`:", "budget": "O(frameCount × tick work + state hash); one allocation", "experimental": false}, + {"name": "laige::kReplayMagic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 253, "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": 257, "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": 260, "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": 263, "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": 266, "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": 270, "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": 275, "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": 280, "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": 289, "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": 291, "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": 293, "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": 296, "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": 299, "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": 302, "signature": "std::uint64_t configHash{}", "summary": "FNV-1a 64 over the EngineConfig's simulation-affecting field encoding (M1-CFG-01's presentation fields are excluded).", "budget": null, "experimental": false}, + {"name": "laige::ReplayFrame", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 308, "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": 310, "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": 312, "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": 319, "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": 321, "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": 324, "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": 336, "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": 344, "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": 348, "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": 349, "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": 353, "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": 354, "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": 357, "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": 363, "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": 371, "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": 376, "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": 379, "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": 383, "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": 386, "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": 390, "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": 437, "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": 449, "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": 464, "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": 475, "signature": "[[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept", "summary": "FNV-1a 64 over the EngineConfig's canonical field encoding (tag word 1 — the simulation-affecting fields: tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math). M1-CFG-01's declared presentation fields (budgets, camera, asset_roots) are deliberately excluded — they never touch simulation — so the encoding is unchanged by the final schema and every committed baseline stays valid. 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": 481, "signature": "[[nodiscard]] ReplayIdentity makeReplayIdentity(const World& world, const EngineConfig& config) noexcept", "summary": "Assemble the full replay identity (ADR 0002) from the world's component registry and the engine config. O(n) in the registered types, no allocation.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::ReplayIdentityDiff", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 508, "signature": "struct ReplayIdentityDiff", "summary": "The field-by-field result of replayIdentityDiff: one bit per identity field (ADR 0002); empty() == every field matches.", "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 509, "signature": "bool seed{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 510, "signature": "bool tickRateHz{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::componentSchemaHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 511, "signature": "bool componentSchemaHash{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::mathBackendId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 512, "signature": "bool mathBackendId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::configHash", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 513, "signature": "bool configHash{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayIdentityDiff::empty", "kind": "method", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 516, "signature": "[[nodiscard]] bool empty() const noexcept", "summary": "True when no field differs (the identities match).", "budget": null, "experimental": false}, + {"name": "laige::replayIdentityDiff", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 529, "signature": "[[nodiscard]] ReplayIdentityDiff replayIdentityDiff(const ReplayLog& log, const World& world, const EngineConfig& config) noexcept", "summary": "Compare a loaded log's replay identity against the identity the caller's (world, config) would produce (makeReplayIdentity). A non-empty result is a REJECTED REPLAY (never a silent divergence — the header preamble \"The replay identity\"): the log was recorded under a different identity and must not be replayed on this world. O(n) in the registered types, no allocation, no side effects.", "budget": "O(componentCount); no allocation.", "experimental": false}, + {"name": "laige::ReplayRunResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 537, "signature": "struct ReplayRunResult", "summary": "The result of runReplay: the per-tick state hashes (the hash line contract above): tickHashes[i] == the world's stateHash(i) after the replay — index 0 is the initial state, indices 1..frameCount one entry per completed tick.", "budget": null, "experimental": false}, + {"name": "laige::ReplayRunResult::tickHashes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 539, "signature": "std::vector tickHashes", "summary": "frameCount + 1 entries (tick 0 .. frameCount).", "budget": null, "experimental": false}, + {"name": "laige::runReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 586, "signature": "[[nodiscard]] Result runReplay(const ReplayLog& log, World& world, const EngineConfig& config) noexcept", "summary": "Replay `log` headlessly on `world`:", "budget": "O(frameCount × tick work + state hash); one allocation", "experimental": false}, {"name": "laige::kReplayDiffDefaultEntries", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 233, "signature": "inline constexpr std::uint32_t kReplayDiffDefaultEntries = 16", "summary": "The default bounded-report size (differences reported in ReplayDiffResult::entries when maxEntries is left at its default). 16 items names the divergence without dumping a 10k-entity world.", "budget": null, "experimental": false}, {"name": "laige::kMaxReplayDiffEntries", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 239, "signature": "inline constexpr std::uint32_t kMaxReplayDiffEntries = 64", "summary": "The hard cap on the bounded report: a diff report beyond 64 differing (entity, component) pairs is a full dump, not a report — diffReplays clamps maxEntries to this, and the laige-replay CLI rejects --entries values above it at the argument boundary.", "budget": null, "experimental": false}, {"name": "laige::ReplayDiffResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 250, "signature": "struct ReplayDiffResult", "summary": "The result of one diffReplays call (field table in the header preamble \"diffReplays contract\"). A plain value; the entries' byte pointers are non-owning views into the two worlds' component columns (World::StateDiffItem — valid until the next mutation of the involved entities' components).", "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index b486fde..f3e78f8 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -215,7 +215,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A ## Configuration -- [ ] **M1-CFG-01 · Declarative game config** +- [x] **M1-CFG-01 · Declarative game config** - **Refs:** FR-1.5; PRD §7.1 - **Depends:** M0-CORE-07, M1-LOOP-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 85f94b7..d9a8372 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 | 18 | 🚧 in progress (M1-DET-04) | +| M1 | 25 | 20 | 🚧 in progress (M1-CFG-01) | | 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** | **36** | | +| **Total** | **193** | **38** | | --- @@ -212,6 +212,8 @@ One line per completed (or split/renumbered) step. | 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. | | 2026-09-17 | M1-SAMPLE-01 | `75d1df6` | hello.laige headless template game (NFR-13.5, PRD §9.4/§13, TEST-004, ADR 0002, ARCH-010, CORE-008): the first complete Laige game — samples/hello (hello.cpp: 99 code lines < the PRD §9.4 budget of 100, the ~76 NFR-13.5 comment lines do not count; one component PlayerPos + LAIGE_COMPONENT/LAIGE_DETERMINISM_SAFE at namespace scope, one system MovePlayer (LAIGE_SYSTEM; constant +1/tick per axis, wrapped in the [-16, 16] box), one entity at the box center; the canonical config (60 Hz / 8-entity / 256-churn / 0x1F055EED / fpx16_16) is embedded in the binary with config.json as its declarative record (hello_config_valid CTest runs laige-run --headless against it); the stdout stream is the detcheck scenario contract (tick-0 + one World::stateHash line per completed tick — 301 lines at 300 ticks, docs/api/detcheck.md), stderr = engine logging + the hello {headless|replay} ticks=N status=ok summary, exit 0/1/2; replay: --replay records its own run (debug builds only — the engine startReplayRecording policy parity, exit 2 under NDEBUG), --log loads the log, checks the replay identity against its own (world, config) via replayIdentityDiff, and re-runs the log's frame count through the same tick loop — record → replay is byte-identical (hello_replay CTest); laige-replay cannot replay a game log (it registers only the built-ins — different identity), so the scenario binary carries the mode (docs/api/replay.md); CI: tests/sample (9 CTest entries, TSan env blocks) — hello_scenario, hello_replay (Debug-gated), hello_replay_missing/_malformed, hello_mode_exclusive, hello_usage_unknown/_missing_value, hello_config_valid, hello_line_budget (CMake-core code-line counter: non-blank, non-// lines; semicolon-protected split, explicit whitespace class — CMake regexes have no POSIX classes); the detcheck job's real-scenario step self-activates (both no-arg runs must exit 0 — M1-DET-04 swaps in the build-asan vs build two-configuration comparison); docs: detcheck.md / replay.md / building.md / docs/README.md updated in the same change; local verification: Debug 76/76 + ASan 9/9 + Release gate + both CI forms byte-identical + determinism/include lints + api-manifest (no new public header) — additive only, no existing symbol or behavior changed. | | 2026-09-17 | M1-DET-04 | `—` | Bit-exactness CI (FR-1.4/11.5, NFR-8.3, PRD §14, ARCH-010, TEST-004, ADR 0002): activates `laige-detcheck` with the real M1-SAMPLE-01 scenario — committed per-tick hash-stream baselines in `samples/hello/baselines/` (one per SimMath backend; reference build: canonical Debug g++; 301 lines each), `hello --expect BASELINE` as the scenario-side equivalent of `laige-replay --expect` (the scenario binary carries the check — a game log cannot be replayed by laige-replay; same 0/1/2 contract, `hello::BaselineCheck` in `hello-baseline.cpp`), and the `hello-fp32` build variant (same source, `float_pinned_32` via the `LAIGE_HELLO_BACKEND` macros — one op surface, two backends); CI: every P0 OS job's ctest now asserts per-tick identity against the baselines on both backends (`hello_baseline_fpx`/`hello_baseline_fp32`) plus the failure fixtures (`hello_baseline_mismatch`/`_truncated`/`_malformed`/`_missing` — first-divergence, stream-length, malformed-line, missing-file paths), the merge `detcheck` job builds four configurations (Debug g++, Debug clang++, Debug+ASan clang++, Release g++) and runs the two-configuration pairs (pair A: g++ vs clang++ Debug; pair B: Debug+ASan vs Release — both backends, `--run-a/--run-b` + `--compare-combined`) after a reference-baseline sanity check, and the PR `detcheck` job runs the both-backend baseline comparison (tooling cadence, label-independent); local Verify: full `ctest` 82/82 on the canonical tree, all four detcheck pairs OK (ticks=301), byte-identical clang++ vs g++ streams (both backends), perturbation proof — `kVelocity` 1→2 in `MovePlayer` makes `hello --expect` exit 1 with "hash mismatch at tick 1 (first divergence)", the revert restores exit 0; determinism report recorded in `docs/benchmarks/determinism-matrix.md` (AGENTS §12 metadata, ARCH-010 scope statement, the `float_pinned_32` per-platform support list generated from matrix results — desynced pairs declared unsupported, never re-baselined; regeneration policy); `hello.cpp` stays at 99 code lines (PRD §9.4 — the baseline check and the fp32 variant live in separate TUs); docs updated in the same change (detcheck.md baseline-comparison section + CI status, hello README, building.md canonical rows, concepts/determinism.md cross-target, docs/README.md, tools/detcheck comment); commit lands with the step's PR (#41) | +| 2026-09-20 | M1-DET-05 | `6b63465` / PR #44 | Replay diff (FR-11.3, M1-DET-05 scope, nothing else): `World::componentStateHash` (the PRNG-excluded canonical state hash — the replay diff's alignment key) + `World::stateDiff` (the bounded canonical-order state comparison) + `diffReplays` (the replay DIFF: identity-checked lock-step tick walk aligned on the component-state hash, first divergent tick + bounded state diff, length divergence, `fullStateDivergent` flag — the tick-37 integration scenario) + `laige-replay --diff `; `replay_diff` CTest entry; local Verify: `ctest -R replay_diff` green, full suite green on the canonical trees (board/changelog row retroactively added 2026-09-21 by the M1-CFG-01 PR — the step merged as `6b63465`/PR #44 without updating this board or log) | +| 2026-09-21 | M1-CFG-01 | `—` | Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 `config.json` schema in a new public header `src/laige-sim/include/laige/sim/config.h` (+ `config.cpp`) — `EngineConfig` MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the `budgets` block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the `camera` block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and `asset_roots` (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED `version` key gates before every other key (missing → `config/version_missing`, non-integer → `config/version_invalid`, ≠ 1 → `config/version_unsupported` — the provisional M1-HEAD-01 documents migrate by adding `"version": 1`); unknown keys at any level warn `config/unknown_key` and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + `InvalidArgument`; `loadGameConfig(path)` (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); `EngineConfigOverride` + `applyConfigOverride` (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, `assetRoots` replacement); `ConfigHotReloader` (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/`record_disabled` pattern: release builds reject with `config/hot_reload_disabled`): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera.*, draw/particle budgets, asset_roots) apply in place + `config/hot_reload_applied` (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism.*, budgets.system_time_default_ms) refused ATOMICALLY + `config/hot_reload_rejected` (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (`config/hot_reload_read_failed` or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's `configHash` covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW `docs/api/config.md` (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; `Engine::create` re-validates the tick rate with the shared `kConfigTickRateInvalidMessage`); fixtures gain `"version": 1` (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW `game_config` CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — `ConfigHotReload.*`); engine_tests drops the migrated `EngineConfigParse.*` (the suites live in game_config_tests.cpp); determinism_tests' config docs gain `"version": 1`; `laige-api.json` regenerated (734 symbols); local Verify: `ctest -R config` green (config_json + game_config + hello_config_valid), full `ctest` 88/88 on `build` (Debug g++), `build-asan` (leak-free), `build-release` (NDEBUG — the `hot_reload_disabled` path exercised), `build-clang`, `build-tsan` (config suites `halt_on_error=1`), `build-shared`; zero new warnings under NFR-8.10; untested: the MSVC `_fsopen` branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split | --- diff --git a/samples/hello/README.md b/samples/hello/README.md index b793ebd..0ecc800 100644 --- a/samples/hello/README.md +++ b/samples/hello/README.md @@ -12,7 +12,7 @@ order. | File | Role | |---|---| | `hello.laige` | The project manifest (provisional M1 format: `laige.project` v1 JSON. The asset-pipeline format it replaces is owned by M3-ASSET-01). | -| `config.json` | The canonical sample config in the engine's declarative JSON surface (`laige-run`'s format, M1-HEAD-01). The values are identical to the config embedded in `hello.cpp` (see "Config" below). | +| `config.json` | The canonical sample config in the engine's declarative JSON surface (the version 1 schema, M1-CFG-01 — `laige-run`'s format). The values are identical to the config embedded in `hello.cpp` (see "Config" below). | | `hello.cpp` | The game source. | | `hello-baseline.cpp` | The `--expect` baseline check (M1-DET-04) — separate TU so `hello.cpp` stays inside the PRD §9.4 line budget. | | `hello-fp32.cpp` | The `float_pinned_32` build variant (M1-DET-04, ADR 0002): defines the backend macros and includes `hello.cpp` — same game source, backend swapped by the build. | @@ -102,10 +102,11 @@ backend field is `fixed_point_16_16` in `hello` and `LAIGE_HELLO_BACKEND_ID` macro, ADR 0002: the backend is part of the replay identity, so each binary's stream belongs to exactly one backend). `config.json` is the declarative record of the same values -in the engine's JSON surface — it is what -`laige-run --headless samples/hello/config.json` consumes and what the -M1-DET-04 baseline tooling reads; the template binary keeps its CLI -minimal (CORE-004) and does not re-read it. +in the engine's JSON surface (the version 1 schema: `"version": 1` +plus the same keys — [docs/api/config.md](../../docs/api/config.md)) +— it is what `laige-run --headless samples/hello/config.json` +consumes and what the M1-DET-04 baseline tooling reads; the template +binary keeps its CLI minimal (CORE-004) and does not re-read it. ## Replay diff --git a/samples/hello/config.json b/samples/hello/config.json index 44d8965..d4dfc50 100644 --- a/samples/hello/config.json +++ b/samples/hello/config.json @@ -1,4 +1,5 @@ { + "version": 1, "tick_rate_hz": 60, "entity_budget": 8, "churn_per_frame_budget": 256, diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index b0e0d7e..5ea2668 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -51,8 +51,13 @@ # run_headless/shutdown — config -> world -> systems -> loop, the # presentation onTick wiring this step owns, and the ordered CONC-006 # 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). +# include/laige/sim/engine.h). M1-CFG-01 adds config.cpp + +# include/laige/sim/config.h: the final versioned declarative config +# schema (EngineConfig — moved from engine.h — plus the version gate, +# the budgets/camera/asset_roots blocks, parseEngineConfig, +# loadGameConfig, the EngineConfigOverride merge, and the +# debug-only ConfigHotReloader — the provisional JSON config surface +# in engine.cpp is folded into it). # 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 @@ -74,7 +79,7 @@ # replay_diff.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp - engine.cpp replay.cpp state_hash.cpp + engine.cpp config.cpp replay.cpp state_hash.cpp replay_diff.cpp) if(LAIGE_BUILD_SHARED) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index d27c3b8..d15e1c9 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -85,8 +85,7 @@ class-template pattern, ADR 0002) (`include/laige/sim/presentation.h` [docs/api/presentation.md](../docs/api/presentation.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `presentation`). M1-HEAD-01 landed the headless engine run — `Engine` -(config → world → systems → loop: `EngineConfig` + the provisional -`parseEngineConfig` JSON surface, `run_headless(maxTicks)` the +(config → world → systems → loop: `run_headless(maxTicks)` the bounded + server run forms, the ordered idempotent CONC-006 shutdown, the presentation snapshot wiring on the configured SimMath backend) plus the `laige-run` binary (`--headless`/`--ticks`/ @@ -94,6 +93,20 @@ backend) plus the `laige-run` binary (`--headless`/`--ticks`/ (`include/laige/sim/engine.h`, `engine.cpp`, `tools/run`; API contract in [docs/api/engine.md](../docs/api/engine.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `engine`). +M1-CFG-01 landed the declarative game config — the version 1 +`config.json` schema (`EngineConfig` + `BudgetsConfig` + +`CameraConfig`, the REQUIRED `version` key, the budgets/camera/ +asset_roots declared blocks), `parseEngineConfig`/`loadGameConfig` +(the bounded file read + schema validation), the +`EngineConfigOverride`/`applyConfigOverride` merge, and the +debug-build-only `ConfigHotReloader` (hot reload of non-simulation +keys; sim-affecting changes are refused with +`config/hot_reload_rejected`) — folded in from the provisional +M1-HEAD-01 surface; `EngineConfig` moved to +`include/laige/sim/config.h` (the five original members keep their +order); API contract in +[docs/api/config.md](../docs/api/config.md), tests under +[tests/laige-sim](../tests/laige-sim) (CTest entry `game_config`). M1-DET-01 landed deterministic mode — the SimMath-only sim guarantee (the G-R8 compile-time trait `include/laige/sim/determinism.h`: `SimMathBackend`, @@ -101,8 +114,9 @@ guarantee (the G-R8 compile-time trait `LAIGE_DETERMINISM_SAFE`, enforced by a `static_assert` in `World::registerSystem`), the per-system PRNG substreams (`World::Options.seed`/`deterministic`, `SystemContext.rng`), the -`seed`/`determinism` keys on the provisional config surface + the -backend selection at engine init (built-in component + snapshot), and +`seed`/`determinism` keys on the config surface (final: the +version 1 schema, M1-CFG-01) + the backend selection at engine init +(built-in component + snapshot), and the sim-source determinism scan (`tools/laige-determinism-lint`, the `determinism-lint` CI job) with same-line `LAIGE-DETERM-EXCEPTION` markers as the documented false-positive diff --git a/src/laige-sim/config.cpp b/src/laige-sim/config.cpp new file mode 100644 index 0000000..6730a1b --- /dev/null +++ b/src/laige-sim/config.cpp @@ -0,0 +1,751 @@ +// laige-sim declarative game config (M1-CFG-01). +// +// Implementation of the EngineConfig loaders (parseEngineConfig, +// loadGameConfig), the subset-override merge (applyConfigOverride), +// and the debug-only hot reloader (ConfigHotReloader) declared in +// include/laige/sim/config.h — see that header for the full contract +// (the version 1 schema, versioning, loading and overrides, ownership +// and performance, the error events) and docs/api/config.md for the +// API document. +// +// Hot-path cost: none — every function here is a cold path (startup, +// dev-time, or explicit dev-loop polling); the sim tick path is never +// touched (PERF-002/003, DBG-004). + +#include "laige/sim/config.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below +#endif + +#include "laige/json.h" +#include "laige/logging.h" + +namespace laige { + +namespace { + +// The 1 MiB config-document bound (ADR 0003 / the JsonOptions default): +// the read stops one byte past the bound so an oversized file is a +// MalformedInput, not a truncated parse. +inline constexpr std::size_t kMaxConfigBytes = 1u << 20; + +// The ADR 0003 JSON number bound: 2^53 (doubles are exact up to +// here) — the upper bound of the JSON "seed" key (and of "version"). +inline constexpr std::uint64_t kMaxJsonSeed = 9007199254740992ull; + +// Portable file open (CPP-009 platform boundary; the laige-run.cpp / +// replay.cpp precedent): MSVC's CRT deprecates plain fopen (C4996, +// fatal under the engine's /WX policy), so the MSVC path uses +// _fsopen with _SH_DENYNO — plain-fopen sharing semantics. +#if defined(_MSC_VER) +std::FILE* openConfigFile(const char* path) { + return ::_fsopen(path, "rb", _SH_DENYNO); +} +#else +std::FILE* openConfigFile(const char* path) { + return std::fopen(path, "rb"); +} +#endif + +// Reads the file at `path` fully into `out` (bounded, CORE-008): +// open/read failure -> IoError (config/file_read_failed) +// over the bound -> MalformedInput (config/file_read_failed) +// On success `out` holds the exact file bytes (a failed read leaves +// `out` untouched). Cold path: one file open + bounded reads. +Status readFileBounded(std::string_view path, std::string* out) noexcept { + std::FILE* file = openConfigFile(std::string(path).c_str()); + if (file == nullptr) { + LAIGE_LOG_WARN(kConfigSubsystem, "file_read_failed", + kConfigFileReadFailedMessage, + laige::log::field("path", path)); + return ErrorCode::IoError; + } + std::string bytes; + char buffer[8192]; + for (;;) { + const std::size_t n = std::fread(buffer, 1, sizeof(buffer), file); + if (n > 0) { + bytes.append(buffer, n); + if (bytes.size() > kMaxConfigBytes) { + std::fclose(file); + LAIGE_LOG_WARN(kConfigSubsystem, "file_read_failed", + kConfigFileReadFailedMessage, + laige::log::field("path", path), + laige::log::field("bound_bytes", kMaxConfigBytes)); + return ErrorCode::MalformedInput; + } + } else { + if (!std::feof(file)) { + // A short read that is not EOF: a read error (a locked or + // vanishing file — the transient states the reloader retries). + std::fclose(file); + LAIGE_LOG_WARN(kConfigSubsystem, "file_read_failed", + kConfigFileReadFailedMessage, + laige::log::field("path", path)); + return ErrorCode::IoError; + } + break; // clean EOF + } + } + std::fclose(file); + *out = std::move(bytes); + return Status{}; +} + +// A JsonValue holds an exact integer in [lo, hi] (ADR 0003 JSON number +// policy — config parsing, not sim math). +bool parseIntInRange(const JsonValue& value, std::uint32_t lo, + std::uint32_t hi, std::uint32_t* out) noexcept { + if (!value.isNumber()) return false; + const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + d != std::floor(d)) { + return false; + } + const std::uint32_t u = static_cast(d); + if (u < lo) return false; + *out = u; + return true; +} + +// A JsonValue holds an exact integer in [lo, hi] (u64 range; the +// ADR 0003 JSON number policy — config parsing, not sim math). +bool parseUint64InRange(const JsonValue& value, std::uint64_t lo, + std::uint64_t hi, std::uint64_t* out) noexcept { + if (!value.isNumber()) return false; + const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + d != std::floor(d)) { + return false; + } + const std::uint64_t u = static_cast(d); + if (u < lo) return false; + *out = u; + return true; +} + +// A JsonValue holds a finite double (the ADR 0003 JSON number policy — +// config parsing, not sim math). +bool parseFiniteDouble(const JsonValue& value, double* out) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003): the config number is a double, not sim math + if (!value.isNumber()) return false; + const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + if (!std::isfinite(d)) return false; + *out = d; + return true; +} + +// A stable machine-searchable name for a JsonValue's kind (the +// `value_kind` field of the rejection warns; LOG-001). +const char* jsonKindName(const JsonValue& value) noexcept { + switch (value.kind()) { + case JsonKind::Null: return "null"; + case JsonKind::Bool: return "bool"; + case JsonKind::Number: return "number"; + case JsonKind::String: return "string"; + case JsonKind::Array: return "array"; + case JsonKind::Object: return "object"; + } + return "unknown"; +} + +} // namespace + +// --------------------------------------------------------------------------- +// parseEngineConfig (the version 1 schema — the rejection table in +// config.h; the full key table in docs/api/config.md) +// --------------------------------------------------------------------------- + +Result parseEngineConfig(const JsonValue& doc) noexcept { + if (!doc.isObject()) { + LAIGE_LOG_WARN(kConfigSubsystem, "not_an_object", + kConfigNotAnObjectMessage); + return ErrorCode::InvalidArgument; + } + // The version gate runs FIRST (ARCH-007): before any other key is + // read, so a document from the wrong schema generation is rejected + // on the oldest rule, whatever else it contains. + const JsonValue* version = doc.findMember("version"); + if (version == nullptr) { + LAIGE_LOG_WARN(kConfigSubsystem, "version_missing", + kConfigVersionMissingMessage); + return ErrorCode::InvalidArgument; + } + std::uint64_t versionValue = 0; + if (!parseUint64InRange(*version, 0, kMaxJsonSeed, &versionValue)) { + if (version->isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "version_invalid", + kConfigVersionInvalidMessage, + laige::log::field("value", version->asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "version_invalid", + kConfigVersionInvalidMessage, + laige::log::field("value_kind", + jsonKindName(*version))); + } + return ErrorCode::InvalidArgument; + } + if (versionValue != kSupportedConfigVersion) { + LAIGE_LOG_WARN(kConfigSubsystem, "version_unsupported", + kConfigVersionUnsupportedMessage, + laige::log::field("version", versionValue), + laige::log::field("supported", kSupportedConfigVersion)); + return ErrorCode::InvalidArgument; + } + + EngineConfig config; + for (const auto& [key, value] : doc.asObject()) { + if (key == "version") { + // Validated by the gate above; the walk skips the gate key. + } else if (key == "tick_rate_hz") { + if (!parseIntInRange(value, kMinTickRateHz, kMaxTickRateHz, + &config.tickRateHz)) { + if (value.isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", + kConfigTickRateInvalidMessage, + laige::log::field("key", key), + laige::log::field("value", value.asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", + kConfigTickRateInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + } + return ErrorCode::InvalidArgument; + } + } else if (key == "entity_budget") { + if (!parseIntInRange(value, 0, Entity::kMaxEntities, + &config.entityCapacity)) { + if (value.isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "entity_budget_invalid", + kConfigEntityBudgetInvalidMessage, + laige::log::field("key", key), + laige::log::field("value", value.asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "entity_budget_invalid", + kConfigEntityBudgetInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + } + return ErrorCode::InvalidArgument; + } + } else if (key == "churn_per_frame_budget") { + const std::uint32_t kMaxUint32 = + std::numeric_limits::max(); + if (!parseIntInRange(value, 0, kMaxUint32, + &config.churnPerFrameBudget)) { + if (value.isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "churn_budget_invalid", + kConfigChurnBudgetInvalidMessage, + laige::log::field("key", key), + laige::log::field("value", value.asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "churn_budget_invalid", + kConfigChurnBudgetInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + } + return ErrorCode::InvalidArgument; + } + } else if (key == "seed") { + std::uint64_t seed = 0; + if (!parseUint64InRange(value, 0, kMaxJsonSeed, &seed)) { + if (value.isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", + kConfigSeedInvalidMessage, + laige::log::field("key", key), + laige::log::field("value", value.asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", + kConfigSeedInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + } + return ErrorCode::InvalidArgument; + } + config.seed = seed; + } else if (key == "determinism") { + if (!value.isObject()) { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_invalid", + kConfigDeterminismInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + return ErrorCode::InvalidArgument; + } + for (const auto& [dkey, dvalue] : value.asObject()) { + if (dkey == "enabled") { + if (!dvalue.isBool()) { + LAIGE_LOG_WARN(kConfigSubsystem, + "determinism_enabled_invalid", + kConfigDeterminismEnabledInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value_kind", + jsonKindName(dvalue))); + return ErrorCode::InvalidArgument; + } + config.determinism.enabled = dvalue.asBool(); + } else if (dkey == "math") { + if (!dvalue.isString()) { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", + kConfigDeterminismMathInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value_kind", + jsonKindName(dvalue))); + return ErrorCode::InvalidArgument; + } + const std::string_view m = dvalue.asString(); + if (m == "fixed_point_16_16") { + config.determinism.math = SimMathBackend::FixedPoint16_16; + } else if (m == "float_pinned_32") { + config.determinism.math = SimMathBackend::FloatPinned32; + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", + kConfigDeterminismMathInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value", std::string{m})); + return ErrorCode::InvalidArgument; + } + } else { + // Unknown nested key: WARN (forward-compat) and ignore — + // the schema rule applied inside the block (never silent). + LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", + kConfigUnknownKeyMessage, + laige::log::field("key", dkey)); + } + } + } else if (key == "budgets") { + if (!value.isObject()) { + LAIGE_LOG_WARN(kConfigSubsystem, "budgets_invalid", + kConfigBudgetsInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + return ErrorCode::InvalidArgument; + } + const std::uint32_t kMaxUint32 = + std::numeric_limits::max(); + for (const auto& [bkey, bvalue] : value.asObject()) { + if (bkey == "system_time_default_ms") { + double ms = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + if (!parseFiniteDouble(bvalue, &ms) || ms <= 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "system_time_budget_invalid", + kConfigSystemTimeBudgetInvalidMessage, + laige::log::field("key", bkey), + laige::log::field("value_kind", + jsonKindName(bvalue))); + return ErrorCode::InvalidArgument; + } + config.budgets.systemTimeDefaultMs = ms; + } else if (bkey == "draw_calls_per_frame") { + if (!parseIntInRange(bvalue, 0, kMaxUint32, + &config.budgets.drawCallsPerFrame)) { + LAIGE_LOG_WARN(kConfigSubsystem, "draw_calls_budget_invalid", + kConfigDrawCallsBudgetInvalidMessage, + laige::log::field("key", bkey), + laige::log::field("value_kind", + jsonKindName(bvalue))); + return ErrorCode::InvalidArgument; + } + } else if (bkey == "particles_per_frame") { + if (!parseIntInRange(bvalue, 0, kMaxUint32, + &config.budgets.particlesPerFrame)) { + LAIGE_LOG_WARN(kConfigSubsystem, "particles_budget_invalid", + kConfigParticlesBudgetInvalidMessage, + laige::log::field("key", bkey), + laige::log::field("value_kind", + jsonKindName(bvalue))); + return ErrorCode::InvalidArgument; + } + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", + kConfigUnknownKeyMessage, + laige::log::field("key", bkey)); + } + } + } else if (key == "camera") { + if (!value.isObject()) { + LAIGE_LOG_WARN(kConfigSubsystem, "camera_invalid", + kConfigCameraInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + return ErrorCode::InvalidArgument; + } + for (const auto& [ckey, cvalue] : value.asObject()) { + if (ckey == "fov_degrees") { + double fov = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + if (!parseFiniteDouble(cvalue, &fov) || fov <= 0.0 || // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + fov > 180.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_fov_invalid", + kConfigCameraFovInvalidMessage, + laige::log::field("key", ckey), + laige::log::field("value_kind", + jsonKindName(cvalue))); + return ErrorCode::InvalidArgument; + } + config.camera.fovDegrees = fov; + } else if (ckey == "zoom") { + double zoom = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + if (!parseFiniteDouble(cvalue, &zoom) || zoom <= 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_zoom_invalid", + kConfigCameraZoomInvalidMessage, + laige::log::field("key", ckey), + laige::log::field("value_kind", + jsonKindName(cvalue))); + return ErrorCode::InvalidArgument; + } + config.camera.zoom = zoom; + } else if (ckey == "follow_lerp_per_sec") { + double lerp = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + if (!parseFiniteDouble(cvalue, &lerp) || lerp < 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config parsing (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_follow_lerp_invalid", + kConfigCameraFollowLerpInvalidMessage, + laige::log::field("key", ckey), + laige::log::field("value_kind", + jsonKindName(cvalue))); + return ErrorCode::InvalidArgument; + } + config.camera.followLerpPerSec = lerp; + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", + kConfigUnknownKeyMessage, + laige::log::field("key", ckey)); + } + } + } else if (key == "asset_roots") { + if (!value.isArray()) { + LAIGE_LOG_WARN(kConfigSubsystem, "asset_roots_invalid", + kConfigAssetRootsInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(value))); + return ErrorCode::InvalidArgument; + } + for (const JsonValue& root : value.asArray()) { + if (!root.isString() || root.asString().empty()) { + LAIGE_LOG_WARN(kConfigSubsystem, "asset_roots_invalid", + kConfigAssetRootsInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", + jsonKindName(root))); + return ErrorCode::InvalidArgument; + } + config.assetRoots.emplace_back(root.asString()); + } + } else { + // Unknown top-level key: WARN (forward-compat) and ignore — + // never silent (CORE-008). + LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", + kConfigUnknownKeyMessage, + laige::log::field("key", key)); + } + } + return config; +} + +// --------------------------------------------------------------------------- +// loadGameConfig (file -> document -> typed config) +// --------------------------------------------------------------------------- + +Result loadGameConfig(std::string_view path) noexcept { + std::string bytes; + const Status readStatus = readFileBounded(path, &bytes); + if (readStatus.isError()) return readStatus.error(); // IoError / MalformedInput + const Result parsed = parseJson(bytes); + if (parsed.isError()) return parsed.error(); // MalformedInput (ADR 0003) + return parseEngineConfig(parsed.value()); +} + +// --------------------------------------------------------------------------- +// applyConfigOverride (the programmatic override-of-a-subset merge) +// --------------------------------------------------------------------------- + +Result applyConfigOverride( + const EngineConfig& base, const EngineConfigOverride& override) noexcept { + EngineConfig config = base; // value semantics: the base is untouched + // Every set field is validated in its documented domain, first set + // field that fails wins (the struct's declaration order). + if (override.tickRateHz) { + if (*override.tickRateHz < kMinTickRateHz || + *override.tickRateHz > kMaxTickRateHz) { + LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", + kConfigTickRateInvalidMessage, + laige::log::field("value", *override.tickRateHz)); + return ErrorCode::InvalidArgument; + } + config.tickRateHz = *override.tickRateHz; + } + if (override.entityCapacity) { + if (*override.entityCapacity > Entity::kMaxEntities) { + LAIGE_LOG_WARN(kConfigSubsystem, "entity_budget_invalid", + kConfigEntityBudgetInvalidMessage, + laige::log::field("value", *override.entityCapacity)); + return ErrorCode::InvalidArgument; + } + config.entityCapacity = *override.entityCapacity; + } + if (override.churnPerFrameBudget) { + // uint32: every value is in the >= 0 domain — no check needed. + config.churnPerFrameBudget = *override.churnPerFrameBudget; + } + if (override.seed) { + // uint64: the programmatic surface accepts the full 64 bits (no + // 2^53 JSON bound — the config.h table). + config.seed = *override.seed; + } + if (override.determinismEnabled) { + config.determinism.enabled = *override.determinismEnabled; + } + if (override.determinismMath) { + config.determinism.math = *override.determinismMath; + } + if (override.systemTimeDefaultMs) { + const double ms = *override.systemTimeDefaultMs; // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + if (!std::isfinite(ms) || ms <= 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "system_time_budget_invalid", + kConfigSystemTimeBudgetInvalidMessage, + laige::log::field("value", ms)); + return ErrorCode::InvalidArgument; + } + config.budgets.systemTimeDefaultMs = ms; + } + if (override.drawCallsPerFrame) { + config.budgets.drawCallsPerFrame = *override.drawCallsPerFrame; + } + if (override.particlesPerFrame) { + config.budgets.particlesPerFrame = *override.particlesPerFrame; + } + if (override.fovDegrees) { + const double fov = *override.fovDegrees; // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + if (!std::isfinite(fov) || fov <= 0.0 || fov > 180.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_fov_invalid", + kConfigCameraFovInvalidMessage, + laige::log::field("value", fov)); + return ErrorCode::InvalidArgument; + } + config.camera.fovDegrees = fov; + } + if (override.zoom) { + const double zoom = *override.zoom; // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + if (!std::isfinite(zoom) || zoom <= 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_zoom_invalid", + kConfigCameraZoomInvalidMessage, + laige::log::field("value", zoom)); + return ErrorCode::InvalidArgument; + } + config.camera.zoom = zoom; + } + if (override.followLerpPerSec) { + const double lerp = *override.followLerpPerSec; // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + if (!std::isfinite(lerp) || lerp < 0.0) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time value (ADR 0003 JSON number policy), not sim math + LAIGE_LOG_WARN(kConfigSubsystem, "camera_follow_lerp_invalid", + kConfigCameraFollowLerpInvalidMessage, + laige::log::field("value", lerp)); + return ErrorCode::InvalidArgument; + } + config.camera.followLerpPerSec = lerp; + } + if (override.assetRoots) { + for (const std::string& root : *override.assetRoots) { + if (root.empty()) { + LAIGE_LOG_WARN(kConfigSubsystem, "asset_roots_invalid", + kConfigAssetRootsInvalidMessage, + laige::log::field("key", "asset_roots")); + return ErrorCode::InvalidArgument; + } + } + config.assetRoots = *override.assetRoots; // the cold-path copy + } + return config; +} + +// --------------------------------------------------------------------------- +// ConfigHotReloader (debug builds only — FR-1.5) +// --------------------------------------------------------------------------- + +// Emits the hot_reload_rejected ERROR for one changed sim-affecting +// key and returns the failure Status (the caller's config and the +// reloader's baseline are untouched — restart to apply, FR-1.5). +Status rejectSimKey(const char* key, const log::Field& oldField, + const log::Field& newField) noexcept { + LAIGE_LOG_ERROR(kConfigSubsystem, "hot_reload_rejected", + kConfigHotReloadRejectedMessage, + laige::log::field("key", key), oldField, newField); + return ErrorCode::InvalidArgument; +} + +Result ConfigHotReloader::create( + std::string_view path) noexcept { +#if defined(NDEBUG) + // Hot reload is development tooling: release builds reject the + // feature call (the replay/record_disabled pattern). + LAIGE_LOG_WARN(kConfigSubsystem, "hot_reload_disabled", + kConfigHotReloadDisabledMessage); + return ErrorCode::InvalidArgument; +#else + std::string bytes; + const Status readStatus = readFileBounded(path, &bytes); + if (readStatus.isError()) return readStatus.error(); // IoError / MalformedInput + const Result parsed = parseJson(bytes); + if (parsed.isError()) return parsed.error(); // MalformedInput (ADR 0003) + Result validated = + parseEngineConfig(parsed.value()); + if (validated.isError()) return validated.error(); + ConfigHotReloader reloader; + reloader.path_ = std::string(path); + reloader.baselineBytes_ = std::move(bytes); + reloader.baselineConfig_ = std::move(validated).takeValue(); + return reloader; +#endif +} + +Status ConfigHotReloader::poll(EngineConfig& config) noexcept { +#if defined(NDEBUG) + (void)config; + LAIGE_LOG_WARN(kConfigSubsystem, "hot_reload_disabled", + kConfigHotReloadDisabledMessage); + return ErrorCode::InvalidArgument; +#else + if (movedFrom_) return ErrorCode::InvalidArgument; // no log (stopped state) + std::string bytes; + const Status readStatus = readFileBounded(path_, &bytes); + if (readStatus.isError()) { + // Transient (locked/deleted/oversized file): the previous config + // stays in effect; the next poll retries. + LAIGE_LOG_WARN(kConfigSubsystem, "hot_reload_read_failed", + kConfigHotReloadReadFailedMessage, + laige::log::field("path", path_)); + return readStatus; + } + if (bytes == baselineBytes_) return Status{}; // unchanged: no event + const Result parsed = parseJson(bytes); + if (parsed.isError()) return parsed.error(); // MalformedInput; no change + Result freshResult = + parseEngineConfig(parsed.value()); + if (freshResult.isError()) return freshResult.error(); // no change + const EngineConfig& fresh = freshResult.value(); + const EngineConfig& base = baselineConfig_; + // The simulation-affecting comparison (the documented key order; the + // version cannot differ between two parseable documents — a + // different version fails the gate above — so the set starts at the + // tick rate). + if (fresh.tickRateHz != base.tickRateHz) { + return rejectSimKey("tick_rate_hz", + log::field("old", base.tickRateHz), + log::field("new", fresh.tickRateHz)); + } + if (fresh.entityCapacity != base.entityCapacity) { + return rejectSimKey("entity_budget", + log::field("old", base.entityCapacity), + log::field("new", fresh.entityCapacity)); + } + if (fresh.churnPerFrameBudget != base.churnPerFrameBudget) { + return rejectSimKey("churn_per_frame_budget", + log::field("old", base.churnPerFrameBudget), + log::field("new", fresh.churnPerFrameBudget)); + } + if (fresh.seed != base.seed) { + return rejectSimKey("seed", log::field("old", base.seed), + log::field("new", fresh.seed)); + } + if (fresh.determinism.enabled != base.determinism.enabled) { + return rejectSimKey("determinism.enabled", + log::field("old", base.determinism.enabled), + log::field("new", fresh.determinism.enabled)); + } + if (fresh.determinism.math != base.determinism.math) { + return rejectSimKey( + "determinism.math", + log::field("old", static_cast(base.determinism.math)), + log::field("new", + static_cast(fresh.determinism.math))); + } + if (fresh.budgets.systemTimeDefaultMs != + base.budgets.systemTimeDefaultMs) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time comparison of declared budget values, not sim math + return rejectSimKey( + "budgets.system_time_default_ms", + log::field("old", base.budgets.systemTimeDefaultMs), + log::field("new", fresh.budgets.systemTimeDefaultMs)); + } + // Only non-simulation keys changed: apply them in place (the file is + // now authoritative for them — a programmatic override of those + // fields is replaced, the config.h contract) and advance the + // baseline. + std::vector changed; + if (fresh.budgets.drawCallsPerFrame != base.budgets.drawCallsPerFrame) { + config.budgets.drawCallsPerFrame = fresh.budgets.drawCallsPerFrame; + changed.push_back("budgets.draw_calls_per_frame"); + } + if (fresh.budgets.particlesPerFrame != base.budgets.particlesPerFrame) { + config.budgets.particlesPerFrame = fresh.budgets.particlesPerFrame; + changed.push_back("budgets.particles_per_frame"); + } + if (fresh.camera.fovDegrees != base.camera.fovDegrees) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time comparison of declared camera defaults, not sim math + config.camera.fovDegrees = fresh.camera.fovDegrees; + changed.push_back("camera.fov_degrees"); + } + if (fresh.camera.zoom != base.camera.zoom) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time comparison of declared camera defaults, not sim math + config.camera.zoom = fresh.camera.zoom; + changed.push_back("camera.zoom"); + } + if (fresh.camera.followLerpPerSec != base.camera.followLerpPerSec) { // LAIGE-DETERM-EXCEPTION: G-R8 config-time comparison of declared camera defaults, not sim math + config.camera.followLerpPerSec = fresh.camera.followLerpPerSec; + changed.push_back("camera.follow_lerp_per_sec"); + } + if (fresh.assetRoots != base.assetRoots) { + config.assetRoots = fresh.assetRoots; + changed.push_back("asset_roots"); + } + if (!changed.empty()) { + // One Info event naming every changed key (bounded: <= 6 keys; the + // joined string is a cold debug-path allocation). + std::string keys; + for (const std::string& key : changed) { + if (!keys.empty()) keys.push_back(','); + keys += key; + } + LAIGE_LOG_INFO(kConfigSubsystem, "hot_reload_applied", + kConfigHotReloadAppliedMessage, + laige::log::field("keys", keys), + laige::log::field("count", changed.size())); + } + baselineBytes_ = std::move(bytes); + baselineConfig_ = std::move(freshResult).takeValue(); + return Status{}; +#endif +} + +ConfigHotReloader::ConfigHotReloader(ConfigHotReloader&& other) noexcept + : path_(std::move(other.path_)), + baselineBytes_(std::move(other.baselineBytes_)), + baselineConfig_(std::move(other.baselineConfig_)), + movedFrom_(other.movedFrom_) { + other.movedFrom_ = true; +} + +ConfigHotReloader& ConfigHotReloader::operator=( + ConfigHotReloader&& other) noexcept { + if (this != &other) { + path_ = std::move(other.path_); + baselineBytes_ = std::move(other.baselineBytes_); + baselineConfig_ = std::move(other.baselineConfig_); + movedFrom_ = other.movedFrom_; + other.movedFrom_ = true; + } + return *this; +} + +} // namespace laige diff --git a/src/laige-sim/engine.cpp b/src/laige-sim/engine.cpp index 685c347..e85d963 100644 --- a/src/laige-sim/engine.cpp +++ b/src/laige-sim/engine.cpp @@ -1,12 +1,13 @@ // laige-sim headless engine run (M1-HEAD-01; FR-1.6, ARCH-003, // 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 replay recording, the performance notes) and docs/api/engine.md -// for the API document and the laige-run CLI contract. +// Implementation of the Engine declared in include/laige/sim/engine.h +// — see that header for the full contract (the lifecycle, the run +// contract, the ordered shutdown, the config surface (M1-CFG-01: +// EngineConfig + the versioned schema live in laige/sim/config.h), +// the determinism scope, 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 — @@ -19,15 +20,12 @@ #include "laige/sim/engine.h" // the Engine contract (this header) #include -#include #include -#include #include #include #include #include -#include "laige/json.h" #include "laige/logging.h" namespace laige { @@ -38,9 +36,9 @@ namespace { // game_loop.cpp constant). inline constexpr std::int64_t kNanosecondsPerSecond = 1000000000LL; -// The stable subsystem names (LOG-001). +// The stable subsystem names (LOG-001). kConfigSubsystem is defined in +// laige/sim/config.h (M1-CFG-01 owns the config surface). 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 @@ -52,67 +50,17 @@ std::int64_t steadyNowNs() noexcept { .count(); } +// The config-surface rejection messages (the config/_invalid +// events, the config/hot_reload_* events) live in laige/sim/config.h +// (M1-CFG-01 owns the config surface — one text for one rejection, +// LOG-002); Engine::create's tick-rate re-validation shares +// kConfigTickRateInvalidMessage from there. +// // NFR-13.3 5-field grammar, identical in every build ({code} | // {what} | {why} | {fix} | {doc_anchor}): the machine-parseable // message stays build-stable; the dynamic values are structured // fields, never message text (the system_timing.cpp precedent). -inline constexpr const char* kNotAnObjectMessage = - "not_an_object | the engine config document is not a JSON object | " - "the headless config must be a top-level object | wrap the config " - "in a top-level object ({} for all defaults) | docs/api/engine.md"; - -inline constexpr const char* kTickRateInvalidMessage = - "tick_rate_invalid | the configured tick_rate_hz is invalid | the " - "value must be an exact integer in the 20-120 Hz range | set " - "tick_rate_hz to a value in 20-120 (the default is 60) | " - "docs/api/engine.md"; - -inline constexpr const char* kEntityBudgetInvalidMessage = - "entity_budget_invalid | the configured entity_budget is invalid | " - "the value must be an exact integer in 0-65536 (the 16-bit entity " - "id space) | set entity_budget to a value in 0-65536 (the " - "scene's declared budget, G-R3) | docs/api/engine.md"; - -inline constexpr const char* kChurnBudgetInvalidMessage = - "churn_budget_invalid | the configured churn_per_frame_budget is " - "invalid | the value must be a non-negative exact integer | set " - "churn_per_frame_budget to a non-negative integer (the default is " - "256; 0 disables the G-R4 guardrail) | docs/api/engine.md"; - -inline constexpr const char* kUnknownKeyMessage = - "unknown_key | the config key is not part of the M1-HEAD-01 config " - "surface | the key is not (yet) consumed by the headless run " - "(M1-CFG-01 lands the full declarative schema) | remove the key, " - "or wait for M1-CFG-01 | docs/api/engine.md"; - -// M1-DET-01: the determinism config keys (seed, determinism.*). -inline constexpr const char* kSeedInvalidMessage = - "seed_invalid | the configured seed is invalid | the value must be " - "an exact integer in 0-2^53 (the ADR 0003 JSON number bound: " - "doubles are exact to 2^53; the programmatic EngineConfig.seed " - "accepts the full 64 bits) | set seed to an integer in 0-2^53 " - "(the default is 0) | docs/api/engine.md"; - -inline constexpr const char* kDeterminismInvalidMessage = - "determinism_invalid | the configured determinism block is invalid " - "| the block must be a JSON object ({} for all defaults); the " - "nested keys enabled (bool) and math (string) have their own " - "checks below | wrap the determinism block in an object | " - "docs/api/engine.md"; - -inline constexpr const char* kDeterminismEnabledInvalidMessage = - "determinism_enabled_invalid | the configured determinism.enabled " - "is invalid | the value must be a JSON boolean (default true — " - "deterministic by default, S-7) | set determinism.enabled to true " - "or false | docs/api/engine.md"; - -inline constexpr const char* kDeterminismMathInvalidMessage = - "determinism_math_invalid | the configured determinism.math is " - "invalid | the value must be the string \"fixed_point_16_16\" " - "(default) or \"float_pinned_32\" (the ADR 0002 backend ids) | " - "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) @@ -152,200 +100,8 @@ inline constexpr const char* kRecordAbortedMessage = "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). -inline constexpr std::uint64_t kMaxJsonSeed = 1ull << 53; - -// True when `value` is a JSON number holding an exact unsigned integer -// in [lo, hi] (hi must be <= 2^53, where doubles are exact — ADR 0003 -// number policy); stores the value in `out` on success. The double -// here is the JSON number policy's storage type (ADR 0003: numbers -// are parsed to double and must round-trip exactly), not simulation -// math — see the exception markers. -bool parseIntInRange(const JsonValue& value, std::uint32_t lo, - std::uint32_t hi, std::uint32_t* out) noexcept { - if (!value.isNumber()) return false; - const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy: doubles store JSON numbers exactly only to 2^53 (ADR 0003); this is config parsing, not sim math - if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math - d != std::floor(d)) { - return false; - } - const std::uint64_t u = static_cast(d); - if (u < lo) return false; - *out = static_cast(u); - return true; -} - -// The 64-bit twin for the seed key: an exact unsigned integer in -// [lo, hi] (hi <= 2^53 — the ADR 0003 JSON bound, kMaxJsonSeed). -// Same policy as parseIntInRange: config parsing, not sim math. -bool parseUint64InRange(const JsonValue& value, std::uint64_t lo, - std::uint64_t hi, std::uint64_t* out) noexcept { - if (!value.isNumber()) return false; - const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math - if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math - d != std::floor(d)) { - return false; - } - const std::uint64_t u = static_cast(d); - if (u < lo) return false; - *out = u; - return true; -} - -// A stable machine-searchable name for a JsonValue's kind (the -// `value_kind` field of the rejection warns; LOG-001). -const char* jsonKindName(const JsonValue& value) noexcept { - switch (value.kind()) { - case JsonKind::Null: return "null"; - case JsonKind::Bool: return "bool"; - case JsonKind::Number: return "number"; - case JsonKind::String: return "string"; - case JsonKind::Array: return "array"; - case JsonKind::Object: return "object"; - } - return "unknown"; -} - } // namespace -// --------------------------------------------------------------------------- -// parseEngineConfig (the provisional M1-HEAD-01 config surface — the -// keys, defaults, and rejection table are in engine.h) -// --------------------------------------------------------------------------- - -Result parseEngineConfig(const JsonValue& doc) noexcept { - if (!doc.isObject()) { - LAIGE_LOG_WARN(kConfigSubsystem, "not_an_object", kNotAnObjectMessage); - return ErrorCode::InvalidArgument; - } - EngineConfig config; - for (const auto& [key, value] : doc.asObject()) { - if (key == "tick_rate_hz") { - if (!parseIntInRange(value, kMinTickRateHz, kMaxTickRateHz, - &config.tickRateHz)) { - if (value.isNumber()) { - LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", - kTickRateInvalidMessage, - laige::log::field("key", key), - laige::log::field("value", value.asNumber())); - } else { - LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", - kTickRateInvalidMessage, - laige::log::field("key", key), - laige::log::field("value_kind", jsonKindName(value))); - } - return ErrorCode::InvalidArgument; - } - } else if (key == "entity_budget") { - if (!parseIntInRange(value, 0, Entity::kMaxEntities, - &config.entityCapacity)) { - if (value.isNumber()) { - LAIGE_LOG_WARN(kConfigSubsystem, "entity_budget_invalid", - kEntityBudgetInvalidMessage, - laige::log::field("key", key), - laige::log::field("value", value.asNumber())); - } else { - LAIGE_LOG_WARN(kConfigSubsystem, "entity_budget_invalid", - kEntityBudgetInvalidMessage, - laige::log::field("key", key), - laige::log::field("value_kind", jsonKindName(value))); - } - return ErrorCode::InvalidArgument; - } - } else if (key == "churn_per_frame_budget") { - const std::uint32_t kMaxUint32 = - std::numeric_limits::max(); - if (!parseIntInRange(value, 0, kMaxUint32, &config.churnPerFrameBudget)) { - if (value.isNumber()) { - LAIGE_LOG_WARN(kConfigSubsystem, "churn_budget_invalid", - kChurnBudgetInvalidMessage, - laige::log::field("key", key), - laige::log::field("value", value.asNumber())); - } else { - LAIGE_LOG_WARN(kConfigSubsystem, "churn_budget_invalid", - kChurnBudgetInvalidMessage, - laige::log::field("key", key), - laige::log::field("value_kind", jsonKindName(value))); - } - return ErrorCode::InvalidArgument; - } - } else if (key == "seed") { - std::uint64_t seed = 0; - if (!parseUint64InRange(value, 0, kMaxJsonSeed, &seed)) { - if (value.isNumber()) { - LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", - kSeedInvalidMessage, - laige::log::field("key", key), - laige::log::field("value", value.asNumber())); - } else { - LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", - kSeedInvalidMessage, - laige::log::field("key", key), - laige::log::field("value_kind", jsonKindName(value))); - } - return ErrorCode::InvalidArgument; - } - config.seed = seed; - } else if (key == "determinism") { - if (!value.isObject()) { - LAIGE_LOG_WARN(kConfigSubsystem, "determinism_invalid", - kDeterminismInvalidMessage, - laige::log::field("key", key), - laige::log::field("value_kind", jsonKindName(value))); - return ErrorCode::InvalidArgument; - } - for (const auto& [dkey, dvalue] : value.asObject()) { - if (dkey == "enabled") { - if (!dvalue.isBool()) { - LAIGE_LOG_WARN(kConfigSubsystem, - "determinism_enabled_invalid", - kDeterminismEnabledInvalidMessage, - laige::log::field("key", dkey), - laige::log::field("value_kind", - jsonKindName(dvalue))); - return ErrorCode::InvalidArgument; - } - config.determinism.enabled = dvalue.asBool(); - } else if (dkey == "math") { - if (!dvalue.isString()) { - LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", - kDeterminismMathInvalidMessage, - laige::log::field("key", dkey), - laige::log::field("value_kind", - jsonKindName(dvalue))); - return ErrorCode::InvalidArgument; - } - const std::string_view m = dvalue.asString(); - if (m == "fixed_point_16_16") { - config.determinism.math = SimMathBackend::FixedPoint16_16; - } else if (m == "float_pinned_32") { - config.determinism.math = SimMathBackend::FloatPinned32; - } else { - LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", - kDeterminismMathInvalidMessage, - laige::log::field("key", dkey), - laige::log::field("value", std::string{m})); - return ErrorCode::InvalidArgument; - } - } else { - // Unknown nested key: WARN (forward-compat) and ignore — the - // M1-CFG-01 rule, applied inside the determinism block too. - LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", kUnknownKeyMessage, - laige::log::field("key", dkey)); - } - } - } else { - // Unknown key: WARN (forward-compat) and ignore — the M1-CFG-01 - // rule, applied to the provisional surface (never silent). - LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", kUnknownKeyMessage, - laige::log::field("key", key)); - } - } - return config; -} - // --------------------------------------------------------------------------- // Engine setup // --------------------------------------------------------------------------- @@ -358,8 +114,11 @@ Result Engine::create(const EngineConfig& config) noexcept { // the single warn here, subsystem "config", is the config-surface // rejection). if (config.tickRateHz < kMinTickRateHz || config.tickRateHz > kMaxTickRateHz) { + // The config surface's rejection (M1-CFG-01: the message text + // lives in config.h with the rest of the config/_invalid + // messages — one text for one rejection, LOG-002). LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", - kTickRateInvalidMessage, + kConfigTickRateInvalidMessage, laige::log::field("tick_rate_hz", config.tickRateHz)); return ErrorCode::InvalidArgument; } diff --git a/src/laige-sim/include/laige/sim/config.h b/src/laige-sim/include/laige/sim/config.h new file mode 100644 index 0000000..74496e0 --- /dev/null +++ b/src/laige-sim/include/laige/sim/config.h @@ -0,0 +1,632 @@ +// laige-sim declarative game config (M1-CFG-01). +// +// FR-1.5 (declarative game config in JSON: tick rate, budgets, camera +// defaults, asset roots; runtime config overrides; hot-reload of +// non-simulation config in debug); ARCH-007 (persistent data is +// versioned; readers reject unsupported versions explicitly); ADR 0003 +// (the bounded JSON parser + the JSON-number policy); ADR 0002 (the +// determinism block: the mode flag + the SimMath backend ids, consumed +// by M1-DET-01); PRD §7.1, §10.2 (single-threaded sim), §10.3. +// +// This header is the FINAL config surface. The M1-HEAD-01 provisional +// `parseEngineConfig` surface (declared in engine.h) is folded into it: +// the provisional keys carried over unchanged, and the schema gains the +// REQUIRED "version" key plus the budgets / camera / asset_roots +// blocks. `EngineConfig` itself moved from engine.h to this header +// (engine.h now includes it); the five original members keep their +// order, so existing aggregate initializers still compile. +// +// This header carries: +// +// EngineConfig The typed configuration the engine consumes +// (sim-affecting fields + the declared +// presentation blocks below). +// BudgetsConfig The declared budget block (the per-system +// default time budget + the draw/particle +// budgets, declared before their consumers +// exist). +// CameraConfig The camera defaults (stored now, consumed +// in M2). +// kSupportedConfigVersion +// The only config-document version this build +// accepts (ARCH-007). +// parseEngineConfig The versioned JSON-document -> EngineConfig +// loader (the rejection table below). +// loadGameConfig The file -> EngineConfig loader (bounded +// read + parse + version + schema validation). +// EngineConfigOverride + applyConfigOverride +// The programmatic override-of-a-subset merge +// API (FR-1.5 "runtime config overrides"). +// ConfigHotReloader The debug-build-only file-watch hot reload +// of non-simulation keys (FR-1.5). +// +// --------------------------------------------------------------------------- +// The version 1 schema (the full key table: docs/api/config.md) +// --------------------------------------------------------------------------- +// +// Top level: a JSON OBJECT. The "version" key is REQUIRED and must be +// the exact integer 1 (kSupportedConfigVersion); it is checked before +// any other key. Every other key is optional (missing keys take the +// defaults below); an unknown key is WARNED (one config/unknown_key +// per key, forward-compat) and ignored. First failure wins: the +// loader stops at the first rejected key in document order and +// returns the error (no config is produced on failure, CORE-008). +// +// key type / range default +// "version" exact integer == 1 REQUIRED +// "tick_rate_hz" exact integer, 20..120 kDefaultTickRateHz (60) +// "entity_budget" exact integer, 0..65536 0 (empty scene) +// "churn_per_frame_budget" exact integer, >= 0 kDefaultChurnPerFrameBudget (256) +// "seed" exact integer, 0..2^53 kDefaultSimulationSeed (0) +// "determinism" object (below) {} +// "enabled" bool true +// "math" "fixed_point_16_16" | +// "float_pinned_32" "fixed_point_16_16" +// "budgets" object (below) {} +// "system_time_default_ms" number, finite, > 0 kDefaultSystemTimeBudgetMs (1.5) +// "draw_calls_per_frame" exact integer, >= 0 kDefaultDrawCallsPerFrame (30) +// "particles_per_frame" exact integer, >= 0 kDefaultParticlesPerFrame (4096) +// "camera" object (below) {} +// "fov_degrees" number, finite, 0 < v <= 180 kDefaultCameraFovDegrees (45) +// "zoom" number, finite, > 0 kDefaultCameraZoom (1) +// "follow_lerp_per_sec" number, finite, >= 0 kDefaultCameraFollowLerpPerSec (8) +// "asset_roots" array of non-empty strings [] +// +// The budgets and camera blocks are DECLARED values in M1: the scene +// entity budget already has its consumer (World capacity, G-R3); +// system_time_default_ms (the per-system default time budget for the +// M1-PROF-02 budget report), the draw/particle budgets (the M2 +// render/particle consumers), and the camera defaults (the M2 camera +// work) are stored and validated now, consumed later (FR-1.5: values +// stored; the consumers land in M2). +// +// Number policy (ADR 0003): JSON numbers are doubles; the exact- +// integer keys are validated to be integral and in-range, and the +// bounded 2^53 exactness of doubles is the seed's upper bound. +// Non-finite values (a literal beyond double range parses to +/-inf) +// are rejected by every numeric check. +// +// --------------------------------------------------------------------------- +// Versioning (ARCH-007) +// --------------------------------------------------------------------------- +// +// The config document is persistent data and is VERSIONED. This +// build accepts exactly version 1; rejection is explicit, never a +// silent migration: +// +// "version" absent InvalidArgument + warn +// config/version_missing +// "version" not an exact +// non-negative integer InvalidArgument + warn +// config/version_invalid +// "version" != 1 InvalidArgument + warn +// config/version_unsupported +// +// Documents from the M1-HEAD-01 provisional surface (no "version" +// key) are rejected by the version_missing rule; the migration is to +// add "version": 1 (every other key is unchanged — see +// docs/api/config.md, "Migration from the provisional surface"). +// +// The replay identity's configHash (replay.h, ADR 0002) covers ONLY +// the simulation-affecting fields — tick rate, entity budget, churn +// budget, seed, and the determinism block. The declared presentation +// values (the budgets block, the camera block, the asset roots) never +// touch simulation and are deliberately EXCLUDED from the identity: +// a replay recorded under one set of those values replays bit-exact +// under another. The hashed field encoding (its tag word 1 included) +// therefore does not change with this step, and every committed +// baseline/replay log stays valid. +// +// --------------------------------------------------------------------------- +// Loading and overrides (cold path, PERF-002/003) +// --------------------------------------------------------------------------- +// +// loadGameConfig(path) reads the file (bounded: kMaxConfigDocumentBytes +// = 1 MiB, the ADR 0003 bound; an oversized file is MalformedInput, +// not a truncated parse), parses it (laige-core JSON), and runs the +// schema validation above. It allocates (a document-sized buffer, the +// config's string storage) — config loading is a startup/dev-time +// operation, never on the sim tick path. +// +// applyConfigOverride(base, ov) merges a SUBSET override (FR-1.5): +// a std::nullopt field is kept from the base; a set field replaces it +// and is validated in its documented domain (a set field outside the +// domain is InvalidArgument — the same events as the JSON path). +// First set field that fails wins (the struct's declaration order). +// +// ConfigHotReloader (debug builds only; release builds reject with +// config/hot_reload_disabled — the replay/record_disabled pattern) +// watches the config file: a poll re-reads the file and compares the +// bytes to the baseline. A change whose simulation-affecting fields +// are all unchanged applies the non-simulation fields to the caller's +// EngineConfig in place (the file becomes authoritative for them — a +// programmatic override of those fields is replaced) and logs +// config/hot_reload_applied. A change of ANY simulation-affecting +// field is REFUSED with InvalidArgument + the config/hot_reload_rejected +// ERROR naming the first differing key; the config and the baseline +// are left untouched (restart the engine to apply it, FR-1.5). +// +// The simulation-affecting set (restart-required on hot reload): +// version, tick_rate_hz, entity_budget, churn_per_frame_budget, +// seed, determinism.enabled, determinism.math, and +// budgets.system_time_default_ms (it governs the G-R5 budget +// enforcement during ticks). The non-simulation set (hot-reloadable): +// budgets.draw_calls_per_frame, budgets.particles_per_frame, +// camera.fov_degrees, camera.zoom, camera.follow_lerp_per_sec, and +// asset_roots. +// +// --------------------------------------------------------------------------- +// Ownership, threading, performance +// --------------------------------------------------------------------------- +// +// EngineConfig is a plain value (copied into Engine::create; the +// assetRoots vector is its only heap member). The loader functions are +// free functions over values — no hidden state. +// +// ConfigHotReloader is MOVE-ONLY (it owns the baseline bytes and the +// baseline config), single-owner-thread (the game's loop thread — the +// sim is single-threaded, PRD §10.2), and creates no thread +// (CONC-005): the caller drives the poll cadence (typically once per +// frame in a dev loop). A moved-from reloader's poll fails with +// InvalidArgument and no log (the stopped-state precedent). +// +// Performance (the cold paths): loadGameConfig is O(file bytes) once +// at startup. applyConfigOverride is O(1) plus the assetRoots copy +// when that field is set. A hot-reload poll is O(file bytes) per call +// (one bounded read + one byte comparison; a detected change adds one +// parse + one field comparison) — debug-only and opt-in (the +// reloader exists only when constructed, so the disabled cost is +// zero, DBG-004), and it never runs on the sim tick path. +// +// --------------------------------------------------------------------------- +// Error events (LOG-001 stable names; LOG-002 5-field messages; +// docs/api/config.md carries the full table) +// --------------------------------------------------------------------------- +// +// config/version_missing (Warn) config/version_invalid (Warn) +// config/version_unsupported (Warn) config/not_an_object (Warn) +// config/tick_rate_invalid (Warn) config/entity_budget_invalid (Warn) +// config/churn_budget_invalid (Warn) config/seed_invalid (Warn) +// config/determinism_invalid (Warn) config/determinism_enabled_invalid (Warn) +// config/determinism_math_invalid (Warn) +// config/budgets_invalid (Warn) config/system_time_budget_invalid (Warn) +// config/draw_calls_budget_invalid (Warn) +// config/particles_budget_invalid (Warn) +// config/camera_invalid (Warn) config/camera_fov_invalid (Warn) +// config/camera_zoom_invalid (Warn) config/camera_follow_lerp_invalid (Warn) +// config/asset_roots_invalid (Warn) config/unknown_key (Warn) +// config/file_read_failed (Warn) config/hot_reload_rejected (Error) +// config/hot_reload_applied (Info) config/hot_reload_read_failed (Warn) +// config/hot_reload_disabled (Warn) (release builds only) +// +// Every rejection is a `Status`/`Result` error RETURNED to the caller +// (never silent, CORE-008); the warns carry the NFR-13.3 5-field +// message (build-stable text; the dynamic values are structured +// fields, never message text). + +#pragma once + +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/result.h" +#include "laige/sim/determinism.h" // DeterminismConfig, SimMathBackend +#include "laige/sim/entity.h" // Entity::kMaxEntities, kDefaultChurnPerFrameBudget +#include "laige/sim/game_loop.h" // kMinTickRateHz, kDefaultTickRateHz, kMaxTickRateHz + +namespace laige { + +// The full JsonValue definition is laige/json.h; only a const reference +// is used here (CPP-010: no transitive include). +class JsonValue; + +// The only config-document version this build accepts (ARCH-007: +// readers reject or migrate unsupported data explicitly — the +// rejection table in the header preamble). +inline constexpr std::uint32_t kSupportedConfigVersion = 1; + +// The default master simulation seed (M1-DET-01: the PRNG substreams +// derive from it; it is part of the replay identity, ADR 0002). +inline constexpr std::uint64_t kDefaultSimulationSeed = 0; + +// The per-system default time budget in milliseconds (G-R5). 1.5 ms is +// half of the 3.0 ms sim_tick_avg budget (budgets.json, PRD §8.1): at +// 60 Hz, a scene whose systems all take their defaults still fits the +// tick budget with margin. Declared value in M1 (no scheduler consumer +// yet — the per-system default lands with M1-PROF-02's budget report). +inline constexpr double kDefaultSystemTimeBudgetMs = 1.5; // LAIGE-DETERM-EXCEPTION: G-R8 config-time constant: a declared budget value, never sim state or math + +// The declared per-frame draw-call budget. 30 is the PRD §8.1 budget +// for the worst-case busy scene (50k visible sprites, 3 parallax +// layers, UI — budgets.json `sprites_50k_draw_calls`). Declared value +// in M1; the M2 batcher consumes it. +inline constexpr std::uint32_t kDefaultDrawCallsPerFrame = 30; + +// The declared per-frame particle budget (FR-2.7: budgeted, pooled). +// No measured M1 consumer; 4096 is a power-of-two default sized for a +// 60 fps pool (the M2 particle system validates it against the +// platform). +inline constexpr std::uint32_t kDefaultParticlesPerFrame = 4096; + +// The default perspective field of view in degrees (FR-2.4/FR-2.5). +// 45 degrees is the 2.5D default for the perspective projection modes; +// the isometric ortho presets (the PRD primary) use `zoom` instead. +// Declared value in M1; the M2 camera work consumes it. +inline constexpr double kDefaultCameraFovDegrees = 45.0; // LAIGE-DETERM-EXCEPTION: G-R8 config-time constant: a declared default, never sim state or math + +// The default camera zoom (world-to-screen scale factor). +inline constexpr double kDefaultCameraZoom = 1.0; // LAIGE-DETERM-EXCEPTION: G-R8 config-time constant: a declared default, never sim state or math + +// The default smooth-follow rate in 1/s (FR-2.4 "smooth follow"): the +// per-frame follow factor is 1 - exp(-8*dt), about a 125 ms time +// constant at 60 Hz. Declared value in M1; the M2 camera work +// consumes it. +inline constexpr double kDefaultCameraFollowLerpPerSec = 8.0; // LAIGE-DETERM-EXCEPTION: G-R8 config-time constant: a declared default, never sim state or math + +// The declared budget block (M1-CFG-01; FR-1.5 "budgets"). +// +// All three fields are validated at load time; only the scene entity +// budget (a sibling key, World capacity) has an M1 consumer. +struct BudgetsConfig { + // The per-system default time budget in milliseconds (G-R5). + double systemTimeDefaultMs{kDefaultSystemTimeBudgetMs}; // LAIGE-DETERM-EXCEPTION: G-R8 a declared budget value, not sim math + // The declared per-frame draw-call budget (the M2 batcher's budget). + std::uint32_t drawCallsPerFrame{kDefaultDrawCallsPerFrame}; + // The declared per-frame particle budget (the M2 particle system's + // budget). + std::uint32_t particlesPerFrame{kDefaultParticlesPerFrame}; +}; + +// The camera defaults (M1-CFG-01; FR-1.5 "camera defaults"). Stored now, +// consumed in M2 (the camera work) — see the header preamble. +struct CameraConfig { + // The perspective field of view in degrees (0 < fov <= 180). + double fovDegrees{kDefaultCameraFovDegrees}; // LAIGE-DETERM-EXCEPTION: G-R8 a declared default, not sim math + // The world-to-screen zoom factor (> 0). + double zoom{kDefaultCameraZoom}; // LAIGE-DETERM-EXCEPTION: G-R8 a declared default, not sim math + // The smooth-follow rate in 1/s (>= 0; 0 disables smoothing). + double followLerpPerSec{kDefaultCameraFollowLerpPerSec}; // LAIGE-DETERM-EXCEPTION: G-R8 a declared default, not sim math +}; + +// The typed game configuration the engine consumes (M1-CFG-01, the +// final schema; the five M1-HEAD-01 members keep their order — existing +// aggregate initializers compile unchanged). +// +// Simulation-affecting fields (part of the replay identity's +// configHash, replay.h; hot-reload-refused): tickRateHz, +// entityCapacity, churnPerFrameBudget, seed, determinism. +// +// Declared presentation fields (validated, stored, no M1 consumer; +// hot-reloadable in debug): budgets, camera, assetRoots. +struct EngineConfig { + // The simulation tick rate in Hz (kMinTickRateHz..kMaxTickRateHz — + // 20..120; the GameLoop re-validates the same range, game_loop.h). + std::uint32_t tickRateHz{kDefaultTickRateHz}; + // The scene's entity budget in entities (0..Entity::kMaxEntities — + // the G-R3 scene budget; 0 is the empty scene). + std::uint32_t entityCapacity{0}; + // The per-frame component-churn budget (the G-R4 guardrail; 0 + // disables it). + std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}; + // The master simulation seed (the PRNG substreams derive from it, + // M1-DET-01; part of the replay identity, ADR 0002). The programmatic + // surface accepts the full 64 bits (the JSON surface is bounded to + // 2^53 by the ADR 0003 number policy — see the parse table). + std::uint64_t seed{kDefaultSimulationSeed}; + // The determinism block (M1-DET-01; ADR 0002). + DeterminismConfig determinism{}; + // The declared budget block (FR-1.5). + BudgetsConfig budgets{}; + // The camera defaults (FR-1.5; consumed in M2). + CameraConfig camera{}; + // The asset root paths (FR-1.5; the asset pipeline — M2 — resolves + // them). Existence is NOT checked at config load (a root may be + // created later; the pipeline owns the check). + std::vector assetRoots; +}; + +// The NFR-13.3 5-field rejection messages (build-stable; the doc +// anchor is docs/api/config.md — the schema's API document). +// engine.cpp's Engine::create tick-rate re-validation shares +// kConfigTickRateInvalidMessage (one text for one rejection, LOG-002). +inline constexpr const char* kConfigSubsystem = "config"; +inline constexpr const char* kConfigNotAnObjectMessage = + "not_an_object | the config document is not a JSON object | the " + "game config must be a top-level object | wrap the config in a " + "top-level object ({} plus \"version\": 1 for all defaults) | " + "docs/api/config.md"; +inline constexpr const char* kConfigVersionMissingMessage = + "version_missing | the config document has no version key | the " + "document predates the versioned schema (the M1-HEAD-01 provisional " + "surface) or the key was removed | add \"version\": 1 to the config " + "(docs/api/config.md) | docs/api/config.md"; +inline constexpr const char* kConfigVersionInvalidMessage = + "version_invalid | the config version key is not an exact " + "non-negative integer | version must be a JSON number holding an " + "exact integer | set version to 1 | docs/api/config.md"; +inline constexpr const char* kConfigVersionUnsupportedMessage = + "version_unsupported | the config document version is not " + "supported by this build | this build accepts version 1 only " + "(ARCH-007) | rebuild with the engine version matching the " + "document, or downgrade the document to version 1 | " + "docs/api/config.md"; +inline constexpr const char* kConfigTickRateInvalidMessage = + "tick_rate_invalid | the configured tick_rate_hz is invalid | the " + "value must be an exact integer in the 20-120 Hz range | set " + "tick_rate_hz to a value in 20-120 (the default is 60) | " + "docs/api/config.md"; +inline constexpr const char* kConfigEntityBudgetInvalidMessage = + "entity_budget_invalid | the configured entity_budget is invalid | " + "the value must be an exact integer in 0-65536 (the 16-bit entity " + "id space) | set entity_budget to a value in 0-65536 (the scene's " + "declared budget, G-R3) | docs/api/config.md"; +inline constexpr const char* kConfigChurnBudgetInvalidMessage = + "churn_budget_invalid | the configured churn_per_frame_budget is " + "invalid | the value must be a non-negative exact integer | set " + "churn_per_frame_budget to a non-negative integer (the default is " + "256; 0 disables the G-R4 guardrail) | docs/api/config.md"; +inline constexpr const char* kConfigSeedInvalidMessage = + "seed_invalid | the configured seed is invalid | the value must be " + "an exact integer in 0-2^53 (the ADR 0003 JSON bound: doubles are " + "exact to 2^53; the programmatic EngineConfig.seed accepts the " + "full 64 bits) | set seed to an integer in 0-2^53 (the default is " + "0) | docs/api/config.md"; +inline constexpr const char* kConfigDeterminismInvalidMessage = + "determinism_invalid | the configured determinism block is invalid " + "| the block must be a JSON object ({} for all defaults); the " + "nested keys enabled (bool) and math (string) have their own " + "checks | wrap the determinism block in an object | " + "docs/api/config.md"; +inline constexpr const char* kConfigDeterminismEnabledInvalidMessage = + "determinism_enabled_invalid | the configured determinism.enabled " + "is invalid | the value must be a JSON boolean (the default is " + "true — deterministic by default, S-7) | set determinism.enabled to " + "true or false | docs/api/config.md"; +inline constexpr const char* kConfigDeterminismMathInvalidMessage = + "determinism_math_invalid | the configured determinism.math is " + "invalid | the value must be the string \"fixed_point_16_16\" " + "(default) or \"float_pinned_32\" (the ADR 0002 backend ids) | set " + "determinism.math to one of the two backend ids | " + "docs/api/config.md"; +inline constexpr const char* kConfigBudgetsInvalidMessage = + "budgets_invalid | the configured budgets block is invalid | the " + "block must be a JSON object ({} for all defaults); the nested " + "keys have their own checks | wrap the budgets block in an object " + "| docs/api/config.md"; +inline constexpr const char* kConfigSystemTimeBudgetInvalidMessage = + "system_time_budget_invalid | the configured budgets." + "system_time_default_ms is invalid | the value must be a finite " + "number > 0 (milliseconds; the per-system default time budget, " + "G-R5) | set system_time_default_ms to a positive finite value " + "(the default is 1.5) | docs/api/config.md"; +inline constexpr const char* kConfigDrawCallsBudgetInvalidMessage = + "draw_calls_budget_invalid | the configured budgets." + "draw_calls_per_frame is invalid | the value must be a non-" + "negative exact integer (the declared per-frame draw-call budget; " + "the M2 batcher consumes it) | set draw_calls_per_frame to a non-" + "negative integer (the default is 30) | docs/api/config.md"; +inline constexpr const char* kConfigParticlesBudgetInvalidMessage = + "particles_budget_invalid | the configured budgets." + "particles_per_frame is invalid | the value must be a non-" + "negative exact integer (the declared per-frame particle budget; " + "the M2 particle system consumes it) | set particles_per_frame to " + "a non-negative integer (the default is 4096) | " + "docs/api/config.md"; +inline constexpr const char* kConfigCameraInvalidMessage = + "camera_invalid | the configured camera block is invalid | the " + "block must be a JSON object ({} for all defaults); the nested " + "keys have their own checks | wrap the camera block in an object | " + "docs/api/config.md"; +inline constexpr const char* kConfigCameraFovInvalidMessage = + "camera_fov_invalid | the configured camera.fov_degrees is invalid " + "| the value must be a finite number in (0, 180] degrees | set " + "fov_degrees to a finite value in (0, 180] (the default is 45) | " + "docs/api/config.md"; +inline constexpr const char* kConfigCameraZoomInvalidMessage = + "camera_zoom_invalid | the configured camera.zoom is invalid | the " + "value must be a finite number > 0 | set zoom to a finite value > 0 " + "(the default is 1) | docs/api/config.md"; +inline constexpr const char* kConfigCameraFollowLerpInvalidMessage = + "camera_follow_lerp_invalid | the configured camera." + "follow_lerp_per_sec is invalid | the value must be a finite " + "number >= 0 (the smooth-follow rate in 1/s, FR-2.4) | set " + "follow_lerp_per_sec to a finite value >= 0 (the default is 8) | " + "docs/api/config.md"; +inline constexpr const char* kConfigAssetRootsInvalidMessage = + "asset_roots_invalid | the configured asset_roots value is invalid " + "| asset_roots must be a JSON array of non-empty strings | set " + "asset_roots to an array of path strings ([] for none) | " + "docs/api/config.md"; +inline constexpr const char* kConfigUnknownKeyMessage = + "unknown_key | the config key is not part of the version 1 config " + "schema | the key is not (yet) consumed by this build | remove the " + "key, or wait for a later schema version (forward-compat) | " + "docs/api/config.md"; +inline constexpr const char* kConfigFileReadFailedMessage = + "file_read_failed | the config file could not be read | the path " + "does not exist, is not readable, or is over the 1 MiB bound (ADR " + "0003) | check the path, permissions, and file size | " + "docs/api/config.md"; +// The hot-reload messages (debug builds except the _disabled one, which +// is release-only — the guards keep both trees warning-free). +#ifndef NDEBUG +inline constexpr const char* kConfigHotReloadRejectedMessage = + "hot_reload_rejected | the config hot reload was refused: a " + "simulation-affecting key changed | sim-affecting keys (tick rate, " + "budgets, seed, determinism) change the simulation and require a " + "restart (FR-1.5) | restart the engine with the new config; only " + "non-simulation keys are hot-reloadable | docs/api/config.md"; +inline constexpr const char* kConfigHotReloadAppliedMessage = + "hot_reload_applied | the config file changed and its " + "non-simulation keys were reloaded | the file's camera defaults, " + "presentation budgets, and asset roots now apply to the live " + "config | none (informational) | docs/api/config.md"; +inline constexpr const char* kConfigHotReloadReadFailedMessage = + "hot_reload_read_failed | the config file could not be re-read " + "during a hot-reload poll | the file was locked, deleted, or is " + "over the 1 MiB bound (a transient editor state) | the previous " + "config stays in effect; retry on the next poll | " + "docs/api/config.md"; +#endif +#if defined(NDEBUG) +inline constexpr const char* kConfigHotReloadDisabledMessage = + "hot_reload_disabled | config hot reload is a debug-build feature " + "| this binary was built without debug support (NDEBUG defined — " + "release build) | rebuild with CMAKE_BUILD_TYPE=Debug, or restart " + "the engine with the new config | docs/api/config.md"; +#endif + +// Parses a version 1 config document into the typed config (the +// schema + rejection table in the header preamble; the full key table +// in docs/api/config.md). +// +// doc not an object -> InvalidArgument (config/not_an_object) +// "version" missing -> InvalidArgument (config/version_missing) +// "version" not an exact -> InvalidArgument (config/version_invalid) +// non-negative integer +// "version" != 1 -> InvalidArgument (config/version_unsupported) +// any known key outside its -> InvalidArgument (config/_invalid) +// documented domain +// unknown key (any level) -> WARN (config/unknown_key) and ignore +// +// First failure wins (document order); a failed parse produces no +// EngineConfig (CORE-008). Cold path: one O(document) walk. +[[nodiscard]] +Result parseEngineConfig(const JsonValue& doc) noexcept; + +// Reads the config file at `path`, parses it, and validates it against +// the version 1 schema. The read is bounded (kMaxConfigDocumentBytes = +// 1 MiB; an oversized file is MalformedInput, not a truncated parse). +// +// file missing/unreadable -> IoError (config/file_read_failed) +// file over the bound -> MalformedInput (config/file_read_failed) +// JSON grammar violation -> MalformedInput (the ADR 0003 parser) +// schema rejection -> InvalidArgument (the parse table) +// +// Cold path (startup/dev-time): O(file bytes) + the parse. +[[nodiscard]] +Result loadGameConfig(std::string_view path) noexcept; + +// A programmatic override of a SUBSET of an EngineConfig (FR-1.5 +// "runtime config overrides"). A std::nullopt field is kept from the +// base config; a set field replaces it and is validated in its +// documented domain (a set field outside the domain is InvalidArgument +// — the same config/_invalid events as the JSON path). First set +// field that fails wins (the declaration order below). +// +// Cold path: O(1) plus the assetRoots copy when that field is set. +struct EngineConfigOverride { + // The simulation-affecting fields (the same domains as the JSON + // schema): 20..120 Hz; 0..65536 entities; >= 0; full 64-bit seed + // (no 2^53 JSON bound on the programmatic surface); the two backend + // enums. + std::optional tickRateHz; + std::optional entityCapacity; + std::optional churnPerFrameBudget; + std::optional seed; + std::optional determinismEnabled; + std::optional determinismMath; + // The declared presentation fields: finite > 0 ms; >= 0; finite + // (0, 180] degrees; finite > 0; finite >= 0; non-empty strings. + std::optional systemTimeDefaultMs; // LAIGE-DETERM-EXCEPTION: G-R8 config-time optional: a declared value, never sim math + std::optional drawCallsPerFrame; + std::optional particlesPerFrame; + std::optional fovDegrees; // LAIGE-DETERM-EXCEPTION: G-R8 config-time optional: a declared value, never sim math + std::optional zoom; // LAIGE-DETERM-EXCEPTION: G-R8 config-time optional: a declared value, never sim math + std::optional followLerpPerSec; // LAIGE-DETERM-EXCEPTION: G-R8 config-time optional: a declared value, never sim math + std::optional> assetRoots; +}; + +// Merges `override` into a copy of `base` (value semantics: the base +// is untouched) and validates every set field (the header preamble, +// Loading and overrides). Returns the merged config, or the first +// rejected set field's error (no config on failure). +[[nodiscard]] +Result applyConfigOverride( + const EngineConfig& base, const EngineConfigOverride& override) noexcept; + +// The debug-build-only config hot reloader (FR-1.5; the contract in +// the header preamble, Loading and overrides). +// +// create(path) binds to the file and takes the baseline: the current +// bytes + a full validated parse. Read/parse/schema +// failure -> the error, no reloader (CORE-008). +// poll(config) re-reads the file and compares the bytes to the +// baseline: +// unchanged -> ok (no event) +// changed, sim-affecting +// fields all equal the +// baseline -> the non-sim fields +// (camera.*, the +// draw/particle +// budgets, +// asset_roots) are +// applied to `config` +// IN PLACE, the +// baseline advances, +// config/hot_reload_applied (Info) +// names the changed +// keys +// changed, any sim-affecting +// field differs -> InvalidArgument + +// config/hot_reload_rejected +// (Error) naming the +// first differing key; +// `config` and the +// baseline are +// UNCHANGED (restart +// to apply) +// file no longer reads -> IoError + +// config/hot_reload_read_failed +// (Warn); `config` +// unchanged (retry +// on the next poll) +// file no longer validates -> the loader's error; +// `config` unchanged +// Release builds (NDEBUG): create and poll both reject with +// InvalidArgument + config/hot_reload_disabled (the +// replay/record_disabled pattern — hot reload is dev tooling). +// +// Move-only, single-owner-thread, no internal thread (CONC-005); the +// caller drives the poll cadence (typically once per frame in a dev +// loop — never on the sim tick path). A moved-from reloader's poll +// fails with InvalidArgument and no log (the stopped-state +// precedent). Poll cost: one bounded file read + one byte comparison +// per call (O(file bytes)); a detected change adds one parse + one +// field comparison (debug-only, opt-in — the disabled cost is zero, +// DBG-004). +class ConfigHotReloader { + public: + // Binds to the config file at `path` and takes the baseline. Cold + // path: one bounded file read + one parse + one schema validation. + [[nodiscard]] static Result create( + std::string_view path) noexcept; + + // Polls the file once (see the class contract above). `config` is + // updated in place only on a successful non-sim reload. + [[nodiscard]] Status poll(EngineConfig& config) noexcept; + + ConfigHotReloader(ConfigHotReloader&&) noexcept; + ConfigHotReloader& operator=(ConfigHotReloader&&) noexcept; + ConfigHotReloader(const ConfigHotReloader&) = delete; + ConfigHotReloader& operator=(const ConfigHotReloader&) = delete; + + private: + ConfigHotReloader() = default; + // The watched path (the poll re-reads it; the file is not kept open — + // an editor may rewrite it between polls). + std::string path_{}; + // The baseline document bytes (the poll's compare target). + std::string baselineBytes_{}; + // The baseline's validated config (the sim-field compare target). + EngineConfig baselineConfig_{}; + // True after the move-out (the stopped-state precedent: poll fails + // without logging). + bool movedFrom_ = false; +}; + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/engine.h b/src/laige-sim/include/laige/sim/engine.h index 0b96921..e1cdd54 100644 --- a/src/laige-sim/include/laige/sim/engine.h +++ b/src/laige-sim/include/laige/sim/engine.h @@ -8,12 +8,6 @@ // (simulation is single-threaded); CONC-006 (ordered, testable, // idempotent shutdown). This header ships the headless engine run: // -// EngineConfig The typed configuration the engine consumes -// (tick rate, scene entity budget, the G-R4 -// per-frame churn budget). -// parseEngineConfig The JSON -> EngineConfig loader (the provisional -// M1-HEAD-01 config surface; M1-CFG-01 owns the -// full declarative config schema). // Engine The headless engine object: config -> world -> // systems -> loop. It owns the World, the // PresentationSnapshot, and the GameLoop; it runs @@ -112,44 +106,27 @@ // path), and after a failed run. // // --------------------------------------------------------------------------- -// The config surface (PROVISIONAL — M1-CFG-01 owns the final schema) +// The config surface (M1-CFG-01: the final versioned schema) // --------------------------------------------------------------------------- // -// M1-CFG-01 (unchecked at this step's start) will land the full -// declarative config.json: versioned schema, unknown-key handling, -// budgets, camera defaults, asset roots, and the determinism block's -// final placement (M1-DET-01 lands the seed and determinism keys on -// this PROVISIONAL surface; M1-CFG-01 owns the final schema). Until -// then, parseEngineConfig reads the SUBSET the headless run consumes, -// from an unversioned top-level JSON object: -// -// "tick_rate_hz" integer, 20..120 (default 60) -// "entity_budget" integer, 0..65536 (default 0: no -// entities — every World::create fails -// with BudgetExhausted; set it to the -// scene's declared budget, G-R3) -// "churn_per_frame_budget" integer, >= 0 (default 256; 0 -// disables the G-R4 guardrail) -// "seed" integer, 0..2^53 (default 0 — the -// ADR 0003 JSON bound: exact doubles to -// 2^53; the programmatic -// EngineConfig.seed accepts the full -// 64 bits. M1-DET-01: the master -// simulation seed, part of the replay -// identity) -// "determinism" object (default {}) -// "enabled" bool (default true — -// deterministic by default, S-7) -// "math" "fixed_point_16_16" | "float_pinned_32" -// (default "fixed_point_16_16" — the -// ADR 0002 backend ids) -// -// Missing keys take the defaults; unknown keys are WARNED (one -// config/unknown_key per key, forward-compat) and ignored; a wrong -// type, a non-integer, or an out-of-range value is one rate-limited -// warn (subsystem "config") plus InvalidArgument (FR-12.3, never -// silent). M1-CFG-01 replaces/extends this loader; the keys above are -// expected to carry over into the versioned schema unchanged. +// The declarative game config (FR-1.5) is owned by laige/sim/config.h: +// EngineConfig (the typed configuration the engine consumes — moved +// there by M1-CFG-01 from this header; the five M1-HEAD-01 members +// keep their order), the version 1 JSON schema (the REQUIRED "version" +// key, the tick rate, the scene/churn budgets, the seed, the +// determinism block, the declared budgets/camera/asset_roots blocks), +// parseEngineConfig (the versioned document loader), loadGameConfig +// (the file loader), EngineConfigOverride + applyConfigOverride (the +// programmatic override merge), and ConfigHotReloader (the +// debug-build-only hot reload of non-simulation keys). This header +// includes config.h; the full key table, the versioning rules, the +// rejection table, and the hot-reload contract are in config.h and +// docs/api/config.md. +// +// Engine::create re-validates the typed config's tick rate (the single +// warn there is config/tick_rate_invalid, the config surface's +// rejection — the JSON/file loaders emit the same event for the same +// domain). // // --------------------------------------------------------------------------- // Built-in components and the determinism scope (ARCH-009/010) @@ -291,6 +268,7 @@ #include "laige/errors.h" #include "laige/fpx16_16.h" #include "laige/result.h" +#include "laige/sim/config.h" // EngineConfig, the version 1 schema (M1-CFG-01) #include "laige/sim/determinism.h" // SimMathBackend, DeterminismConfig (M1-DET-01) #include "laige/sim/entity.h" // World, kDefaultChurnPerFrameBudget #include "laige/sim/game_loop.h" // GameLoop, GameLoopStats, tick-rate constants @@ -299,85 +277,6 @@ namespace laige { -class JsonValue; // declared in laige/json.h; only a const reference is used - -// 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. -inline constexpr std::uint64_t kDefaultSimulationSeed = 0; - -// 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(). -struct EngineConfig { - // The simulation tick rate in HERTZ (FR-1.1: 20-120 validated at - // Engine::create; default kDefaultTickRateHz). - std::uint32_t tickRateHz{kDefaultTickRateHz}; - // 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). - std::uint32_t entityCapacity{0}; - // The G-R4 per-frame component-churn budget (0 disables the - // guardrail; default kDefaultChurnPerFrameBudget). - std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}; - // 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). - std::uint64_t seed{kDefaultSimulationSeed}; - // 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. - DeterminismConfig determinism{}; -}; - -// 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). -// -// document not an object -> InvalidArgument + warn -// (config/not_an_object) -// "tick_rate_hz" not a number, not an exact integer, or outside -// 20..120 -> InvalidArgument + warn -// (config/tick_rate_invalid) -// "entity_budget" not a number, not an exact integer, -// or outside 0..65536 -> InvalidArgument + warn -// (config/entity_budget_invalid) -// "churn_per_frame_budget" not a number, not an exact -// integer, or < 0 -> InvalidArgument + warn -// (config/churn_budget_invalid) -// "seed" not a number, not an exact integer, or -// outside 0..2^53 -> InvalidArgument + warn -// (config/seed_invalid) -// "determinism" not an object -> InvalidArgument + warn -// (config/determinism_invalid) -// "determinism.enabled" not a -> InvalidArgument + warn -// boolean (config/determinism_enabled_invalid) -// "determinism.math" not a -> InvalidArgument + warn -// string, or not one of (config/determinism_math_invalid) -// the two ADR 0002 ids -// unknown key (top-level or -> Warn only (config/unknown_key, -// inside "determinism") forward-compat — M1-CFG-01's -// rule); the key is ignored -// -// Cold path (config load); O(keys), allocates only for the warn -// fields. First failure wins; on failure the config is not returned. -// @budget O(document keys); cold path, warn fields allocate only when a key is rejected. -[[nodiscard]] Result -parseEngineConfig(const JsonValue& doc) noexcept; - namespace detail { // The engine's presentation snapshot, type-erased (M1-DET-01): one diff --git a/src/laige-sim/include/laige/sim/replay.h b/src/laige-sim/include/laige/sim/replay.h index 8f7fa58..97a4dee 100644 --- a/src/laige-sim/include/laige/sim/replay.h +++ b/src/laige-sim/include/laige/sim/replay.h @@ -32,7 +32,9 @@ // loadReplay The file reader (bounded read + parseReplay). // componentSchemaHash // The component registry's deterministic hash. -// configHash The provisional EngineConfig's deterministic hash. +// configHash The EngineConfig's deterministic hash (the +// simulation-affecting fields only — M1-CFG-01's +// declared presentation fields are excluded). // makeReplayIdentity // The full identity from (world, config). // ReplayIdentityDiff @@ -118,11 +120,11 @@ // 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) +// configHash FNV-1a 64 over the EngineConfig's canonical +// field encoding (tag word 1 — the +// simulation-affecting fields only; M1-CFG-01's +// declared presentation fields are excluded, so +// the encoding is unchanged by the final schema) // // Both hashes are pure-integer FNV-1a over u64 word streams, // big-endian byte order per word (the house hash convention — @@ -295,7 +297,8 @@ struct ReplayIdentity { // 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. + // FNV-1a 64 over the EngineConfig's simulation-affecting field + // encoding (M1-CFG-01's presentation fields are excluded). std::uint64_t configHash{}; }; @@ -460,11 +463,14 @@ loadReplay(std::string_view path, // @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, +// FNV-1a 64 over the EngineConfig's canonical field encoding (tag +// word 1 — the simulation-affecting fields: 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. +// determinism.math). M1-CFG-01's declared presentation fields +// (budgets, camera, asset_roots) are deliberately excluded — they +// never touch simulation — so the encoding is unchanged by the final +// schema and every committed baseline stays valid. O(1), no +// allocation. // @budget O(1); no allocation. [[nodiscard]] std::uint64_t configHash(const EngineConfig& config) noexcept; diff --git a/src/laige-sim/replay.cpp b/src/laige-sim/replay.cpp index 5d8574e..338b301 100644 --- a/src/laige-sim/replay.cpp +++ b/src/laige-sim/replay.cpp @@ -156,9 +156,13 @@ bool atomicReplace(const std::string& from, const std::string& to) { #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). +// The tag word of the EngineConfig field encoding (the tag identifies +// this encoding in the hash word stream). M1-CFG-01 grew the schema +// (version, budgets, camera, asset_roots), but the encoding covers +// ONLY the simulation-affecting fields — the declared presentation +// values are presentation state, not replay identity (ARCH-009) — so +// the encoding is UNCHANGED (tag 1) and every committed baseline +// stays valid. inline constexpr std::uint64_t kConfigHashTag = 1; } // namespace @@ -552,9 +556,12 @@ std::uint64_t componentSchemaHash(const World& world) noexcept { } 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). + // The EngineConfig field encoding (tag word 1): the simulation- + // affecting fields ONLY. M1-CFG-01's declared presentation fields + // (budgets, camera, asset_roots) are deliberately excluded — they + // never touch simulation (ARCH-009), so a replay recorded under one + // set of them replays bit-exact under another; the encoding (tag 1) + // therefore does not change with the final schema. std::uint64_t words[7]; words[0] = kConfigHashTag; words[1] = config.tickRateHz; diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 715e766..2cd642b 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,5 +1,5 @@ # laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03 -# + M1-LOOP-01/02 + M1-HEAD-01 + M1-DET-01/02/03): +# + M1-LOOP-01/02 + M1-HEAD-01 + M1-CFG-01 + M1-DET-01/02/03): # entity handle + World entity storage, component type registry, # archetype SoA storage, query API + iteration legality, # deterministic iteration order, the ECS guardrails (G-R3/G-R4), the @@ -14,8 +14,11 @@ # 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), and -# the replay recording (M1-DET-02: the versioned log format's +# idempotent CONC-006 shutdown), and the declarative game config +# (M1-CFG-01: the version 1 schema loader's rejection table, the +# file loader's bounded read, the programmatic override merge, and +# the debug-only hot reload of non-simulation keys), 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), and the @@ -39,20 +42,20 @@ # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, -# `determinism_mode`, `replay_record`, `replay_replay`, and -# `replay_diff` entries +# `game_config`, `determinism_mode`, `replay_record`, `replay_replay`, +# and `replay_diff` entries # are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, # M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, -# M1-LOOP-02, M1-HEAD-01, M1-DET-01, M1-DET-02, M1-DET-03, and -# M1-DET-05 Verify commands (`ctest -R entity`, `ctest -R +# M1-LOOP-02, M1-HEAD-01, M1-CFG-01, M1-DET-01, M1-DET-02, M1-DET-03, +# and M1-DET-05 Verify commands (`ctest -R entity`, `ctest -R # component_registry`, `ctest -R archetype`, `ctest -R query`, # `ctest -R iter_order`, `ctest -R ecs_guardrails`, `ctest -R # ecs_stress`, `ctest -R system_registry`, `ctest -R scheduler`, # `ctest -R system_timing`, `ctest -R game_loop`, `ctest -R -# presentation`, `ctest -R engine`, `ctest -R determinism_mode`, -# `ctest -R replay_record`, `ctest -R replay_replay`, and -# `ctest -R replay_diff`), selecting exactly the suites below from -# the shared executable. +# presentation`, `ctest -R engine`, `ctest -R game_config`, +# `ctest -R determinism_mode`, `ctest -R replay_record`, +# `ctest -R replay_replay`, and `ctest -R replay_diff`), 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 @@ -64,6 +67,7 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp game_loop_tests.cpp presentation_tests.cpp engine_tests.cpp + game_config_tests.cpp determinism_tests.cpp replay_record_tests.cpp replay_replay_tests.cpp @@ -217,7 +221,16 @@ add_test(NAME presentation # the ctest output). add_test(NAME engine COMMAND laige-sim_tests - --gtest_filter=EngineCreate.*:EngineConfigParse.*:EngineRun.*:EngineShutdown.*) + --gtest_filter=EngineCreate.*:EngineRun.*:EngineShutdown.*) + +# M1-CFG-01: declarative game config (FR-1.5, ARCH-007). The step's +# Verify command is `ctest -R config` (this `game_config` entry plus +# laige-core's `config_json` parser suite); this entry selects exactly +# the GameConfigParse / GameConfigFile / ConfigOverride / +# ConfigHotReload suites from the shared laige-sim_tests executable. +add_test(NAME game_config + COMMAND laige-sim_tests + --gtest_filter=GameConfigParse.*:GameConfigFile.*:ConfigOverride.*:ConfigHotReload.*) # M1-DET-01: deterministic mode + SimMath rules (ARCH-010, G-R8, # ADR 0002). The step's Verify command is `ctest -R determinism_mode`; @@ -324,7 +337,8 @@ 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 + system_timing game_loop presentation engine game_config + determinism_mode replay_record replay_replay replay_diff PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/determinism_tests.cpp b/tests/laige-sim/determinism_tests.cpp index c83190d..cf4e4e4 100644 --- a/tests/laige-sim/determinism_tests.cpp +++ b/tests/laige-sim/determinism_tests.cpp @@ -568,7 +568,7 @@ laige::JsonValue parseDoc(const char* text) { TEST(DeterminismConfigParse, DefaultsAreDeterministicFixedPoint) { const laige::EngineConfig config = - laige::parseEngineConfig(parseDoc("{}")).value(); + laige::parseEngineConfig(parseDoc(R"({"version":1})")).value(); EXPECT_EQ(config.seed, 0u); EXPECT_TRUE(config.determinism.enabled); EXPECT_EQ(config.determinism.math, laige::SimMathBackend::FixedPoint16_16); @@ -576,7 +576,7 @@ TEST(DeterminismConfigParse, DefaultsAreDeterministicFixedPoint) { TEST(DeterminismConfigParse, SeedAndDeterminismValid) { const laige::EngineConfig config = laige::parseEngineConfig(parseDoc( - R"({"seed":123,"determinism":{"enabled":false,"math":"float_pinned_32"}})")) + R"({"version":1,"seed":123,"determinism":{"enabled":false,"math":"float_pinned_32"}})")) .value(); EXPECT_EQ(config.seed, 123u); EXPECT_FALSE(config.determinism.enabled); @@ -586,7 +586,7 @@ TEST(DeterminismConfigParse, SeedAndDeterminismValid) { TEST(DeterminismConfigParse, SeedAtTheJsonBoundIsExact) { // 2^53 is the largest exact-integer double (ADR 0003): accepted. const laige::EngineConfig config = - laige::parseEngineConfig(parseDoc(R"({"seed":9007199254740992})")) + laige::parseEngineConfig(parseDoc(R"({"version":1,"seed":9007199254740992})")) .value(); EXPECT_EQ(config.seed, 9007199254740992ull); } @@ -599,10 +599,10 @@ TEST(DeterminismConfigParse, SeedRejections) { // 1.5 (non-integer), -1 (negative), and a non-number: all // rejected, one warn each, first-failure-wins. const char* badDocs[] = { - R"({"seed":9007199254740994})", // 2^53 + 2 - R"({"seed":1.5})", - R"({"seed":-1})", - R"({"seed":"x"})", + R"({"version":1,"seed":9007199254740994})", // 2^53 + 2 + R"({"version":1,"seed":1.5})", + R"({"version":1,"seed":-1})", + R"({"version":1,"seed":"x"})", }; for (const char* doc : badDocs) { const laige::Result r = @@ -617,10 +617,10 @@ TEST(DeterminismConfigParse, SeedRejections) { TEST(DeterminismConfigParse, DeterminismRejections) { MemorySink* sink = installCaptureSink(); const char* badDocs[] = { - R"({"determinism":true})", // not an object - R"({"determinism":{"enabled":"yes"}})", // not a bool - R"({"determinism":{"math":"fpx16"}})", // unknown backend id - R"({"determinism":{"math":3}})", // not a string + R"({"version":1,"determinism":true})", // not an object + R"({"version":1,"determinism":{"enabled":"yes"}})", // not a bool + R"({"version":1,"determinism":{"math":"fpx16"}})", // unknown backend id + R"({"version":1,"determinism":{"math":3}})", // not a string }; for (const char* doc : badDocs) { const laige::Result r = @@ -640,7 +640,7 @@ TEST(DeterminismConfigParse, UnknownNestedKeyIsForwardCompat) { MemorySink* sink = installCaptureSink(); const laige::Result r = laige::parseEngineConfig( - parseDoc(R"({"determinism":{"unknown_future_key":1}})")); + parseDoc(R"({"version":1,"determinism":{"unknown_future_key":1}})")); ASSERT_TRUE(r.ok()); EXPECT_EQ(countEvents(*sink, "unknown_key"), 1u); restoreLogger(); @@ -652,7 +652,7 @@ TEST(DeterminismConfigParse, FirstFailureWinsAcrossBlocks) { MemorySink* sink = installCaptureSink(); const laige::Result r = laige::parseEngineConfig( - parseDoc(R"({"seed":-1,"determinism":{"math":"nope"}})")); + parseDoc(R"({"version":1,"seed":-1,"determinism":{"math":"nope"}})")); ASSERT_TRUE(r.isError()); EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); EXPECT_EQ(countEvents(*sink, "seed_invalid"), 1u); diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index 6e8b421..ca5d809 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -9,9 +9,9 @@ // - run_headless completes the requested ticks exactly under a // healthy cadence (frame budget 1: one tick per frame, no drops) // and reports the loop accounting (the profiler feed) -// - the provisional config surface: defaults, the rejection table -// (wrong type, non-integer, out of range), first-failure-wins, -// unknown-key forward-compat warn (M1-CFG-01's rule) +// - the config surface (M1-CFG-01): the versioned schema loader, the +// rejection table, and the hot reloader live in +// game_config_tests.cpp (the CTest entry `game_config`) // - a stopped engine (post-run / moved-from) fails without logging // (the stopped-state precedent) // - no GPU/window symbols: guaranteed by the include-graph lint @@ -36,7 +36,6 @@ #include "gtest/gtest.h" #include "laige/errors.h" -#include "laige/json.h" #include "laige/logging.h" #include "laige/result.h" #include "laige/sim/engine.h" @@ -149,16 +148,6 @@ std::size_t countEvents(const MemorySink& sink, std::string_view event) { return n; } -// Parses a JSON document from a literal (test cold path). -laige::JsonValue parseDoc(const char* text) { - const laige::Result r = laige::parseJson(text); - if (r.isError()) { - ADD_FAILURE() << "test fixture JSON failed to parse: " << text; - abort(); - } - return r.value(); -} - // Creates an engine, failing the test loudly on a setup error (the // test configs below are all valid — a failure here is a bug in the // test or the engine, never a scenario). @@ -236,115 +225,6 @@ TEST(EngineCreate, EntityBudgetAboveHandleSpaceRejected) { restoreLogger(); } -// --------------------------------------------------------------------------- -// parseEngineConfig (the provisional M1-HEAD-01 config surface) -// --------------------------------------------------------------------------- - -TEST(EngineConfigParse, EmptyDocumentIsAllDefaults) { - const laige::EngineConfig config = - laige::parseEngineConfig(parseDoc("{}")).value(); - EXPECT_EQ(config.tickRateHz, laige::kDefaultTickRateHz); - EXPECT_EQ(config.entityCapacity, 0u); - EXPECT_EQ(config.churnPerFrameBudget, laige::kDefaultChurnPerFrameBudget); -} - -TEST(EngineConfigParse, FullValidDocument) { - const laige::EngineConfig config = - laige::parseEngineConfig(parseDoc( - R"({"tick_rate_hz":120,"entity_budget":65536,"churn_per_frame_budget":0})")) - .value(); - EXPECT_EQ(config.tickRateHz, 120u); - EXPECT_EQ(config.entityCapacity, 65536u); - EXPECT_EQ(config.churnPerFrameBudget, 0u); -} - -TEST(EngineConfigParse, TickRateRejects) { - for (const char* bad : {"19", "121", "60.5", R"("60")", "true"}) { - const laige::Result result = - laige::parseEngineConfig(parseDoc(std::string("{\"tick_rate_hz\":") - .append(bad) - .append("}") - .c_str())); - ASSERT_TRUE(result.isError()); - EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); - } - // The inclusive boundaries are accepted. - for (const char* good : {"20", "120"}) { - const laige::Result result = - laige::parseEngineConfig(parseDoc(std::string("{\"tick_rate_hz\":") - .append(good) - .append("}") - .c_str())); - ASSERT_TRUE(result.ok()); - } -} - -TEST(EngineConfigParse, EntityBudgetRejects) { - for (const char* bad : {"-1", "65537", "65536.5", R"("100")"}) { - const laige::Result result = - laige::parseEngineConfig(parseDoc(std::string("{\"entity_budget\":") - .append(bad) - .append("}") - .c_str())); - ASSERT_TRUE(result.isError()); - EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); - } - const laige::EngineConfig config = - laige::parseEngineConfig(parseDoc(R"({"entity_budget":65536})")) - .value(); - EXPECT_EQ(config.entityCapacity, 65536u); -} - -TEST(EngineConfigParse, ChurnBudgetRejects) { - for (const char* bad : {"-1", R"("10")", "10.5"}) { - const laige::Result result = - laige::parseEngineConfig(parseDoc(std::string( - "{\"churn_per_frame_budget\":") - .append(bad) - .append("}") - .c_str())); - ASSERT_TRUE(result.isError()); - EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); - } - const laige::EngineConfig config = - laige::parseEngineConfig( - parseDoc(R"({"churn_per_frame_budget":4294967295})")) - .value(); - EXPECT_EQ(config.churnPerFrameBudget, 4294967295u); -} - -TEST(EngineConfigParse, NotAnObjectRejected) { - for (const char* doc : {"[]", "42", R"("config")", "null"}) { - const laige::Result result = - laige::parseEngineConfig(parseDoc(doc)); - ASSERT_TRUE(result.isError()); - EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); - } -} - -TEST(EngineConfigParse, UnknownKeyWarnsAndIsIgnored) { - MemorySink* sink = installCaptureSink(); - const laige::Result result = - laige::parseEngineConfig( - parseDoc(R"({"tick_rate_hz":60,"camera":{"zoom":1}})")); - ASSERT_TRUE(result.ok()); - EXPECT_EQ(result.value().tickRateHz, 60u); - EXPECT_EQ(countEvents(*sink, "unknown_key"), 1u); - restoreLogger(); -} - -TEST(EngineConfigParse, FirstFailureWins) { - MemorySink* sink = installCaptureSink(); - const laige::Result result = - laige::parseEngineConfig( - parseDoc(R"({"tick_rate_hz":999,"entity_budget":-5})")); - ASSERT_TRUE(result.isError()); - EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); - EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), 1u); - EXPECT_EQ(countEvents(*sink, "entity_budget_invalid"), 0u); - restoreLogger(); -} - // --------------------------------------------------------------------------- // run_headless (the bounded run, the loop accounting, the stop states) // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/fixtures/headless_smoke.json b/tests/laige-sim/fixtures/headless_smoke.json index 76ed2b0..e3d9b88 100644 --- a/tests/laige-sim/fixtures/headless_smoke.json +++ b/tests/laige-sim/fixtures/headless_smoke.json @@ -1,4 +1,5 @@ { + "version": 1, "tick_rate_hz": 60, "entity_budget": 10000, "churn_per_frame_budget": 256 diff --git a/tests/laige-sim/game_config_tests.cpp b/tests/laige-sim/game_config_tests.cpp new file mode 100644 index 0000000..c4f237e --- /dev/null +++ b/tests/laige-sim/game_config_tests.cpp @@ -0,0 +1,1005 @@ +// laige-sim declarative game config suite (M1-CFG-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md, `ctest -R config`): +// GameConfigParse the version 1 document -> EngineConfig loader +// (parseEngineConfig): the key table, the version +// gate (missing / non-integer / unsupported — +// ARCH-007), every rejection domain, first-failure- +// wins, unknown-key forward-compat (the config +// surface's rule) +// GameConfigFile loadGameConfig: the bounded file read, the +// missing file (IoError), the truncated file +// (MalformedInput), the over-bound file +// (MalformedInput), the versioned file +// ConfigOverride applyConfigOverride: the programmatic override- +// of-a-subset merge (FR-1.5), the per-field +// validation, the first-set-field-wins rule +// ConfigHotReload ConfigHotReloader: the debug-build-only hot +// reload of non-simulation keys (FR-1.5) — a +// camera-default change reloads without a restart; +// a tick-rate change is refused with the +// actionable hot_reload_rejected error; read +// failures keep the previous config; release +// builds reject the feature (hot_reload_disabled) +// +// The underlying JSON parser is M0-CORE-07 (`ctest -R config_json`); +// this suite covers the schema layer above it. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/json.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim/config.h" +#include "laige/sim/entity.h" +#include "laige/sim/game_loop.h" + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "game_config_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "game_config_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, + "game_config_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test fixtures (the MemorySink capture pattern — engine_tests.cpp; +// Info and up, because hot_reload_applied is an Info event) +// --------------------------------------------------------------------------- + +namespace { + +// One log event captured from the facade. +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::Info) 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; +}; + +// Installs a fresh capture sink with rate limiting OFF: the tests +// assert the source-level behavior, not the facade's LOG-004 window. +// (Re-)initializes the facade — required because an Engine shutdown +// retires it (CONC-006/LOG-007). +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& entry : sink.entries) { + if (entry.event == event) ++n; + } + return n; +} + +const MemorySink::Entry* findEvent(const MemorySink& sink, + std::string_view event) { + for (const auto& entry : sink.entries) { + if (entry.event == event) return &entry; + } + return nullptr; +} + +std::string fieldValue(const MemorySink::Entry& entry, + std::string_view name) { + for (const auto& [key, value] : entry.fields) { + if (key == name) return value; + } + return ""; +} + +// Parses a test-fixture document (a parse failure is a test bug). +laige::JsonValue parseDoc(const char* text) { + const laige::Result r = laige::parseJson(text); + if (r.isError()) { + ADD_FAILURE() << "test fixture JSON failed to parse: " << text; + abort(); + } + return r.value(); +} + +std::string tempPath(const char* name) { + return std::string(::testing::TempDir()) + name; +} + +// Test-fixture text surgery: replace the first / every occurrence of a +// literal substring (a parse failure or a missing marker is a test +// bug). +std::string replaceFirst(const std::string& text, const char* old, + const char* rep) { + const std::size_t pos = text.find(old); + if (pos == std::string::npos) { + ADD_FAILURE() << "fixture text not found in: " << text; + abort(); + } + std::string out = text; + out.replace(pos, std::strlen(old), rep); + return out; +} + +std::string replaceAll(const std::string& text, const char* old, + const char* rep) { + const std::size_t oldLen = std::strlen(old); + std::string out; + out.reserve(text.size()); + std::size_t pos = 0; + for (;;) { + const std::size_t found = text.find(old, pos); + if (found == std::string::npos) { + out.append(text, pos, std::string::npos); + break; + } + out.append(text, pos, found - pos); + out.append(rep); + pos = found + oldLen; + } + return out; +} + +// Portable file write (CPP-009 platform boundary; the +// replay_record_tests.cpp precedent): MSVC's CRT deprecates plain +// fopen (C4996, fatal under the engine's /WX policy). +#if defined(_MSC_VER) +std::FILE* openTestFile(const char* path, const char* mode) { + return ::_fsopen(path, mode, _SH_DENYNO); +} +#else +std::FILE* openTestFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +#endif + +bool writeConfigFile(const std::string& path, std::string_view content) { + std::FILE* file = openTestFile(path.c_str(), "wb"); + if (file == nullptr) return false; + const std::size_t written = + std::fwrite(content.data(), 1, content.size(), file); + std::fclose(file); + return written == content.size(); +} + +// The canonical test document: every key at a non-default value. +const char* kFullDocument = R"({ + "version": 1, + "tick_rate_hz": 120, + "entity_budget": 65536, + "churn_per_frame_budget": 0, + "seed": 9007199254740992, + "determinism": { "enabled": false, "math": "float_pinned_32" }, + "budgets": { + "system_time_default_ms": 2.5, + "draw_calls_per_frame": 12, + "particles_per_frame": 8192 + }, + "camera": { "fov_degrees": 60, "zoom": 2.5, "follow_lerp_per_sec": 0 }, + "asset_roots": ["assets/", "shaders/"] +})"; + +} // namespace + +// --------------------------------------------------------------------------- +// GameConfigParse: the version 1 document -> EngineConfig loader +// --------------------------------------------------------------------------- + +TEST(GameConfigParse, MinimalDocumentIsAllDefaults) { + const laige::EngineConfig config = + laige::parseEngineConfig(parseDoc(R"({"version":1})")).value(); + EXPECT_EQ(config.tickRateHz, laige::kDefaultTickRateHz); + EXPECT_EQ(config.entityCapacity, 0u); + EXPECT_EQ(config.churnPerFrameBudget, laige::kDefaultChurnPerFrameBudget); + EXPECT_EQ(config.seed, laige::kDefaultSimulationSeed); + EXPECT_TRUE(config.determinism.enabled); + EXPECT_EQ(config.determinism.math, laige::SimMathBackend::FixedPoint16_16); + EXPECT_DOUBLE_EQ(config.budgets.systemTimeDefaultMs, + laige::kDefaultSystemTimeBudgetMs); + EXPECT_EQ(config.budgets.drawCallsPerFrame, + laige::kDefaultDrawCallsPerFrame); + EXPECT_EQ(config.budgets.particlesPerFrame, + laige::kDefaultParticlesPerFrame); + EXPECT_DOUBLE_EQ(config.camera.fovDegrees, laige::kDefaultCameraFovDegrees); + EXPECT_DOUBLE_EQ(config.camera.zoom, laige::kDefaultCameraZoom); + EXPECT_DOUBLE_EQ(config.camera.followLerpPerSec, + laige::kDefaultCameraFollowLerpPerSec); + EXPECT_TRUE(config.assetRoots.empty()); +} + +TEST(GameConfigParse, FullValidDocument) { + const laige::EngineConfig config = + laige::parseEngineConfig(parseDoc(kFullDocument)).value(); + EXPECT_EQ(config.tickRateHz, 120u); + EXPECT_EQ(config.entityCapacity, 65536u); + EXPECT_EQ(config.churnPerFrameBudget, 0u); + EXPECT_EQ(config.seed, 9007199254740992ull); + EXPECT_FALSE(config.determinism.enabled); + EXPECT_EQ(config.determinism.math, laige::SimMathBackend::FloatPinned32); + EXPECT_DOUBLE_EQ(config.budgets.systemTimeDefaultMs, 2.5); + EXPECT_EQ(config.budgets.drawCallsPerFrame, 12u); + EXPECT_EQ(config.budgets.particlesPerFrame, 8192u); + EXPECT_DOUBLE_EQ(config.camera.fovDegrees, 60.0); + EXPECT_DOUBLE_EQ(config.camera.zoom, 2.5); + EXPECT_DOUBLE_EQ(config.camera.followLerpPerSec, 0.0); + ASSERT_EQ(config.assetRoots.size(), 2u); + EXPECT_EQ(config.assetRoots[0], "assets/"); + EXPECT_EQ(config.assetRoots[1], "shaders/"); +} + +TEST(GameConfigParse, NotAnObjectRejected) { + for (const char* doc : {"[]", "42", R"("config")", "null"}) { + const laige::Result result = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } +} + +// --------------------------------------------------------------------------- +// The version gate (ARCH-007) +// --------------------------------------------------------------------------- + +TEST(GameConfigParse, VersionMissingRejected) { + MemorySink* sink = installCaptureSink(); + const laige::Result result = + laige::parseEngineConfig(parseDoc("{}")); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "version_missing"), 1u); + restoreLogger(); +} + +TEST(GameConfigParse, VersionUnsupportedRejected) { + MemorySink* sink = installCaptureSink(); + const laige::Result result = + laige::parseEngineConfig(parseDoc(R"({"version":2})")); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + const MemorySink::Entry* event = findEvent(*sink, "version_unsupported"); + ASSERT_NE(event, nullptr); + EXPECT_EQ(fieldValue(*event, "version"), "2"); + EXPECT_EQ(fieldValue(*event, "supported"), + std::to_string(laige::kSupportedConfigVersion)); + restoreLogger(); +} + +TEST(GameConfigParse, VersionNotAnExactIntegerRejected) { + for (const char* bad : {R"("1")", "1.5", "-1", "true", "null"}) { + const laige::Result result = + laige::parseEngineConfig( + parseDoc(std::string(R"({"version":)").append(bad).append("}") + .c_str())); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } +} + +TEST(GameConfigParse, VersionIsCheckedBeforeTheKeys) { + // The document has a bad tick rate AND no version: the version gate + // fires first (the oldest rule rejects the whole document). + MemorySink* sink = installCaptureSink(); + const laige::Result result = + laige::parseEngineConfig( + parseDoc(R"({"tick_rate_hz":999,"version":2})")); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "version_unsupported"), 1u); + EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), 0u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// The key domains (the version 1 key table, config.h) +// --------------------------------------------------------------------------- + +namespace { +laige::Result +parseKey(const char* key, const char* value) { + // Plain literals (a raw string here would swallow the closing quote + // of the value: the document is assembled by concatenation). The + // value carries its own JSON quotes when it is a string literal. + const std::string doc = + std::string("{\"version\":1,\"") + key + "\":" + value + "}"; + return laige::parseEngineConfig(parseDoc(doc.c_str())); +} +} // namespace + +TEST(GameConfigParse, TickRateRejects) { + for (const char* bad : {"19", "121", "60.5", R"("60")", "true", + "1e999"}) { // 1e999 parses to +inf (ADR 0003) + const laige::Result result = + parseKey("tick_rate_hz", bad); + ASSERT_TRUE(result.isError()) << bad; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + // The inclusive boundaries are accepted. + for (const char* good : {"20", "120"}) { + ASSERT_TRUE(parseKey("tick_rate_hz", good).ok()) << good; + } +} + +TEST(GameConfigParse, EntityBudgetRejects) { + for (const char* bad : {"-1", "65537", "65536.5", R"("100")"}) { + const laige::Result result = + parseKey("entity_budget", bad); + ASSERT_TRUE(result.isError()) << bad; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ( + parseKey("entity_budget", "65536") + .value() + .entityCapacity, + laige::Entity::kMaxEntities); + EXPECT_EQ(parseKey("entity_budget", "0").value().entityCapacity, 0u); +} + +TEST(GameConfigParse, ChurnBudgetRejects) { + for (const char* bad : {"-1", R"("10")", "10.5"}) { + const laige::Result result = + parseKey("churn_per_frame_budget", bad); + ASSERT_TRUE(result.isError()) << bad; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(parseKey("churn_per_frame_budget", "4294967295") + .value() + .churnPerFrameBudget, + 4294967295u); +} + +TEST(GameConfigParse, SeedRejects) { + for (const char* bad : {"-1", "9007199254740994", "1.5", R"("7")"}) { + // 9007199254740994 = 2^53 + 2: beyond the ADR 0003 exactness bound. + const laige::Result result = + parseKey("seed", bad); + ASSERT_TRUE(result.isError()) << bad; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + // The 2^53 bound is inclusive (doubles are exact to 2^53). + EXPECT_EQ(parseKey("seed", "9007199254740992").value().seed, + 9007199254740992ull); +} + +TEST(GameConfigParse, DeterminismRejects) { + MemorySink* sink = installCaptureSink(); + const char* blocks[] = { + R"({"version":1,"determinism":5})", + R"({"version":1,"determinism":{"enabled":"yes"}})", + R"({"version":1,"determinism":{"math":"float32"}})"}; + for (const char* doc : blocks) { + const laige::Result result = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(result.isError()) << doc; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(countEvents(*sink, "determinism_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "determinism_enabled_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "determinism_math_invalid"), 1u); + restoreLogger(); +} + +TEST(GameConfigParse, BudgetsRejects) { + MemorySink* sink = installCaptureSink(); + const char* docs[] = { + R"({"version":1,"budgets":[]})", + R"({"version":1,"budgets":{"system_time_default_ms":0}})", + R"({"version":1,"budgets":{"system_time_default_ms":-1}})", + R"({"version":1,"budgets":{"system_time_default_ms":1e999}})", + R"({"version":1,"budgets":{"draw_calls_per_frame":-1}})", + R"({"version":1,"budgets":{"draw_calls_per_frame":1.5}})", + R"({"version":1,"budgets":{"particles_per_frame":-1}})"}; + for (const char* doc : docs) { + const laige::Result result = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(result.isError()) << doc; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(countEvents(*sink, "budgets_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "system_time_budget_invalid"), 3u); + EXPECT_EQ(countEvents(*sink, "draw_calls_budget_invalid"), 2u); + EXPECT_EQ(countEvents(*sink, "particles_budget_invalid"), 1u); + restoreLogger(); +} + +TEST(GameConfigParse, CameraRejects) { + MemorySink* sink = installCaptureSink(); + const char* docs[] = { + R"({"version":1,"camera":"iso"})", + R"({"version":1,"camera":{"fov_degrees":0}})", + R"({"version":1,"camera":{"fov_degrees":181}})", + R"({"version":1,"camera":{"fov_degrees":1e999}})", + R"({"version":1,"camera":{"zoom":0}})", + R"({"version":1,"camera":{"follow_lerp_per_sec":-1}})"}; + for (const char* doc : docs) { + const laige::Result result = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(result.isError()) << doc; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(countEvents(*sink, "camera_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "camera_fov_invalid"), 3u); + EXPECT_EQ(countEvents(*sink, "camera_zoom_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "camera_follow_lerp_invalid"), 1u); + // The inclusive fov upper bound is accepted. + EXPECT_TRUE( + laige::parseEngineConfig( + parseDoc(R"({"version":1,"camera":{"fov_degrees":180}})")) + .ok()); + restoreLogger(); +} + +TEST(GameConfigParse, AssetRootsRejects) { + MemorySink* sink = installCaptureSink(); + const char* docs[] = { + R"({"version":1,"asset_roots":"assets/"})", + R"({"version":1,"asset_roots":[7]})", + R"({"version":1,"asset_roots":["assets/",""]})"}; + for (const char* doc : docs) { + const laige::Result result = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(result.isError()) << doc; + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(countEvents(*sink, "asset_roots_invalid"), 3u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// Unknown keys and first-failure-wins +// --------------------------------------------------------------------------- + +TEST(GameConfigParse, UnknownKeysWarnAndAreIgnored) { + MemorySink* sink = installCaptureSink(); + const laige::Result result = + laige::parseEngineConfig(parseDoc( + R"({"version":1,"tick_rate_hz":60,"fog_density":0.1,"determinism":{"enabled":true,"future_flag":true},"budgets":{"legacy":1},"camera":{"shake":2}})")); + ASSERT_TRUE(result.ok()); + EXPECT_EQ(result.value().tickRateHz, 60u); + // One unknown_key per unknown key: one top-level, three nested. + EXPECT_EQ(countEvents(*sink, "unknown_key"), 4u); + restoreLogger(); +} + +TEST(GameConfigParse, FirstFailureWins) { + MemorySink* sink = installCaptureSink(); + const laige::Result result = + laige::parseEngineConfig( + parseDoc(R"({"version":1,"tick_rate_hz":999,"entity_budget":-5})")); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_invalid"), 0u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// GameConfigFile: loadGameConfig (the bounded file read + schema) +// --------------------------------------------------------------------------- + +TEST(GameConfigFile, LoadsTheFullDocument) { + const std::string path = tempPath("game_config_full.json"); + ASSERT_TRUE(writeConfigFile(path, kFullDocument)); + const laige::Result result = + laige::loadGameConfig(path); + ASSERT_TRUE(result.ok()); + EXPECT_EQ(result.value().tickRateHz, 120u); + EXPECT_EQ(result.value().seed, 9007199254740992ull); + EXPECT_DOUBLE_EQ(result.value().camera.zoom, 2.5); + ASSERT_EQ(result.value().assetRoots.size(), 2u); + std::remove(path.c_str()); // clean up (the gtest temp dir) +} + +TEST(GameConfigFile, MissingFileIsAnIoError) { + MemorySink* sink = installCaptureSink(); + const std::string path = tempPath("game_config_missing.json"); + const laige::Result result = + laige::loadGameConfig(path); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::IoError); + const MemorySink::Entry* event = findEvent(*sink, "file_read_failed"); + ASSERT_NE(event, nullptr); + EXPECT_EQ(fieldValue(*event, "path"), path); + restoreLogger(); +} + +TEST(GameConfigFile, TruncatedFileIsMalformedInput) { + const std::string path = tempPath("game_config_truncated.json"); + // A full document minus its closing brace (and trailing newline): + // a truncated file is a grammar violation (MalformedInput), not a + // truncated parse. + const std::string full = kFullDocument; + const std::string truncated = full.substr(0, full.size() - 2); + ASSERT_TRUE(writeConfigFile(path, truncated)); + const laige::Result result = + laige::loadGameConfig(path); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::MalformedInput); + std::remove(path.c_str()); +} + +TEST(GameConfigFile, UnsupportedVersionFileIsRejected) { + const std::string path = tempPath("game_config_bad_version.json"); + ASSERT_TRUE(writeConfigFile(path, R"({"version":2,"tick_rate_hz":60})")); + const laige::Result result = + laige::loadGameConfig(path); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + std::remove(path.c_str()); +} + +TEST(GameConfigFile, OverBoundFileIsMalformedInput) { + const std::string path = tempPath("game_config_overbound.json"); + // 1 MiB + 1 byte: over the ADR 0003 bound (the read stops one byte + // past the bound — MalformedInput, not a truncated parse). + std::string overbound(1u << 20, 'x'); + overbound.push_back('x'); + ASSERT_TRUE(writeConfigFile(path, overbound)); + const laige::Result result = + laige::loadGameConfig(path); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::MalformedInput); + std::remove(path.c_str()); +} + +// --------------------------------------------------------------------------- +// ConfigOverride: applyConfigOverride (the programmatic merge, FR-1.5) +// --------------------------------------------------------------------------- + +namespace { +laige::EngineConfig fullBaseConfig() { + laige::EngineConfig config; + config.tickRateHz = 90; + config.entityCapacity = 2048; + config.churnPerFrameBudget = 128; + config.seed = 42; + config.determinism.enabled = false; + config.determinism.math = laige::SimMathBackend::FloatPinned32; + config.budgets.systemTimeDefaultMs = 2.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared budget, not sim math + config.budgets.drawCallsPerFrame = 16; + config.budgets.particlesPerFrame = 1024; + config.camera.fovDegrees = 50.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + config.camera.zoom = 2.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + config.camera.followLerpPerSec = 4.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + config.assetRoots = {"assets/"}; + return config; +} +} // namespace + +TEST(ConfigOverride, EmptyOverrideKeepsTheBase) { + const laige::EngineConfig base = fullBaseConfig(); + const laige::EngineConfig merged = + laige::applyConfigOverride(base, {}).value(); + EXPECT_EQ(merged.tickRateHz, base.tickRateHz); + EXPECT_EQ(merged.entityCapacity, base.entityCapacity); + EXPECT_EQ(merged.churnPerFrameBudget, base.churnPerFrameBudget); + EXPECT_EQ(merged.seed, base.seed); + EXPECT_EQ(merged.determinism.enabled, base.determinism.enabled); + EXPECT_EQ(merged.determinism.math, base.determinism.math); + EXPECT_DOUBLE_EQ(merged.budgets.systemTimeDefaultMs, + base.budgets.systemTimeDefaultMs); + EXPECT_EQ(merged.budgets.drawCallsPerFrame, base.budgets.drawCallsPerFrame); + EXPECT_EQ(merged.budgets.particlesPerFrame, base.budgets.particlesPerFrame); + EXPECT_DOUBLE_EQ(merged.camera.fovDegrees, base.camera.fovDegrees); + EXPECT_DOUBLE_EQ(merged.camera.zoom, base.camera.zoom); + EXPECT_DOUBLE_EQ(merged.camera.followLerpPerSec, + base.camera.followLerpPerSec); + EXPECT_EQ(merged.assetRoots, base.assetRoots); +} + +TEST(ConfigOverride, SingleFieldOverridesLeaveTheRest) { + const laige::EngineConfig base = fullBaseConfig(); + laige::EngineConfigOverride override; + override.tickRateHz = 120; + const laige::EngineConfig merged = + laige::applyConfigOverride(base, override).value(); + EXPECT_EQ(merged.tickRateHz, 120u); + EXPECT_EQ(merged.entityCapacity, base.entityCapacity); + EXPECT_EQ(merged.seed, base.seed); + EXPECT_EQ(merged.camera.zoom, base.camera.zoom); + // The base is untouched (value semantics). + EXPECT_EQ(base.tickRateHz, 90u); +} + +TEST(ConfigOverride, InvalidFieldsAreRejected) { + MemorySink* sink = installCaptureSink(); + const laige::EngineConfig base = fullBaseConfig(); + laige::EngineConfigOverride badTick; + badTick.tickRateHz = 19; + ASSERT_TRUE(laige::applyConfigOverride(base, badTick).isError()); + EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), 1u); + + laige::EngineConfigOverride badBudget; + badBudget.entityCapacity = laige::Entity::kMaxEntities + 1; + ASSERT_TRUE(laige::applyConfigOverride(base, badBudget).isError()); + EXPECT_EQ(countEvents(*sink, "entity_budget_invalid"), 1u); + + laige::EngineConfigOverride badFov; + badFov.fovDegrees = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + ASSERT_TRUE(laige::applyConfigOverride(base, badFov).isError()); + EXPECT_EQ(countEvents(*sink, "camera_fov_invalid"), 1u); + + laige::EngineConfigOverride badLerp; + badLerp.followLerpPerSec = -1.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + ASSERT_TRUE(laige::applyConfigOverride(base, badLerp).isError()); + EXPECT_EQ(countEvents(*sink, "camera_follow_lerp_invalid"), 1u); + + laige::EngineConfigOverride badBudgetMs; + badBudgetMs.systemTimeDefaultMs = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared budget, not sim math + ASSERT_TRUE(laige::applyConfigOverride(base, badBudgetMs).isError()); + EXPECT_EQ(countEvents(*sink, "system_time_budget_invalid"), 1u); + + laige::EngineConfigOverride badRoots; + badRoots.assetRoots = {"assets/", ""}; + ASSERT_TRUE(laige::applyConfigOverride(base, badRoots).isError()); + EXPECT_EQ(countEvents(*sink, "asset_roots_invalid"), 1u); + restoreLogger(); +} + +TEST(ConfigOverride, FirstSetFieldThatFailsWins) { + MemorySink* sink = installCaptureSink(); + const laige::EngineConfig base = fullBaseConfig(); + laige::EngineConfigOverride override; + override.fovDegrees = 181.0; // LAIGE-DETERM-EXCEPTION: G-R8 test value: a declared camera default, not sim math + override.tickRateHz = 19; + // The struct's declaration order puts tickRateHz before fovDegrees. + const laige::Result result = + laige::applyConfigOverride(base, override); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "camera_fov_invalid"), 0u); + restoreLogger(); +} + +TEST(ConfigOverride, AssetRootsReplaceTheVector) { + const laige::EngineConfig base = fullBaseConfig(); + laige::EngineConfigOverride override; + override.assetRoots = {"a/", "b/"}; + const laige::EngineConfig merged = + laige::applyConfigOverride(base, override).value(); + ASSERT_EQ(merged.assetRoots.size(), 2u); + EXPECT_EQ(merged.assetRoots[0], "a/"); + EXPECT_EQ(merged.assetRoots[1], "b/"); + // Every other field survives the replacement. + EXPECT_EQ(merged.tickRateHz, base.tickRateHz); +} + +// --------------------------------------------------------------------------- +// ConfigHotReload: the debug-build-only hot reload (FR-1.5) +// --------------------------------------------------------------------------- + +namespace { +// The reloader's baseline document: tick 60, camera fov 45 (the +// defaults), one asset root. +const char* kBaselineDocument = R"({ + "version": 1, + "tick_rate_hz": 60, + "camera": { "fov_degrees": 45, "zoom": 1, "follow_lerp_per_sec": 8 }, + "asset_roots": ["assets/"] +})"; + +laige::EngineConfig baselineConfig() { + const laige::EngineConfig config = + laige::parseEngineConfig(parseDoc(kBaselineDocument)).value(); + return config; +} +} // namespace + +TEST(ConfigHotReload, CreateFailsOnMissingFile) { + MemorySink* sink = installCaptureSink(); + const std::string path = tempPath("hot_reload_missing.json"); + const laige::Result result = + laige::ConfigHotReloader::create(path); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::IoError); + EXPECT_EQ(countEvents(*sink, "file_read_failed"), 1u); + restoreLogger(); +} + +TEST(ConfigHotReload, UnchangedFileIsANoop) { + const std::string path = tempPath("hot_reload_unchanged.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + EXPECT_TRUE(reloader.poll(config).ok()); + EXPECT_EQ(config.tickRateHz, 60u); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, CameraDefaultReloadsWithoutRestart) { + const std::string path = tempPath("hot_reload_camera.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The editor changes the camera fov (a non-simulation key): the + // poll applies it to the live config without a restart (FR-1.5). + const std::string changed = + replaceFirst(kBaselineDocument, R"( "fov_degrees": 45)", + R"( "fov_degrees": 60)"); + ASSERT_NE(changed, kBaselineDocument); + ASSERT_TRUE(writeConfigFile(path, changed)); + MemorySink* sink = installCaptureSink(); + EXPECT_TRUE(reloader.poll(config).ok()); + EXPECT_DOUBLE_EQ(config.camera.fovDegrees, 60.0); + // The simulation-affecting fields are untouched. + EXPECT_EQ(config.tickRateHz, 60u); + EXPECT_EQ(config.seed, laige::kDefaultSimulationSeed); + EXPECT_TRUE(config.determinism.enabled); + const MemorySink::Entry* applied = findEvent(*sink, "hot_reload_applied"); + ASSERT_NE(applied, nullptr); + EXPECT_EQ(fieldValue(*applied, "keys"), "camera.fov_degrees"); + restoreLogger(); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, TickRateChangeIsRefused) { + const std::string path = tempPath("hot_reload_tick.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The editor changes the tick rate (a simulation-affecting key): + // the poll is REFUSED with the actionable hot_reload_rejected error + // and the config is left untouched (FR-1.5: restart to apply). + const std::string changed = replaceFirst(kBaselineDocument, + "\"tick_rate_hz\": 60", + "\"tick_rate_hz\": 90"); + ASSERT_NE(changed, kBaselineDocument); + ASSERT_TRUE(writeConfigFile(path, changed)); + MemorySink* sink = installCaptureSink(); + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + const MemorySink::Entry* rejected = + findEvent(*sink, "hot_reload_rejected"); + ASSERT_NE(rejected, nullptr); + EXPECT_EQ(fieldValue(*rejected, "key"), "tick_rate_hz"); + EXPECT_EQ(fieldValue(*rejected, "old"), "60"); + EXPECT_EQ(fieldValue(*rejected, "new"), "90"); + // The live config and the baseline are untouched: a further poll of + // the (still refused) file reports the same refusal, and the engine + // values are unchanged. + EXPECT_EQ(config.tickRateHz, 60u); + EXPECT_TRUE(reloader.poll(config).isError()); + restoreLogger(); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, MixedSimAndNonSimChangeIsRefusedAtomic) { + const std::string path = tempPath("hot_reload_mixed.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // One write changes the seed (sim-affecting) AND the zoom + // (non-sim): the whole change is refused — the zoom is NOT applied + // (the config never corresponds to a partial file state). + std::string changed = replaceFirst(kBaselineDocument, R"("zoom": 1,)", + R"("zoom": 9,)"); + changed = replaceFirst(changed, "\n}", ",\n \"seed\": 7\n}"); + ASSERT_TRUE(writeConfigFile(path, changed)); + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + EXPECT_DOUBLE_EQ(config.camera.zoom, laige::kDefaultCameraZoom); + EXPECT_EQ(config.seed, laige::kDefaultSimulationSeed); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, SystemTimeBudgetChangeIsRefused) { + const std::string path = tempPath("hot_reload_systime.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The per-system default time budget governs the G-R5 enforcement + // during ticks: it is simulation-affecting (restart-required). + const std::string changed = + replaceFirst(kBaselineDocument, "\"asset_roots\"", + R"("budgets": {"system_time_default_ms": 9}, "asset_roots")"); + ASSERT_TRUE(writeConfigFile(path, changed)); + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + EXPECT_DOUBLE_EQ(config.budgets.systemTimeDefaultMs, + laige::kDefaultSystemTimeBudgetMs); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, PresentationBudgetsAndAssetRootsReload) { + const std::string path = tempPath("hot_reload_presentation.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + std::string changed = + replaceFirst(kBaselineDocument, "\"asset_roots\"", + R"("budgets": {"draw_calls_per_frame": 24, "particles_per_frame": 2048}, "asset_roots")"); + changed = replaceFirst(changed, R"("assets/")", R"("assets/", "extra/")"); + ASSERT_TRUE(writeConfigFile(path, changed)); + MemorySink* sink = installCaptureSink(); + EXPECT_TRUE(reloader.poll(config).ok()); + EXPECT_EQ(config.budgets.drawCallsPerFrame, 24u); + EXPECT_EQ(config.budgets.particlesPerFrame, 2048u); + ASSERT_EQ(config.assetRoots.size(), 2u); + EXPECT_EQ(config.assetRoots[1], "extra/"); + const MemorySink::Entry* applied = findEvent(*sink, "hot_reload_applied"); + ASSERT_NE(applied, nullptr); + EXPECT_EQ( + fieldValue(*applied, "keys"), + "budgets.draw_calls_per_frame,budgets.particles_per_frame," + "asset_roots"); + restoreLogger(); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, WhitespaceOnlyChangeAdvancesTheBaseline) { + const std::string path = tempPath("hot_reload_whitespace.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The editor reformats (same fields, different bytes): no semantic + // change — no applied event, but the baseline advances so the NEXT + // semantic change is measured against the new document. + const std::string reformatted = + replaceAll(kBaselineDocument, "\n", "\r\n"); + ASSERT_NE(reformatted, kBaselineDocument); + ASSERT_TRUE(writeConfigFile(path, reformatted)); + MemorySink* sink = installCaptureSink(); + EXPECT_TRUE(reloader.poll(config).ok()); + EXPECT_EQ(countEvents(*sink, "hot_reload_applied"), 0u); + // Now a real camera change against the reformatted baseline. + const std::string changed = + replaceFirst(reformatted, R"( "fov_degrees": 45)", + R"( "fov_degrees": 90)"); + ASSERT_TRUE(writeConfigFile(path, changed)); + EXPECT_TRUE(reloader.poll(config).ok()); + EXPECT_DOUBLE_EQ(config.camera.fovDegrees, 90.0); + EXPECT_EQ(countEvents(*sink, "hot_reload_applied"), 1u); + restoreLogger(); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, MalformedFileKeepsThePreviousConfig) { + const std::string path = tempPath("hot_reload_malformed.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The file is truncated mid-edit (the closing brace is missing): + // the poll fails with MalformedInput and the live config is + // untouched. + const std::string full = kBaselineDocument; + const std::string truncated = full.substr(0, full.size() - 2); + ASSERT_TRUE(writeConfigFile(path, truncated)); + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::MalformedInput); + EXPECT_EQ(config.tickRateHz, 60u); + // The editor finishes the save: the file matches the baseline again + // — the poll is a clean no-op. + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + EXPECT_TRUE(reloader.poll(config).ok()); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, ReadFailureKeepsThePreviousConfig) { + const std::string path = tempPath("hot_reload_readfail.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::EngineConfig config = baselineConfig(); + // The file vanishes (a transient editor state): the poll fails with + // IoError + the retryable read-failed warn; the config stays. + std::remove(path.c_str()); + MemorySink* sink = installCaptureSink(); + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::IoError); + EXPECT_EQ(countEvents(*sink, "hot_reload_read_failed"), 1u); + EXPECT_EQ(config.tickRateHz, 60u); + // The file comes back with the original bytes: a clean no-op. + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + EXPECT_TRUE(reloader.poll(config).ok()); + restoreLogger(); + std::remove(path.c_str()); +} + +TEST(ConfigHotReload, MovedFromReloaderFailsWithoutLogging) { + const std::string path = tempPath("hot_reload_moved.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + laige::ConfigHotReloader reloader = + std::move(laige::ConfigHotReloader::create(path)).takeValue(); + laige::ConfigHotReloader moved = std::move(reloader); + laige::EngineConfig config = baselineConfig(); + MemorySink* sink = installCaptureSink(); + // The moved-from reloader is stopped: poll fails without logging + // (the stopped-state precedent). + const laige::Status status = reloader.poll(config); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sink->entries.size(), 0u); + // The live reloader still works. + EXPECT_TRUE(moved.poll(config).ok()); + restoreLogger(); + std::remove(path.c_str()); +} + +#if defined(NDEBUG) +// Release builds (NDEBUG): hot reload is a debug-build feature — the +// feature call is rejected (the replay/record_disabled pattern). +TEST(ConfigHotReload, ReleaseBuildsRejectTheFeature) { + MemorySink* sink = installCaptureSink(); + const std::string path = tempPath("hot_reload_release.json"); + ASSERT_TRUE(writeConfigFile(path, kBaselineDocument)); + const laige::Result created = + laige::ConfigHotReloader::create(path); + ASSERT_TRUE(created.isError()); + EXPECT_EQ(created.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "hot_reload_disabled"), 1u); + restoreLogger(); + std::remove(path.c_str()); +} +#endif diff --git a/tests/replay/fixtures/replay_smoke.json b/tests/replay/fixtures/replay_smoke.json index c7e71c5..9b4fc2f 100644 --- a/tests/replay/fixtures/replay_smoke.json +++ b/tests/replay/fixtures/replay_smoke.json @@ -1,4 +1,5 @@ { + "version": 1, "tick_rate_hz": 60, "entity_budget": 64, "churn_per_frame_budget": 256, diff --git a/tests/replay/fixtures/replay_smoke_budget128.json b/tests/replay/fixtures/replay_smoke_budget128.json index 881e029..52fe083 100644 --- a/tests/replay/fixtures/replay_smoke_budget128.json +++ b/tests/replay/fixtures/replay_smoke_budget128.json @@ -1,4 +1,5 @@ { + "version": 1, "tick_rate_hz": 60, "entity_budget": 128, "churn_per_frame_budget": 256, diff --git a/tests/replay/fixtures/replay_smoke_wrongseed.json b/tests/replay/fixtures/replay_smoke_wrongseed.json index eb585ed..5b49550 100644 --- a/tests/replay/fixtures/replay_smoke_wrongseed.json +++ b/tests/replay/fixtures/replay_smoke_wrongseed.json @@ -1,4 +1,5 @@ { + "version": 1, "tick_rate_hz": 60, "entity_budget": 64, "churn_per_frame_budget": 256, diff --git a/tools/run/laige-run.cpp b/tools/run/laige-run.cpp index 0e5c8b4..469a7bd 100644 --- a/tools/run/laige-run.cpp +++ b/tools/run/laige-run.cpp @@ -3,9 +3,9 @@ // FR-1.6 / AC-6.2: the entire engine (except presentation) runs // without a window/GPU — required for servers, CI, and replay // tooling. This binary is the M1 entry point for the headless form: -// it loads the declarative config (JSON — the provisional M1-HEAD-01 -// config surface; M1-CFG-01 owns the full schema), builds the -// Engine, and runs it for the requested number of ticks. +// it loads the declarative config (the version 1 JSON schema — +// M1-CFG-01: laige/sim/config.h, loadGameConfig), builds the Engine, +// and runs it for the requested number of ticks. // // Usage (docs/api/engine.md, the "laige-run" section): // @@ -56,31 +56,20 @@ #include #include #include -#include -#include #include #include #include -#if defined(_MSC_VER) -#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below -#endif - #include "laige/errors.h" -#include "laige/json.h" #include "laige/logging.h" #include "laige/result.h" +#include "laige/sim/config.h" #include "laige/sim/engine.h" #include "laige/sim/game_loop.h" #include "laige/sim/replay.h" namespace { -// The 1 MiB config-document bound (ADR 0003 / the JsonOptions -// default): the read stops one byte past the bound so an oversized -// file is a MalformedInput, not a truncated parse. -inline constexpr std::size_t kMaxConfigBytes = 1u << 20; - void printUsage(std::FILE* out) { std::fprintf(out, "Usage: laige-run --headless [--ticks N] " @@ -125,54 +114,6 @@ bool parseTicks(std::string_view text, std::uint64_t* out) { return true; } -// Portable config-file open (CPP-009 compile-time platform boundary — -// the logging.cpp precedent). MSVC's CRT deprecates plain `fopen` -// (C4996, fatal under the engine's /WX policy); `fopen_s` cannot be -// used here: it opens with the `_SH_SECURE` sharing mode, which denies -// re-opening of the file (the windows-msvc CI runs of M0-CORE-02 read -// back an empty file for exactly that reason). `_fsopen(path, mode, -// _SH_DENYNO)` is the CRT's documented way to open with plain-`fopen` -// sharing semantics, which every other supported compiler's `fopen` -// provides. -#if defined(_MSC_VER) -inline std::FILE* openConfigFile(const char* path, const char* mode) { - return ::_fsopen(path, mode, _SH_DENYNO); -} -#else -inline std::FILE* openConfigFile(const char* path, const char* mode) { - return std::fopen(path, mode); -} -#endif - -// Reads the config file into a bounded buffer (the 1 MiB ADR 0003 -// bound). Returns the error Status; on success the document is in -// `out`. -laige::Status readConfigFile(const std::string& path, std::string* out) { - std::FILE* file = openConfigFile(path.c_str(), "rb"); - if (file == nullptr) { - return laige::ErrorCode::IoError; - } - out->clear(); - char chunk[8192]; - for (;;) { - const std::size_t n = std::fread(chunk, 1, sizeof(chunk), file); - if (n == 0) { - if (std::ferror(file)) { - std::fclose(file); - return laige::ErrorCode::IoError; - } - break; // clean EOF - } - out->append(chunk, n); - if (out->size() > kMaxConfigBytes) { - std::fclose(file); - return laige::ErrorCode::MalformedInput; - } - } - std::fclose(file); - return laige::Status{}; -} - } // namespace int main(int argc, char** argv) { @@ -222,22 +163,11 @@ int main(int argc, char** argv) { return 2; } - std::string document; - const laige::Status readStatus = readConfigFile(configPath, &document); - if (readStatus.isError()) { - std::fprintf(stderr, "laige-run: config: %s\n", - laige::errorText(readStatus.error())); - return 2; - } - const laige::Result parsed = - laige::parseJson(document); - if (parsed.isError()) { - std::fprintf(stderr, "laige-run: config: %s\n", - laige::errorText(parsed.error())); - return 2; - } + // The version 1 declarative config (M1-CFG-01): bounded read + + // parse + version gate + schema validation in one cold call; the + // rejection events are the config/* events of laige/sim/config.h. const laige::Result config = - laige::parseEngineConfig(parsed.value()); + laige::loadGameConfig(configPath); if (config.isError()) { std::fprintf(stderr, "laige-run: config: %s\n", laige::errorText(config.error()));