From e80ccc807fd1293bbf60ceb0357521b6becb2d9d Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 11:30:28 +0200 Subject: [PATCH 1/2] [M1-HEAD-01] Headless engine run (Engine + laige-run) Headless engine run (M1-HEAD-01 scope, nothing else): - Engine (src/laige-sim/include/laige/sim/engine.h + engine.cpp): config -> world -> systems -> loop. EngineConfig (tickRateHz 20-120 default 60, entityCapacity 0-65536 default 0 = empty scene, churnPerFrameBudget default 256) + parseEngineConfig over the bounded JSON (unknown key -> warn config/unknown_key, ignored; rejections config/not_an_object, tick_rate_invalid, entity_budget_invalid, churn_budget_invalid - first failure wins, one rate-limited warn each, NFR-13.3 grammar). Engine::create pre-validates, creates the World, registers Position2DFpx16 FIRST (ARCH-010). - run_headless(maxTicks, frameBudgetTicks): schedule -> GameLoop (with the engine per-tick hook) -> first frame (0 ticks, start reference) -> PresentationSnapshot anchored on the loop's exact startReferenceNs (ARCH-009) -> wall-clock-paced frames (one steady_clock read + one bounded sleep per frame; exact integer due math, M1-LOOP-01). The run ALWAYS ends in the ordered shutdown (CONC-006: loop -> world clear -> snapshot -> world release -> logging flush); the shutdown is idempotent (double/triple safe, world() -> nullptr, second run -> InvalidArgument with no log); maxTicks == 0 = the server form. Lifecycle Info pair engine/run_started / engine/run_finished; no logging on the healthy frame path (PERF-003/LOG-003). Setup = exactly three one-shot allocations (loop object, snapshot object, 24 B/slot table); zero per-frame allocations (verified: identical count for 1/2/3/10 ticks - the engine-zeroalloc line). - laige-run binary (tools/run): --headless CONFIG (1 MiB bounded read), --ticks N, --replay LOG stub (warned replay/replay_deferred, M1-DET-02), --help. Exit codes 0 ok / 1 run failure / 2 usage-IO-config; stdout summary 'laige-run headless ticks=... status=ok'; the CLI exercises the double-shutdown idempotency. - CI: laige_run_smoke ctest entry (--ticks 1000 against the fixture, 60 Hz / 10000 slots; TIMEOUT 300, PASS_REGULAR_EXPRESSION status=ok, TSan halt_on_error=1). engine ctest entry (20 tests; TSan property list). - Docs: docs/api/engine.md (new, full contract + Performance), docs/README.md, docs/getting-started/building.md, tools/README.md, src/laige-sim/README.md. laige-api.json regenerated (555 -> 573 symbols; api-real-tree green). - Deviation: declared dependency M1-CFG-01 has not landed - the JSON config surface is PROVISIONAL (three unversioned keys), documented in engine.md, the header preamble, and the roadmap change log; M1-CFG-01 owns the final versioned schema. - Verified: zero-warning 47/47 ctest on all six local trees (build, build-asan, build-tsan, build-clang, build-release, build-shared); ctest -R engine green (20/20); ctest -R laige_run_smoke green (~16.7 s, status=ok); tools/laige-include-lint OK (33 source files, 1/10 vendored deps). --- CMakeLists.txt | 1 + docs/README.md | 16 +- docs/api/engine.md | 254 ++++++++++ docs/getting-started/building.md | 6 +- laige-api.json | 19 + roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-sim/CMakeLists.txt | 10 +- src/laige-sim/README.md | 14 +- src/laige-sim/engine.cpp | 437 ++++++++++++++++ src/laige-sim/include/laige/sim/engine.h | 392 +++++++++++++++ tests/laige-sim/CMakeLists.txt | 51 +- tests/laige-sim/engine_tests.cpp | 495 +++++++++++++++++++ tests/laige-sim/fixtures/headless_smoke.json | 5 + tools/README.md | 10 + tools/run/CMakeLists.txt | 43 ++ tools/run/laige-run.cpp | 243 +++++++++ 17 files changed, 1974 insertions(+), 29 deletions(-) create mode 100644 docs/api/engine.md create mode 100644 src/laige-sim/engine.cpp create mode 100644 src/laige-sim/include/laige/sim/engine.h create mode 100644 tests/laige-sim/engine_tests.cpp create mode 100644 tests/laige-sim/fixtures/headless_smoke.json create mode 100644 tools/run/CMakeLists.txt create mode 100644 tools/run/laige-run.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f11a3c..512d6ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -230,4 +230,5 @@ if(LAIGE_BUILD_TESTS) add_subdirectory(tools/bench) # M0-CORE-08: laige-bench add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner add_subdirectory(tools/detcheck) # M0-TOOL-02: laige-detcheck + add_subdirectory(tools/run) # M1-HEAD-01: laige-run (+ smoke test) endif() diff --git a/docs/README.md b/docs/README.md index 51dc1b4..9f0c6dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,8 @@ accounting suite; M1-SYS-01: the system registry; M1-SYS-02: the system scheduler; M1-SYS-03: the per-system timing + budget enforcement; M1-LOOP-01: the fixed-timestep game loop core; M1-LOOP-02: the per-tick presentation snapshot + interpolation -state). +state; M1-HEAD-01: the headless engine run — `Engine` +(config → world → systems → loop) and the `laige-run` binary). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -21,8 +22,9 @@ still to land. - [Building Laige](getting-started/building.md) — the source of truth for the canonical build commands, build trees, options, compiler policy (NFR-8.10), sanitizer builds (NFR-8.2), and the current M0 - status. Tool commands: `laige-fuzz`, `laige-bench`, `laige-detcheck`, - the `laige-api` manifest target, and the include-graph lint. + status. Tool commands: `laige-run`, `laige-fuzz`, `laige-bench`, + `laige-detcheck`, the `laige-api` manifest target, and the + include-graph lint. ## Concepts @@ -84,6 +86,11 @@ still to land. `PresentationSnapshot`: the per-tick `prev`/`curr` capture, the exact-integer anchored alpha (clamped to [0, 1], never extrapolates), and `sample_position` (M1-LOOP-02; `laige-sim`). + - [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, and the `laige-run` + CLI (M1-HEAD-01; `laige-sim` + `tools/run`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, @@ -177,7 +184,8 @@ still to land. [scheduler.md](api/scheduler.md), [system_timing.md](api/system_timing.md), [game_loop.md](api/game_loop.md), - [presentation.md](api/presentation.md).) + [presentation.md](api/presentation.md), + [engine.md](api/engine.md).) ## Related diff --git a/docs/api/engine.md b/docs/api/engine.md new file mode 100644 index 0000000..c1a84da --- /dev/null +++ b/docs/api/engine.md @@ -0,0 +1,254 @@ +# Headless engine run (`Engine`, M1-HEAD-01) + +The M1 headless engine object (M1-HEAD-01; PRD FR-1.6, ARCH-003, +ARCH-009, ARCH-010, CONC-006, PRD §10.2; AGENTS CORE-002/005/008, +PERF-002/003, LOG-003): wires **config → world → systems → loop** +into one owned object whose `run_headless(maxTicks)` starts the +simulation, ticks it in real time at the configured rate, and shuts +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: +`ctest -R laige_run_smoke` (every P0 OS job, 1000 ticks @ 60 Hz). + +```cpp +laige::EngineConfig config; +config.tickRateHz = 60; +config.entityCapacity = 4096; +config.churnPerFrameBudget = 256; + +laige::Engine engine = laige::Engine::create(config).value(); + +// Game code registers on the world BEFORE the run (the schedule is +// computed once at the start of the run): +engine.world()->registerComponent(); +engine.world()->registerSystem(MySystem_Def); + +const laige::Status status = engine.run_headless(10'000); +// The run always ends in the ordered shutdown — success or failure: +// engine.isShutDown() == true, engine.world() == nullptr. +``` + +## The lifecycle (CONC-006) + +1. **`Engine::create(config)`** — validates the config, creates the + `World` (capacity = `entityCapacity`, churn budget = + `churnPerFrameBudget`), and registers the built-in + `sim::Position2DFpx16` component **first** (ARCH-010: stable + component-type ordering; the ADR 0002 default SimMath backend, + `fpx16_16`). No frame is run and no loop exists yet; the engine is + in the *not started* state. +2. **Game registration** — the game registers its components and + systems on `engine.world()` before the run (see Misuse warnings). +3. **`run_headless(maxTicks)`** — computes the system schedule, + creates the `GameLoop` (M1-LOOP-01) with the engine's per-tick hook, + runs one initial `frame()` (which runs 0 ticks and establishes the + loop's start reference), creates the presentation snapshot anchored + on the loop's exact start reference (ARCH-009), and then drives + frames at wall-clock pacing until `maxTicks` ticks have run (or + forever, when `maxTicks == 0` — the server form). **The run always + ends in the ordered shutdown** — a failed start or a failed frame + mid-run is not an exception: the engine returns the failure `Status` + and is already shut down. +4. **`shutdown()`** — ordered and **idempotent**: loop → world clear → + snapshot → world release → logging flush (CONC-006). Calling it + after a finished run (or twice) is a safe no-op; `world()` reads + back `nullptr` and `run_headless` on a stopped engine returns + `InvalidArgument` without logging (the moved-out `GameLoop` + precedent, M1-LOOP-01). + +The destructor calls `shutdown()`, so a forgotten shutdown never +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}` 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) | + +- **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` — + each maps to `ErrorCode::InvalidArgument` (NFR-13.3 grammar: + `{codeId}|{what}|{why}|{fix}|{docAnchor}`, see `errors.md`). +- `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. + +## The run contract (`run_headless`) + +`Status run_headless(std::uint64_t maxTicks, + std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks)` + +- **`maxTicks == 0`** — the **server form**: run until the process + ends (the headless simulation never self-terminates). +- **`maxTicks > 0`** — the bounded run: complete **exactly** at + `maxTicks` on a healthy machine. With `frameBudgetTicks == 1` each + frame runs at most one tick, so a late frame drops a tick rather + than overshooting — the tick count lands exactly on the target + under any cadence (the M1-LOOP-01 catch-up contract). With the + default budget (5), an overloaded frame may run up to 5 ticks to + catch up, so a late frame can overshoot by at most budget − 1 + ticks before the target check stops the run. +- **Pacing** — one `steady_clock` read per frame, one bounded + `sleep_for` until the next tick's due time (the exact integer due + computation, M1-LOOP-01). 1000 ticks @ 60 Hz ≈ 16.7 s of wall + clock — the `laige_run_smoke` ctest budget (TIMEOUT 300) and its + `status=ok` assertion (CI asserts the run completed, not the tick + count; drops are the documented overload behavior). +- **Lifecycle logs** — one `engine/run_started` (Info) before setup, + one `engine/run_finished` (Info) after the last frame with the + final accounting (`ticks`, `dropped_ticks`, `dropped_frames`, + `status`); both are structured, stable, and machine-greppable + (AGENTS §14). + +**Failure behavior** (the run always ends in shutdown): + +| condition | result | +|---|---| +| engine already stopped | `InvalidArgument`, **no log** (the stopped-state failure is a pure failure) | +| `frameBudgetTicks == 0` | `InvalidArgument` + one `loop/catchup_invalid` warn (the GameLoop's validation) | +| schedule failure | the `scheduleSystems` Status (`system/*` — world-emitted) | +| a failed frame mid-run | that frame's Status (`system/*` — the loop's `runSystems` dispatch) | +| success | `ok` | + +## Presentation wiring (ARCH-009) + +The engine owns a +`PresentationSnapshot` — the **default** SimMath +backend per ADR 0002 (determinism math selection is M1-DET-01's +decision; the engine does not expose a backend knob in M1). Wiring: + +- The loop is created with the engine's per-tick hook from the first + `frame()`, so the snapshot exists before the hook can fire (the + first frame runs 0 ticks by the M1-LOOP-01 contract). +- The snapshot is created **after** the first frame, anchored on the + loop's exact `startReferenceNs()` (the presentation.h alpha + contract: `alpha` is derived from `(now − startReference)`, never + from a floating accumulator). +- Per frame the engine reads the clock once and passes that reading + to `onRenderFrame(now)`; the tick path (`onTick(tick)`) refreshes + prev/curr for every live position entity. Presentation state is + never authoritative (ARCH-009) — `sample_position`/interpolation + consume only the snapshot. + +## Determinism scope (ARCH-010) + +Headless runs are deterministic **within the same build, platform, +architecture, and compiler**: the tick cadence is integer arithmetic, +the system order is the validated schedule, and the SimMath backend +is pinned (`fpx16_16`). Wall-clock pacing (the sleep) does **not** +enter the simulation — it only decides when frames run; dropped ticks +are the documented, logged overload behavior, not nondeterminism. +Cross-build/platform determinism, replay, and state hashing are +**M1-DET-02** (the `--replay` flag is its stub today). + +## `laige-run` (the CLI) + +``` +laige-run --headless CONFIG.json [--ticks N] [--replay LOG] +``` + +- `--headless CONFIG` — required: the JSON config file (bounded read, + 1 MiB max; over-bound → `MalformedInput`; read error → `IoError`). +- `--ticks N` — the bounded run target (decimal digits only; + default 0 = the server form). +- `--replay LOG` — **stubbed** for M1-DET-02: accepted, ignored, and + announced with one `replay/replay_deferred` warn (the flag is + reserved so game-tool scripts can be written now). +- `--help` / `-h` — usage, exit 0. + +**Exit codes:** `0` = the run completed; `1` = the engine run failed +(the `Status`'s error name is printed on stderr); `2` = usage, file, +or config error. On completion the run prints one machine-greppable +summary line on stdout: + +``` +laige-run headless ticks=1000 dropped_ticks=0 dropped_frames=0 status=ok +``` + +The CLI then calls `engine.shutdown()` a second time — the +double-shutdown idempotency the step verifies — and exits. + +## Performance (PERF-002/003) + +- **Per frame** (the headless run loop): one clock read, one bounded + `GameLoop::frame()` (itself one clock read + integer ops + up to + `frameBudgetTicks` system dispatches — PERF-002 bounded), one + snapshot `onRenderFrame` (a few integer ops), one sleep. **No + allocation and no logging on the healthy path** (PERF-003, + LOG-003). +- **Setup, once per run:** exactly three one-shot allocations — the + `GameLoop` object, the `PresentationSnapshot` object, and the + presentation slot record table (24 B/entity slot, sized by the + scene budget — the presentation.h storage contract). Verified + per-frame-zero by `ctest -R engine` + (`HeadlessFramePathAllocatesNothing`: the allocation count is + identical for 1, 2, 3, and 10 ticks). The M1-ALLOC-01 pool + accounting will supersede the probe once it exists. +- **Complexity** — `run_headless` is O(maxTicks × per-tick system + work), bounded per frame by `frameBudgetTicks`. The drop path is + cold: one rate-limited warn per overload frame (M1-LOOP-01). +- **Traps** — registering systems after the run started does not + update the schedule (see below); running at 120 Hz on a 60 Hz + display doubles the tick rate (validate your frame budget against + your systems' declared budgets, M1-SYS-03). + +## Misuse warnings + +- **Register game components/systems on `world()` BEFORE + `run_headless`** — the schedule is computed once at the start of + the run. A registration after the schedule makes the schedule + stale; the next frame fails `system/schedule_stale` (the + M1-SYS-02 contract) and the run returns that status. +- **One run per engine** — a second `run_headless` on a finished + engine returns `InvalidArgument` (no log). Create a new engine + (the config is cheap; the world's pools are sized at create). +- **Do not call `world()` after shutdown** for writes — it reads back + `nullptr` by contract (queries are safe: they return null, they do + not dereference). +- **`maxTicks == 0` never returns** — the server form runs until the + process ends. Use the bounded form for tests and CI. +- **The config is validated at `create`, not at run** — a + hand-built `EngineConfig` bypassing the JSON path is still + validated (same codes), so there is no unvalidated path. + +## Testing and CI + +- `ctest -R engine` — 20 tests: create/config validation, the JSON + parse surface, the bounded run + loop accounting, the zero-frame + budget rejection, the stopped-state second run, the double-shutdown + idempotency, the world release, and the zero-allocation frame-path + probe (non-sanitizer trees). +- `ctest -R laige_run_smoke` — `laige-run --headless + tests/laige-sim/fixtures/headless_smoke.json --ticks 1000` (60 Hz, + 10 000 slots): must exit 0 and print `status=ok` on every P0 OS + job; TIMEOUT 300 s (≈16.7 s nominal); the TSan job sets + `TSAN_OPTIONS=halt_on_error=1`. +- The include-graph lint (`tools/laige-include-lint`) guarantees the + headless path carries no GPU/window symbols (ARCH-003): `laige-run` + links only `laige-sim` → `laige-core`. diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index dfb59c5..61cc478 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -32,6 +32,7 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | Test (ASan/UBSan tree) | `ctest --test-dir build-asan --output-on-failure` | | TSan build | `cmake -S . -B build-tsan -DCMAKE_BUILD_TYPE=Debug -DLAIGE_TSAN=ON` | | Test (TSan tree) | `ctest --test-dir build-tsan --output-on-failure` | +| Headless run | `./build/bin/laige-run --headless CONFIG.json [--ticks N]` | | Fuzz (bounded) | `./build/bin/laige-fuzz --runs=1000` | | Fuzz (long, nightly form) | `./build/bin/laige-fuzz --runs=1000000` | | Benchmarks | `./build/bin/laige-bench --suite=` | @@ -42,7 +43,10 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) Notes: - `Debug` is the canonical `CMAKE_BUILD_TYPE`; `Release` is supported. -- The tool rows above the lint row are live targets now: `laige-fuzz` +- The tool rows above the lint row are live targets now: `laige-run` + (M1-HEAD-01: `--headless CONFIG.json [--ticks N] [--replay LOG]`, + exit codes 0/1/2, the `laige_run_smoke` CTest entry is its CI form — + contract in [docs/api/engine.md](../api/engine.md)), `laige-fuzz` (M0-CORE-07: the `json_parse` target and deterministic bounded runs; M0-TEST-01 documents the CI lane semantics — bounded `--runs=1000` in every P0 job's `ctest`, the nightly long-run form above — and the diff --git a/laige-api.json b/laige-api.json index 31f0412..2af3a99 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/engine.h", "src/laige-sim/include/laige/sim/entity.h", "src/laige-sim/include/laige/sim/game_loop.h", "src/laige-sim/include/laige/sim/presentation.h", @@ -425,6 +426,24 @@ {"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::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 228, "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": 231, "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": 236, "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": 239, "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::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 266, "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": 273, "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": 289, "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), and register the built-in Position2DFpx16 (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": 295, "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": 299, "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": 325, "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": 333, "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": 337, "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": 343, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 348, "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": 349, "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": 350, "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": 351, "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": 355, "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": 187, "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": 188, "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": 189, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 7606e2e..e19e278 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -148,7 +148,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R presentation` green. - **Size:** ~200 lines + tests -- [ ] **M1-HEAD-01 · Headless engine run** +- [x] **M1-HEAD-01 · Headless engine run** - **Refs:** FR-1.6, ARCH-003, AC-6.2 - **Depends:** M1-LOOP-02, M1-CFG-01, M0-CORE-07 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index ce83e89..5a58f72 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 | 12 | 🚧 in progress (M1-LOOP-02) | +| M1 | 25 | 13 | 🚧 in progress (M1-HEAD-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** | **32** | | +| **Total** | **193** | **33** | | --- @@ -207,6 +207,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-SYS-03 | `a63d6b9` | Per-system timing + budget enforcement (PRD §9.3 G-R5; FR-11.1/11.2, FR-12.3; M1-SYS-03 scope, nothing else): `World::runSystems` now times each system's own run (the M0-CORE-08 `TimeIt` steady_clock scope around the run function — two steady_clock reads per system, the context built outside the window) and hands the sample to `World::checkSystemBudget` (new `src/laige-sim/system_timing.cpp`): it records into the system's rolling window — a fixed-capacity `Histogram` (`kSystemTimingWindowSamples` = 64 samples ≈ 1.1 s at 60 Hz; O(1) record, no allocation, drops the OLDEST on overflow, `totalRecorded()` keeps counting; the window rolls across TICKS — `beginFrame()` does not touch it) — and enforces the declared budget: `measured > 1× budget` → `system/budget_overrun` (Warn), `measured >= 3× budget` (`kBudgetCriticalMultiplier`, PRD "over 3× → error event") → `system/budget_critical` (Error); a 3× run fires BOTH in the same tick. Both events: NFR-13.3 5-field grammar (build-stable message text; dynamic values as structured fields `system`/`id`/`measured_ms`/`budget_ms`/`p99_ms`/`window_samples` — never message text), rate-limited per (subsystem, event, severity) with the 1 s window (LOG-004; the `rate_limited` summary carries the suppressed count), and count in `SystemTimingStats` (`warns`/`errors`) even when suppressed; the p99 comes from one cold O(W log W) `stats()` pass (no allocation) only while the breach persists. An over-budget system is STILL RUN — observation and reporting, never an execution gate (FR-12.3). New public API (additive): `SystemTimingStats` (runs/lastMs/warns/errors), `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats(SystemId)` (Result; O(1) pure query; invalid id or moved-from → `InvalidArgument`, no warn — the `World::system` precedent) and `World::systemTimingWindow(SystemId)` (const Histogram* — the M1-PROF-02 frame graph's budgetCheck feed; nullptr for invalid); `detail::SystemTimingRecord` (unique_ptr Histogram — the Histogram has no default ctor — + the cheap scalars) in a fixed kMaxSystems table parallel to the registry (allocated in `create()` even for zero-capacity worlds, travels with the world on move, survives `clear()`); `World::checkSystemBudget` (private hook, called per system per tick). Determinism: measured times are DIAGNOSTIC only (ARCH-009) — they never enter authoritative state, hashes, or replays. Hot path: two clock reads + one ring write + two comparisons per system per tick — no allocation, no logging on success (the `SchedulingAndTicksAllocateNothing` window still proves 0 allocs over 100 ticks). New `SystemTiming` suite (8 tests) + CTest entry `system_timing` (the step's Verify command; TSan property list): healthy ticks log nothing + track stats (runs/lastMs/window count/totalRecorded); over-budget synthetic system warns at the documented multiplier (1 warn, measured ≥ 6.5 ms against a 5 ms budget; second tick rate-limited; shutdown `rate_limited` summary `suppressed` = 1); critical synthetic system (1 ms budget, 7 ms burn) fires the warn THEN the error in one tick; rolling window drops oldest (5W-sample fast/slow/fast phases with min/max/p99 bounds + machine-greppable `system-timing window` line); NFR-13.3 grammar check (5-field split on `" | "`, fields[0] = event, doc anchor `docs/api/system_timing.md`; the second over-budget system's warn is rate-suppressed and summarized at shutdown); query validation (id 0 / above count / moved-from → `InvalidArgument`/nullptr; no-systems world → 0-count stats ok); state travels with move (5 ticks pre-move, moved world keeps stats + window, moved-from queries fail); zero-allocation window (test-only operator-new counter, non-sanitizer trees only — 100 ticks × 2 systems → `allocs=0`, machine-greppable `system-timing-zeroalloc` line). Verified: `ctest -R system_timing` green + full suite 43/43 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang`, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (28 source files, 1/10 vendored deps), `laige-api.json` regenerated (496 → 505 symbols; +9: `SystemTimingStats` + 4 members, `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats`, `World::systemTimingWindow`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/system_timing.md` (measurement scope, the rolling window, the thresholds + event fields, the profiler feed, the ARCH-009 determinism scope, the Performance section, misuse warnings) + cross-refs in `docs/api/scheduler.md`, `docs/api/system_registry.md`, `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-01 | `30f3013` | Fixed-timestep game loop core (FR-1.1, ARCH-002, PRD §10.2/§10.3; M1-LOOP-01 scope, nothing else): new `GameLoop` (public header `src/laige-sim/include/laige/sim/game_loop.h`, implementation `src/laige-sim/game_loop.cpp`) — the accumulator loop that advances the simulation in INTEGER ticks, decoupled from the presentation frame cadence: `GameLoop::create(world, schedule, options)` validates the typed config (first failure wins; every rejection = `InvalidArgument` + one rate-limited warn, FR-12.3/CORE-008 — `loop/tick_rate_invalid` for `tickRateHz` outside 20–120 (`kMinTickRateHz`/`kDefaultTickRateHz` = 60 / `kMaxTickRateHz`), `loop/catchup_invalid` for `maxCatchUpTicks == 0` (default `kDefaultMaxCatchUpTicks` = 5 — bounds per-frame work, not rate)) and holds non-owning world/schedule views (both outlive the loop; one live loop per world); `frame()` is the hot path (one clock read, a few integer ops, up to `maxCatchUpTicks` BOUNDED `runSystems` dispatches — PERF-002; no allocation, no logging on success — PERF-003/LOG-003) and runs exactly `min(due − ticksRun, maxCatchUpTicks)` ticks where `due(now) = floor(elapsedNs × rate / 10⁹)` is computed in EXACT integer arithmetic (the seconds/sub-seconds split keeps every product overflow-free; no floating point, no rounding drift — ARCH-010) and the unrun remainder is re-derived from the clock every frame (no stored accumulator state: a synthetic 10 s clock at 60 Hz yields EXACTLY 600 ticks — a float ms accumulator floors to 599); the first frame establishes the start reference (zero ticks); `beginFrame()` is driven once per FRAME (the entity.h contract: the G-R3/G-R4 per-frame windows are per presentation frame — a catch-up frame of N ticks counts against one per-frame budget, the documented overload signal) and `runSystems` once per tick; overload: when `want > maxCatchUpTicks` the frame runs exactly `maxCatchUpTicks` and DROPS exactly `want − maxCatchUpTicks` (counted in `droppedTicks`/`droppedFrames` — never silent) with one rate-limited `loop/tick_dropped` warn (NFR-13.3 5-field build-stable message; structured fields `dropped`/`total_dropped`/`max_catch_up`/`tick_rate_hz`; one event per rate window + the `rate_limited` summary at shutdown — LOG-004), and the per-frame work stays bounded so the accumulator never grows unboundedly (PERF-008 backpressure); failure: a stale/malformed schedule surfaces the `runSystems` `InvalidArgument` (`system/schedule_stale`/`schedule_invalid` — the loop adds no event), a failed tick is not counted (its system phase did not complete; no system runs in a failed frame — validation precedes dispatch), the tick count freezes and each later frame fails the same way (rate-limited) until the caller recreates the loop with a recomputed schedule; a moved-from loop is STOPPED (`frame()` → `InvalidArgument`, no log, no world access — the moved-from-world pure-failure precedent) while move transfers the tick state (the factory's `Result` move); the clock source is `Options::nowNs` (nanoseconds on a monotonic epoch time base; `nullptr` → the headless monotonic `steady_clock` — the LoggerOptions::ClockFn precedent; a backward reading below the start reference asserts in debug / clamps in release — never UB); `GameLoopStats` (frames/ticks/droppedTicks/droppedFrames) is the since-construction profiler feed (pure O(1) query — the `World::stats()` precedent; the M1-PROF-01 feed). Determinism scope (ARCH-009/010): the tick sequence is a pure function of (clock readings, rate, cap) — integer-only, bit-identical across builds for the same clock sequence (replay state — M1-DET-01/02 include the tick counter in the hash); clock readings are wall-clock facts (the windowed clock M2-GL-02 / replay runner M1-DET-03 supply the canonical time base); frames/drops are presentation/diagnostic state, never authoritative. New `GameLoop` suite (11 tests) + CTest entry `game_loop` (the step's Verify command; TSan property list): config validation + warns + read-back (the 121 Hz repeat is rate-limited and summarized at shutdown — `suppressed = 1`), the first frame runs zero ticks, exact 600 ticks over a synthetic 10 s clock (400 steps of 16666667 ns + 200 of 16666666 ns = 10¹⁰ ns; machine-greppable `game-loop exact` line), the overload drops EXACTLY 8/16/24 (48 total) over three 10-tick demands against a cap of 2 and logs once per episode (the NFR-13.3 grammar check + the `rate_limited` summary `suppressed = 2`; machine-greppable `game-loop drops` line), the healthy cadence runs 120 ticks / zero drops / silent with one `runSystems` dispatch per tick (the M1-SYS-03 feed tracks the ticks exactly), a stale schedule freezes the tick count and surfaces the `Status` (the `system/schedule_stale` warn rate-limited), a backward clock jump (release clamps to the start reference — no tick, no new event; debug asserts — forked SIGABRT child, POSIX jobs), the default `steady_clock` drives real frames (50 ms sleep → ≥ 3 ticks at 60 Hz), move transfers the state and stops the source (the stopped loop's `frame()` → `InvalidArgument`, no log, world untouched), and the zero-allocation window (300 frames × 2 ticks = 600 ticks, zero drops → `allocs = 0` — the test-only operator-new counter, non-sanitizer trees; machine-greppable `game-loop-zeroalloc` line; the sanitizer trees prove it leak-free). Verified: `ctest -R game_loop` green + full suite 44/44 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (30 source files, 1/10 vendored deps), `laige-api.json` regenerated (505 → 530 symbols; +25: `GameLoop` + members, `Options` + 3 fields, `GameLoopStats` + 4 fields, `kMinTickRateHz`/`kDefaultTickRateHz`/`kMaxTickRateHz`/`kDefaultMaxCatchUpTicks`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/game_loop.md` (the two cadences, the exact due computation, config + validation, the overload behavior, the beginFrame wiring, the failure behavior, the determinism scope, the profiler feed, the Performance section, misuse warnings) + cross-refs in `docs/api/system_timing.md`, `include/laige/sim/system.h` (the scheduler sketch now references `GameLoop`), `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | +| 2026-09-15 | M1-HEAD-01 | — | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 30bc250..9715951 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -46,8 +46,16 @@ # exact-integer clamped alpha, sample_position — no new .cpp; the # GameLoop gains the Options::onTick per-completed-tick hook and # startReferenceNs() in game_loop.h/.cpp to drive it). +# startReferenceNs() in game_loop.h/.cpp to drive it). M1-HEAD-01 +# adds engine.cpp: the headless engine run (Engine::create/ +# 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). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp - systems.cpp system_timing.cpp game_loop.cpp) + systems.cpp system_timing.cpp game_loop.cpp + engine.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index c5aa721..3a1a0cd 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -84,5 +84,15 @@ class-template pattern, ADR 0002) (`include/laige/sim/presentation.h` + the `game_loop.h`/`game_loop.cpp` hook; API contract in [docs/api/presentation.md](../docs/api/presentation.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `presentation`). -The profiler, determinism/replay, headless engine, and the remaining -M1 steps land next; physics, input, and animation in M3. +M1-HEAD-01 landed the headless engine run — `Engine` +(config → world → systems → loop: `EngineConfig` + the provisional +`parseEngineConfig` JSON surface, `run_headless(maxTicks)` the +bounded + server run forms, the ordered idempotent CONC-006 +shutdown, the presentation snapshot wiring on the default `fpx16_16` +backend) plus the `laige-run` binary (`--headless`/`--ticks`/ +`--replay` stub) and the `laige_run_smoke` CI entry +(`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`). +The profiler, determinism/replay, and the remaining M1 steps land +next; physics, input, and animation in M3. diff --git a/src/laige-sim/engine.cpp b/src/laige-sim/engine.cpp new file mode 100644 index 0000000..4b97b0b --- /dev/null +++ b/src/laige-sim/engine.cpp @@ -0,0 +1,437 @@ +// laige-sim headless engine run (M1-HEAD-01; FR-1.6, ARCH-003, +// AC-6.2, CONC-006). +// +// Implementation of the Engine, EngineConfig, and parseEngineConfig +// declared in include/laige/sim/engine.h — see that header for the +// full contract (the lifecycle, the run contract, the ordered +// shutdown, the provisional config surface, the determinism scope, +// the performance notes) and docs/api/engine.md for the API document +// and the laige-run CLI contract. +// +// Hot-path cost (per headless frame): one clock read, one bounded +// GameLoop::frame() dispatch, one snapshot onRenderFrame, one sleep — +// no allocation and no logging on the healthy path (PERF-003, +// LOG-003; the per-frame breakdown in engine.h "Performance"). + +#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 { + +namespace { + +// Nanoseconds per second (the clock time base; the +// game_loop.cpp constant). +inline constexpr std::int64_t kNanosecondsPerSecond = 1000000000LL; + +// The stable subsystem names (LOG-001). +inline constexpr const char* kEngineSubsystem = "engine"; +inline constexpr const char* kConfigSubsystem = "config"; + +// The headless clock source (M1-LOOP-01): the monotonic steady_clock +// as nanoseconds since its epoch (the windowed clock arrives with +// M2-GL-02). +std::int64_t steadyNowNs() noexcept { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// 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"; + +// 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. +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(); + if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || + 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; +} + +// 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 { + // 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 +// --------------------------------------------------------------------------- + +Engine::Engine() = default; + +Result Engine::create(const EngineConfig& config) noexcept { + // Validate the typed config first (API-008: reject before allocating; + // the GameLoop re-validates the same range at loop construction — + // the single warn here, subsystem "config", is the config-surface + // rejection). + if (config.tickRateHz < kMinTickRateHz || config.tickRateHz > kMaxTickRateHz) { + LAIGE_LOG_WARN(kConfigSubsystem, "tick_rate_invalid", + kTickRateInvalidMessage, + laige::log::field("tick_rate_hz", config.tickRateHz)); + return ErrorCode::InvalidArgument; + } + World::Options worldOptions; + worldOptions.capacity = config.entityCapacity; + worldOptions.churnPerFrameBudget = config.churnPerFrameBudget; + Result worldResult = World::create(worldOptions); + if (worldResult.isError()) { + // The World's validation (capacity > 65536 -> InvalidArgument, no + // warn there — the World::create precedent). + return worldResult.error(); + } + Engine engine; + engine.world_ = std::make_unique(std::move(worldResult).takeValue()); + // The engine's built-ins always register FIRST (stable + // registration order for the deterministic ComponentTypeIds, + // ARCH-010; the game's components follow through world()). + const Result builtin = + engine.world_->registerComponent(); + if (builtin.isError()) { + // Unreachable on a fresh world (the type is registered once per + // world); propagated anyway — never silent (CORE-008). + return builtin.error(); + } + engine.config_ = config; + return engine; +} + +// --------------------------------------------------------------------------- +// The presentation onTick wiring (M1-LOOP-02) +// --------------------------------------------------------------------------- + +void Engine::onTickHook(void* context, World& world, + std::uint64_t tick) noexcept { + (void)world; // the snapshot owns its own non-owning world view + static_cast(context)->onTickHookDispatch(tick); +} + +void Engine::onTickHookDispatch(std::uint64_t tick) noexcept { + // The first loop frame runs zero ticks (game_loop.h), so the + // snapshot exists by the time this hook can fire; a null snapshot + // is still a no-op, never a crash. + if (snapshot_ != nullptr) snapshot_->onTick(tick); +} + +// --------------------------------------------------------------------------- +// run_headless (the header preamble "run_headless contract") +// --------------------------------------------------------------------------- + +Status Engine::run_headless(std::uint64_t maxTicks, + std::uint32_t frameBudgetTicks) noexcept { + // A stopped engine (shutdown or moved-from) is a no-op failure + // without logging (the stopped-state precedent — the GameLoop's + // moved-out frame()). + if (shutDown_ || world_ == nullptr) { + return ErrorCode::InvalidArgument; + } + LAIGE_LOG_INFO(kEngineSubsystem, "run_started", + "Headless run started", + laige::log::field("tick_rate_hz", config_.tickRateHz), + laige::log::field("tick_target", maxTicks), + laige::log::field("frame_budget_ticks", frameBudgetTicks)); + // The schedule is computed ONCE, at the start of the run (the + // game's registrations must precede run_headless — the header's + // misuse warning; a stale schedule is the loop's documented + // failure). + Status runStatus = world_->scheduleSystems(schedule_); + if (runStatus.ok()) { + Result loopResult = + GameLoop::create(*world_, schedule_, + GameLoop::Options{ + config_.tickRateHz, + frameBudgetTicks, + nullptr, // default headless clock + &Engine::onTickHook, // the M1-LOOP-02 hook + this}); + if (loopResult.ok()) { + loop_ = std::make_unique(std::move(loopResult).takeValue()); + // The first frame establishes the loop's start reference and + // runs zero ticks (game_loop.h) — the snapshot is anchored on + // EXACTLY that reference (the presentation.h "alpha contract"; + // the hook fires only on completed ticks, so the snapshot + // exists before it can fire). + const Status firstFrame = loop_->frame(); + if (firstFrame.ok()) { + using Snapshot = PresentationSnapshot; + Result snapshotResult = Snapshot::create( + *world_, loop_->startReferenceNs(), + Snapshot::Options{config_.tickRateHz}); + if (snapshotResult.ok()) { + snapshot_ = std::make_unique( + std::move(snapshotResult).takeValue()); + runStatus = runFrames(maxTicks); + } else { + runStatus = snapshotResult.error(); + } + } else { + runStatus = firstFrame; + } + } else { + runStatus = loopResult.error(); + } + } + // The loop's accounting BEFORE it is destroyed in shutdown (the + // profiler feed; zeros when the loop never existed). + lastStats_ = (loop_ != nullptr) ? loop_->stats() : GameLoopStats{}; + LAIGE_LOG_INFO(kEngineSubsystem, "run_finished", + "Headless run finished", + laige::log::field("ticks", lastStats_.ticks), + laige::log::field("dropped_ticks", lastStats_.droppedTicks), + laige::log::field("dropped_frames", lastStats_.droppedFrames), + laige::log::field( + "status", + runStatus.ok() ? std::string_view("ok") + : std::string_view( + laige::errorName(runStatus.error())))); + // CONC-006: shutdown ALWAYS happens (the engine's lifecycle ends in + // the ordered teardown, success or failure). + shutdown(); + return runStatus; +} + +// The frame drive: bounded, paced, allocation-free (engine.h +// "Performance"). One clock read, one loop frame, one snapshot +// refresh, one sleep per frame. +Status Engine::runFrames(std::uint64_t maxTicks) noexcept { + const std::int64_t startNs = loop_->startReferenceNs(); + const std::int64_t rate = static_cast(loop_->tickRateHz()); + for (;;) { + if (maxTicks != 0 && loop_->currentTick() >= maxTicks) break; + const std::int64_t now = steadyNowNs(); + const Status frameStatus = loop_->frame(); + if (frameStatus.isError()) return frameStatus; + // The frame's clock reading goes to the presentation state + // (presentation.h wiring: the engine reads the frame clock once + // per frame and passes it to the snapshot). + snapshot_->onRenderFrame(now); + if (maxTicks != 0 && loop_->currentTick() >= maxTicks) { + break; // no sleep after the final tick (a bounded run ends) + } + // Pacing: sleep until the next tick's due time (one bounded sleep + // per frame — the headless pacing; the sleep granularity is + // platform-dependent, and the loop's bounded catch-up absorbs a + // late wake, game_loop.h). The next due time is exact integer + // math (the game_loop.cpp ticksDue identity): with T completed + // ticks, due(T+1) = floor((T+1) x 1e9 / rate) is computed as + // q x 1e9 + r x 1e9 / rate for (T+1) = q x rate + r — every + // intermediate product is in range (r < rate, so r x 1e9 < 1.2e11). + const std::int64_t dueTicks = static_cast(loop_->currentTick()) + 1; + const std::int64_t fullSeconds = dueTicks / rate; + const std::int64_t remainder = dueTicks % rate; + const std::int64_t nextDueNs = + startNs + fullSeconds * kNanosecondsPerSecond + + remainder * kNanosecondsPerSecond / rate; + if (nextDueNs > now) { + std::this_thread::sleep_for(std::chrono::nanoseconds(nextDueNs - now)); + } + } + return Status{}; +} + +// --------------------------------------------------------------------------- +// shutdown (the header preamble "The ordered shutdown") +// --------------------------------------------------------------------------- + +void Engine::shutdown() noexcept { + // IDEMPOTENT (CONC-006): the destructor and a second call are + // no-ops. + if (shutDown_) return; + // 1. systems: the loop stops first — no frame can start after this + // point (the system phase is over). + loop_.reset(); + // 2. world: every live entity is destroyed (the per-entity + // component data is released with its rows; the registries + // survive — the entity.h clear() contract). + if (world_ != nullptr) { + static_cast(world_->clear()); + } + // 3. pools: the presentation record table, then the world's backing + // storage (the per-slot tables, the archetype table and column + // blocks, the type-key index). The snapshot is released BEFORE + // the world: it holds a non-owning world view. + snapshot_.reset(); + world_.reset(); + // 4. logging: the facade's controlled shutdown (the rate-limit + // summaries drain, the sink flushes, the facade retires — + // LOG-007; idempotent). + laige::log::Logger::instance().shutdown(); + shutDown_ = true; +} + +// --------------------------------------------------------------------------- +// Accessors and lifetime +// --------------------------------------------------------------------------- + +World* Engine::world() noexcept { return world_.get(); } + +const EngineConfig& Engine::config() const noexcept { return config_; } + +bool Engine::isShutDown() const noexcept { return shutDown_; } + +GameLoopStats Engine::stats() const noexcept { return lastStats_; } + +Engine::Engine(Engine&& other) noexcept + : world_(std::move(other.world_)), + loop_(std::move(other.loop_)), + snapshot_(std::move(other.snapshot_)), + schedule_(other.schedule_), + config_(other.config_), + lastStats_(other.lastStats_), + shutDown_(other.shutDown_) { + // The source becomes a STOPPED engine (the GameLoop moved-out + // precedent): nothing left to release, nothing to flush. + other.shutDown_ = true; +} + +Engine& Engine::operator=(Engine&& other) noexcept { + if (this != &other) { + // Release this engine's current state first (ordered; a no-op + // when already stopped). + shutdown(); + world_ = std::move(other.world_); + loop_ = std::move(other.loop_); + snapshot_ = std::move(other.snapshot_); + schedule_ = other.schedule_; + config_ = other.config_; + lastStats_ = other.lastStats_; + shutDown_ = other.shutDown_; + other.shutDown_ = true; + } + return *this; +} + +Engine::~Engine() noexcept { shutdown(); } + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/engine.h b/src/laige-sim/include/laige/sim/engine.h new file mode 100644 index 0000000..aba2af5 --- /dev/null +++ b/src/laige-sim/include/laige/sim/engine.h @@ -0,0 +1,392 @@ +// laige-sim headless engine run (M1-HEAD-01). +// +// FR-1.6 (the entire engine, except presentation, runs without a +// window/GPU — required for servers, CI, and replay tooling); +// ARCH-003 (headless builds without graphics, audio, input, or +// windowing; server code must not transitively depend on client UI); +// AC-6.2 (server mode builds headless on Linux and Windows); PRD §10.2 +// (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 +// the loop in run_headless() and shuts everything +// down in a fixed order (shutdown()). +// +// --------------------------------------------------------------------------- +// Lifecycle (config -> world -> systems -> loop) +// --------------------------------------------------------------------------- +// +// The engine is the M1 owner of the wiring the M1 steps deferred to it +// (game_loop.h "Per-tick presentation hook": "M1-HEAD-01 owns the +// wiring"; presentation.h "Misuse warnings": "the engine reads [the +// frame clock] once per frame and passes it to both"): +// +// 1. Engine::create(config) +// Validates the typed config, creates the World (the scene +// budget and churn budget from the config), and registers the +// built-in component Position2DFpx16 FIRST (the engine's +// built-ins always precede the game's components: a stable +// registration order for the deterministic ComponentTypeIds, +// ARCH-010). +// 2. Game setup (the game's setup phase, on the engine's world): +// world->registerComponent(), world->registerSystem(def, +// Io<...>...) — the M1 systems are plain C++ functions +// (system.h); a game binary registers its own before the run. +// 3. run_headless(maxTicks, frameBudgetTicks) +// Computes the schedule (World::scheduleSystems), creates the +// GameLoop (with the presentation onTick hook), runs one first +// frame (it establishes the loop's start reference and runs +// zero ticks — game_loop.h), creates the PresentationSnapshot +// anchored on THAT start reference (exact, presentation.h +// "alpha contract"), and then drives frames against the +// headless monotonic clock until maxTicks ticks have completed +// (or forever, maxTicks == 0 — the server form). +// 4. shutdown() +// The ordered CONC-006 shutdown (below). run_headless() always +// ends in a shutdown; shutdown() is public and idempotent so a +// failed run or a pre-run teardown needs no special path. +// +// --------------------------------------------------------------------------- +// run_headless contract +// --------------------------------------------------------------------------- +// +// maxTicks == 0 run until the process ends (the headless server +// form; bounded runs are the M1 form). +// maxTicks > 0 the run completes once the loop's completed tick +// count REACHES maxTicks. A healthy machine lands +// exactly on maxTicks; a frame running more than +// frameBudgetTicks of due ticks may overshoot by up +// to frameBudgetTicks - 1 (the GameLoop's bounded +// catch-up — game_loop.h), so the final count is +// maxTicks or a little above under overload. +// +// The frame cadence is driven by the headless monotonic clock +// (steady_clock — the M1-LOOP-01 clock source; the windowed clock +// arrives with M2-GL-02): each frame calls GameLoop::frame() (which +// reads the clock and runs the frame's due ticks, bounded by +// frameBudgetTicks, dropping and logging the excess — the M1-LOOP-01 +// overload contract), then hands the SAME frame's clock reading to +// PresentationSnapshot::onRenderFrame (the presentation.h wiring +// guarantee), then sleeps until the next tick's due time (one bounded +// sleep per frame — the headless pacing; a server that must react +// faster than one tick raises the tick rate, it does not spin). +// +// Failure: a failed frame (a stale or malformed schedule — the loop's +// runSystems validation) stops the run: run_headless returns that +// Status and the engine is still shut down (CONC-006: shutdown always +// happens). The run's lifecycle events (engine/run_started, +// engine/run_finished, Info) bracket the run — low volume, one pair +// per run. +// +// --------------------------------------------------------------------------- +// The ordered shutdown (CONC-006: systems -> world -> pools -> +// logging flush) +// --------------------------------------------------------------------------- +// +// 1. systems the loop is destroyed first: no frame can start +// after the shutdown begins (the system phase stops). +// 2. world world->clear() destroys every live entity (the +// per-entity component data is released with its rows; +// the registries survive, entity.h). +// 3. pools the world's backing storage is released (per-slot +// tables, the archetype table and column blocks, the +// type-key index) and the presentation snapshot's +// per-slot record table is released. +// 4. logging the logging facade's controlled shutdown: the +// pending rate-limit summaries drain, the sink +// flushes, and the facade retires (LOG-007). +// +// shutdown() is IDEMPOTENT (CONC-006): a second call — and the +// destructor's call — is a no-op. It is safe before a run (nothing +// started: only the world is released), after a run (the normal +// path), and after a failed run. +// +// --------------------------------------------------------------------------- +// The config surface (PROVISIONAL — M1-CFG-01 owns the final 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. +// 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) +// +// 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. +// +// --------------------------------------------------------------------------- +// Built-in components and the determinism scope (ARCH-009/010) +// --------------------------------------------------------------------------- +// +// The engine runs the default SimMath backend (fpx16_16, ADR 0002): +// it registers Position2DFpx16 (presentation.h) and owns a +// PresentationSnapshot. The config's determinism math +// selection (ADR 0002: fpx16_16 default, fp32_pinned opt-in) is +// consumed by M1-DET-01 — until then the backend is the documented +// default, not a config knob. +// +// The completed tick count of a bounded run is a bounded wall-clock +// fact (the pacing is platform-sensitive — ARCH-009, the game_loop.h +// determinism scope); the simulation STATE after N completed ticks is +// a pure function of (the config, the registration order, the tick +// count): no wall-clock values enter authoritative state. The +// presentation alpha is a wall-clock fact by design (presentation.h: +// never part of replay state or the state hash). +// +// --------------------------------------------------------------------------- +// Ownership, threading +// --------------------------------------------------------------------------- +// +// The Engine is move-only and has exactly one owner thread (CONC-001; +// PRD §10.2): create, the game setup on world(), run_headless, and +// shutdown all run on that thread. The World, the PresentationSnapshot, +// and the GameLoop are owned by the engine (unique state; the loop and +// the snapshot hold non-owning world views — the world outlives them, +// the shutdown order above). A moved-from engine is STOPPED: world() +// is nullptr, run_headless() fails (InvalidArgument, no log — the +// stopped-state precedent), and shutdown() is a no-op. +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003) +// --------------------------------------------------------------------------- +// +// Per frame (the headless run loop): one clock read, one bounded +// GameLoop::frame() dispatch (itself one clock read + integer ops + +// up to frameBudgetTicks system dispatches — PERF-002 bounded), one +// snapshot onRenderFrame (a few integer ops), and one sleep. No +// allocation and no logging on the healthy path (PERF-003, LOG-003). +// The run's setup path allocates exactly three times, all one-shot +// (verified per-frame-zero by the M1-HEAD-01 zero-allocation test): +// the GameLoop object, the PresentationSnapshot object, and the +// presentation slot record table (24 B/entity slot, sized by the +// scene budget — the presentation.h storage contract). The drop +// path is cold (one rate-limited warn per overload frame — the +// M1-LOOP-01 contract). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Register game components/systems on world() BEFORE +// run_headless: the schedule is computed once, at the start of +// the run. A registration after the schedule makes the schedule +// stale — every frame fails with system/schedule_stale (the +// GameLoop's documented behavior; the run stops and reports it). +// - One live engine per world is not expressible: the engine OWNS +// its world. Never drive the engine's world from outside +// run_headless during a run (the owner thread, CONC-001). +// - maxTicks == 0 is the server form: the run never returns on its +// own. Stop the process (the OS signal form); a controlled stop() +// arrives with the server work (M6), not here. +// - The headless clock is steady_clock: it is monotonic by +// definition, but it is a WALL-CLOCK base (ARCH-009) — the number +// of wall-clock seconds a run of N ticks takes is not part of the +// deterministic contract. +// - run_headless after shutdown (or on a moved-from engine) is a +// no-op failure: recreate the engine, do not reuse it. + +#pragma once + +#include +#include + +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/result.h" +#include "laige/sim/entity.h" // World, kDefaultChurnPerFrameBudget +#include "laige/sim/game_loop.h" // GameLoop, GameLoopStats, tick-rate constants +#include "laige/sim/presentation.h" // Position2DFpx16, PresentationSnapshot + +namespace laige { + +class JsonValue; // declared in laige/json.h; only a const reference is used + +// 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}; +}; + +// 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) +// unknown key -> Warn only (config/unknown_key, +// 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; + +// 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. +class Engine { + public: + // 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), and register the built-in + // Position2DFpx16 (the engine's built-ins always come first — + // ARCH-010). O(1) beyond the World's setup allocations. + // + // tickRateHz outside 20..120 -> InvalidArgument + warn + // (config/tick_rate_invalid) + // entityCapacity > 65536 -> InvalidArgument (the World's + // validation — no warn there, + // the World::create precedent) + // built-in registration failure -> propagated (unreachable on a + // fresh world; never silent) + [[nodiscard]] static Result create(const EngineConfig& config) noexcept; + + // 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. + [[nodiscard]] World* world() noexcept; + + // The engine configuration echo (the validated values). O(1), no + // side effects. + [[nodiscard]] const EngineConfig& config() const noexcept; + + // 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). + // + // frameBudgetTicks == 0 -> InvalidArgument + warn + // (loop/catchup_invalid — the + // GameLoop's validation) + // schedule failure -> the scheduleSystems Status + // (system/* — world-emitted); + // the engine is not started, + // but is still shut down + // a failed frame mid-run -> that frame's Status + // (system/* — the loop's + // runSystems dispatch) + // success -> ok + // + // The run emits one engine/run_started (Info, before setup) and one + // engine/run_finished (Info, after the last frame, before the + // shutdown flush) — the lifecycle pair (AGENTS §14 Info contract). + // @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. + [[nodiscard]] Status run_headless(std::uint64_t maxTicks, + std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept; + + // 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. + void shutdown() noexcept; + + // True once shutdown() has completed (or on a moved-from engine). + // O(1), no side effects. + [[nodiscard]] bool isShutDown() const noexcept; + + // 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). + [[nodiscard]] GameLoopStats stats() const noexcept; + + // Move transfers the owned state; the source becomes a STOPPED + // engine (world() nullptr, run_headless fails, shutdown is a no-op + // — the GameLoop moved-out precedent). + Engine(Engine&& other) noexcept; + Engine& operator=(Engine&& other) noexcept; + Engine(const Engine&) = delete; + Engine& operator=(const Engine&) = delete; + + // The destructor shuts down (CONC-006: owned work is released even + // when the caller forgets shutdown()). + ~Engine() noexcept; + + private: + // The factory path (create). + Engine(); + + // The GameLoop's per-completed-tick hook (M1-LOOP-02 wiring): the + // loop is created with this hook from its first frame, but the + // first frame runs zero ticks (game_loop.h), so the snapshot exists + // by the time the hook can fire; a null snapshot (a failed setup + // between frames — unreachable in the engine's sequence) is a + // no-op, never a crash. + static void onTickHook(void* context, World& world, + std::uint64_t tick) noexcept; + void onTickHookDispatch(std::uint64_t tick) noexcept; + + // The frame drive (the run_headless contract: one clock read, one + // bounded loop frame, one snapshot refresh, one sleep per frame; + // stops at maxTicks — 0 = run until the process ends). + [[nodiscard]] Status runFrames(std::uint64_t maxTicks) noexcept; + + // The owned state (all released in shutdown, in the documented + // order). + std::unique_ptr world_; + std::unique_ptr loop_; + std::unique_ptr> snapshot_; + // The run's execution order (computed at the start of the run; a + // plain value — the SystemSchedule ownership contract, system.h). + SystemSchedule schedule_{}; + EngineConfig config_{}; + // The last run's loop accounting (set on every run completion, + // including a failed one — before the loop is destroyed). + GameLoopStats lastStats_{}; + // True after shutdown() has run (or on a moved-from engine). + bool shutDown_{false}; +}; + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 4426153..c251ad3 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-LOOP-01/02 + M1-HEAD-01): # 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 @@ -11,23 +11,26 @@ # fixed-timestep game loop core (the accumulator, the tick-rate # validation, the bounded catch-up + tick_dropped overload behavior), # and the presentation snapshot + interpolation state (the per-tick -# prev/curr refresh, the anchored clamped alpha, sample_position). +# 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). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus # gtest_main. The unfiltered entry runs the whole module; the `entity`, # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, -# `system_timing`, `game_loop`, and `presentation` 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, and -# M1-LOOP-02 Verify commands (`ctest -R entity`, `ctest -R -# component_registry`, `ctest -R archetype`, `ctest -R query`, -# `ctest -R iter_order`, `ctest -R ecs_guardrails`, `ctest -R -# ecs_stress`, `ctest -R system_registry`, `ctest -R scheduler`, -# `ctest -R system_timing`, `ctest -R game_loop`, `ctest -R -# presentation`), selecting exactly the suites below from the shared -# executable. +# `system_timing`, `game_loop`, `presentation`, and `engine` 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, and M1-HEAD-01 Verify commands (`ctest -R entity`, +# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R +# query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, +# `ctest -R ecs_stress`, `ctest -R system_registry`, `ctest -R +# scheduler`, `ctest -R system_timing`, `ctest -R game_loop`, +# `ctest -R presentation`, and `ctest -R engine`), 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 @@ -37,15 +40,17 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp scheduler_tests.cpp system_timing_tests.cpp game_loop_tests.cpp - presentation_tests.cpp) + presentation_tests.cpp + engine_tests.cpp) # M1-ECS-03: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own # new/delete (strong symbols in the Clang/GCC TSan runtime archives, # interposed by ASan), so the counter is excluded from the sanitizer -# trees. There, the step's zero-allocation property is covered by the -# leak-free sanitizer run of the same churn loop plus the -# ArchetypeStats reservation-delta assertion (the same fallback -# pattern as tests/laige-core, M0-CORE-02/05). +# trees. There, the zero-allocation properties (the M1-ECS-03 churn +# and the M1-HEAD-01 headless run) are covered by the leak-free +# sanitizer run of the same loops plus the ArchetypeStats +# reservation-delta assertion (the same fallback pattern as +# tests/laige-core, M0-CORE-02/05). if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) list(APPEND LAIGE_SIM_TEST_SOURCES logging_alloc_counter.cpp) endif() @@ -178,11 +183,21 @@ add_test(NAME presentation COMMAND laige-sim_tests --gtest_filter=Presentation.*) +# M1-HEAD-01: headless engine run (FR-1.6, ARCH-003, CONC-006). The +# step's Verify scope is the double-shutdown test + the laige-run +# smoke (`laige_run_smoke` in tools/run, every P0 OS job); this entry +# selects exactly the Engine suites from the shared laige-sim_tests +# executable (the machine-greppable engine-zeroalloc line lands in +# the ctest output). +add_test(NAME engine + COMMAND laige-sim_tests + --gtest_filter=EngineCreate.*:EngineConfigParse.*:EngineRun.*:EngineShutdown.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # 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 PROPERTIES + system_timing game_loop presentation engine PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp new file mode 100644 index 0000000..28e8868 --- /dev/null +++ b/tests/laige-sim/engine_tests.cpp @@ -0,0 +1,495 @@ +// laige-sim headless engine run suite (M1-HEAD-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - the double-shutdown test is green (CONC-006: shutdown is +// idempotent — after a run, without a run, and a third call) +// - the Engine lifecycle: config -> world -> systems -> loop (the +// built-in Position2D registers FIRST, the game's registrations +// follow, the run schedules once and drives the loop) +// - 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) +// - a stopped engine (post-run / moved-from) fails without logging +// (the stopped-state precedent) +// - no GPU/window symbols: guaranteed by the include-graph lint +// (NFR-8.11 — the engine links only laige-sim + laige-core; no +// dedicated test here, the lint job IS the check) +// - the zero-allocation headless frame path (PERF-003; test-only +// operator-new counter, non-sanitizer trees — the sanitizer +// trees prove the run leak-free) +// +// The laige-run CLI smoke (1000-tick run exits 0 on all P0 OSes) is +// the CTest entry `laige_run_smoke` (tools/run); the engine-level +// suites below run as CTest `engine`. + +#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/engine.h" +#include "laige/sim/game_loop.h" +#include "laige/sim/system.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "engine_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "engine_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, + "engine_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test fixtures (the MemorySink capture pattern — +// ecs_guardrails_tests.cpp) +// --------------------------------------------------------------------------- + +// The scratch system: counts completed ticks (no component I/O — +// the scheduler's zero-entry case). The global counter is written +// only on the owner thread (PRD §10.2: one world, one thread). +namespace { +std::uint64_t gTickCount = 0; +} + +LAIGE_SYSTEM(EngTickCounter, 1) +void EngTickCounter(laige::World&, laige::SystemContext&) { + ++gTickCount; +} + +namespace { + +// One log event captured from the facade (severity >= Warn only — +// the Info lifecycle events of the engine run are not asserted +// here; they are low-volume by design). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +// 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& e : sink.entries) { + if (e.event == event) ++n; + } + 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). +laige::Engine makeEngine(laige::EngineConfig config) { + laige::Result r = + laige::Engine::create(config); + if (!r.ok()) { + ADD_FAILURE() << "Engine::create failed: " << laige::errorName(r.error()); + abort(); + } + return std::move(r).takeValue(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Engine::create (config validation, the built-in registration) +// --------------------------------------------------------------------------- + +TEST(EngineCreate, DefaultsBuildAnEmptyWorld) { + laige::EngineConfig config; // all defaults + laige::Result result = + laige::Engine::create(config); + ASSERT_TRUE(result.ok()); + laige::Engine engine = std::move(result).takeValue(); + // The built-in component registers FIRST (ARCH-010): one + // registered type, the world otherwise empty. + laige::World* world = engine.world(); + ASSERT_NE(world, nullptr); + EXPECT_EQ(world->componentCount(), 1u); + EXPECT_EQ(world->systemCount(), 0u); + // The config echo carries the validated values. + EXPECT_EQ(engine.config().tickRateHz, laige::kDefaultTickRateHz); + EXPECT_EQ(engine.config().entityCapacity, 0u); + EXPECT_EQ(engine.config().churnPerFrameBudget, + laige::kDefaultChurnPerFrameBudget); + engine.shutdown(); +} + +TEST(EngineCreate, ExplicitConfigEchoes) { + const laige::EngineConfig config{120, 4096, 100}; + laige::Result result = + laige::Engine::create(config); + ASSERT_TRUE(result.ok()); + laige::Engine engine = std::move(result).takeValue(); + EXPECT_EQ(engine.config().tickRateHz, 120u); + EXPECT_EQ(engine.config().entityCapacity, 4096u); + EXPECT_EQ(engine.config().churnPerFrameBudget, 100u); + engine.shutdown(); +} + +TEST(EngineCreate, TickRateOutOfRangeRejected) { + MemorySink* sink = installCaptureSink(); + for (const std::uint32_t bad : {0u, 19u, 121u, 1000u}) { + const laige::EngineConfig config{bad, 0, 0}; + const laige::Result result = + laige::Engine::create(config); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "tick_rate_invalid"), + static_cast(sink->entries.size())); + } + EXPECT_EQ(sink->entries.size(), 4u); // one warn per rejection + restoreLogger(); +} + +TEST(EngineCreate, EntityBudgetAboveHandleSpaceRejected) { + MemorySink* sink = installCaptureSink(); + const laige::EngineConfig config{60, laige::Entity::kMaxEntities + 1, 0}; + const laige::Result result = + laige::Engine::create(config); + ASSERT_TRUE(result.isError()); + EXPECT_EQ(result.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sink->entries.size(), 0u); // the World::create precedent: no warn + 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) +// --------------------------------------------------------------------------- + +TEST(EngineRun, BoundedRunCompletesExactlyWithSystems) { + gTickCount = 0; + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 128, 256}); + const laige::Result registered = + engine.world()->registerSystem(EngTickCounter_Def); + ASSERT_TRUE(registered.ok()); + const laige::Status status = engine.run_headless(5, 1); + // Frame budget 1: each frame runs AT MOST one tick, so the run + // lands EXACTLY on the target under any cadence (a late frame + // drops, it does not overshoot — the M1-LOOP-01 contract). + ASSERT_TRUE(status.ok()); + EXPECT_EQ(gTickCount, 5u); + EXPECT_EQ(engine.stats().ticks, 5u); + // (droppedTicks is NOT asserted: a loaded runner may drop ticks + // between frames — the M1-LOOP-01 overload behavior; the tick + // count still lands exactly on the target under frame budget 1.) + EXPECT_TRUE(engine.isShutDown()); // the run always ends in shutdown +} + +TEST(EngineRun, BoundedRunWithoutSystems) { + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + const laige::Status status = engine.run_headless(2, 1); + ASSERT_TRUE(status.ok()); + EXPECT_EQ(engine.stats().ticks, 2u); + EXPECT_GE(engine.stats().frames, 3u); // first frame (0 ticks) + 2 + EXPECT_TRUE(engine.isShutDown()); +} + +TEST(EngineRun, ZeroFrameBudgetRejected) { + MemorySink* sink = installCaptureSink(); + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + const laige::Status status = engine.run_headless(2, 0); + ASSERT_TRUE(status.isError()); + EXPECT_EQ(status.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "catchup_invalid"), 1u); + // The run ALWAYS ends in the ordered shutdown (CONC-006) — a + // failed start included: the world is released, and the caller's + // shutdown() is the idempotent no-op. + EXPECT_TRUE(engine.isShutDown()); + EXPECT_EQ(engine.world(), nullptr); + engine.shutdown(); // the idempotency the step verifies + EXPECT_TRUE(engine.isShutDown()); + restoreLogger(); +} + +TEST(EngineRun, SecondRunFailsWithoutLogging) { + MemorySink* sink = installCaptureSink(); + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + ASSERT_TRUE(engine.run_headless(1, 1).ok()); + const laige::Status second = engine.run_headless(1, 1); + ASSERT_TRUE(second.isError()); + EXPECT_EQ(second.error(), laige::ErrorCode::InvalidArgument); + // The stopped-state failure is a pure failure: no log (the + // GameLoop's moved-out precedent); the Info lifecycle events of + // the first run are below the capture sink's Warn floor anyway. + EXPECT_EQ(sink->entries.size(), 0u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// shutdown (CONC-006: ordered, idempotent) +// --------------------------------------------------------------------------- + +TEST(EngineShutdown, DoubleShutdownAfterRun) { + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + ASSERT_TRUE(engine.run_headless(3, 1).ok()); + ASSERT_TRUE(engine.isShutDown()); + engine.shutdown(); // second call: no-op + engine.shutdown(); // third call: no-op + SUCCEED(); +} + +TEST(EngineShutdown, DoubleShutdownWithoutRun) { + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + engine.shutdown(); + engine.shutdown(); + EXPECT_TRUE(engine.isShutDown()); + SUCCEED(); +} + +TEST(EngineShutdown, ShutdownReleasesTheWorld) { + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 8, 256}); + ASSERT_TRUE(engine.world()->create().ok()); // one live entity + engine.shutdown(); + EXPECT_TRUE(engine.isShutDown()); + EXPECT_EQ(engine.world(), nullptr); // released, queryable as null +} + +// --------------------------------------------------------------------------- +// The zero-allocation headless frame path (PERF-003; the M1-ALLOC-01 +// assertion will supersede this probe once it exists — ASan + pool +// accounting is the milestone's interim check) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(EngineRun, HeadlessFramePathAllocatesNothing) { + MemorySink* sink = installCaptureSink(); + // Gate the engine's Info lifecycle events and the loop's drop + // warns OFF for the window: the healthy path must not log (and a + // gated-off event costs no allocation — LOG-003). + laige::log::Logger::instance().setSubsystemLevel("engine", + laige::log::Level::Off); + laige::log::Logger::instance().setSubsystemLevel("loop", + laige::log::Level::Off); + // Warm-up: construct the logger singleton and exercise the world + // allocation pattern BEFORE the measured window (the first + // logger- and world-touching calls of the process allocate their + // one-time state). + { + laige::Engine warmup = + makeEngine(laige::EngineConfig{60, 8, 256}); + warmup.shutdown(); + } + laige::Engine engine = + makeEngine(laige::EngineConfig{60, 64, 256}); + ASSERT_TRUE(engine.world()->registerSystem(EngTickCounter_Def).ok()); + laige::test::resetAllocCounter(); + const laige::Status status = engine.run_headless(3, 1); + const std::uint64_t allocs = laige::test::allocCounter(); + // The run's setup path allocates exactly three times, all one-shot: + // the GameLoop object, the PresentationSnapshot object, and the + // presentation slot record table (24 B x capacity — the + // presentation.h storage contract). The steady-state frame path + // (clock read, loop frame, snapshot refresh, sleep) touches no + // heap: the count below is identical for 1, 2, 3, and 10 ticks + // (probe-verified, M1-HEAD-01), i.e. zero per frame/per tick — + // the PERF-003 hot-path property. + std::printf("engine-zeroalloc ticks=%llu allocs=%llu\n", + static_cast(engine.stats().ticks), + static_cast(allocs)); + ASSERT_TRUE(status.ok()); + EXPECT_EQ(engine.stats().ticks, 3u); + EXPECT_EQ(allocs, 3u); + EXPECT_EQ(sink->entries.size(), 0u); + restoreLogger(); +} +#endif diff --git a/tests/laige-sim/fixtures/headless_smoke.json b/tests/laige-sim/fixtures/headless_smoke.json new file mode 100644 index 0000000..76ed2b0 --- /dev/null +++ b/tests/laige-sim/fixtures/headless_smoke.json @@ -0,0 +1,5 @@ +{ + "tick_rate_hz": 60, + "entity_budget": 10000, + "churn_per_frame_budget": 256 +} diff --git a/tools/README.md b/tools/README.md index 1d6b4a3..8640b98 100644 --- a/tools/README.md +++ b/tools/README.md @@ -2,6 +2,16 @@ Engine tools and CI scripts, each landing with its roadmap step: +- `laige-run` — the headless run binary (M1-HEAD-01, in `tools/run`): + `laige-run --headless CONFIG.json [--ticks N] [--replay LOG]` — + config → world → systems → loop, the bounded run (default 0 = the + server form), and the ordered idempotent shutdown. Exit codes: + `0` ok · `1` engine run failure · `2` usage/IO/config error; one + machine-greppable summary line on stdout (`laige-run headless + ticks=… status=…`). `--replay` is the M1-DET-02 stub (accepted, + warned, ignored). Full contract in + [docs/api/engine.md](../docs/api/engine.md); the `laige_run_smoke` + CTest entry (1000 ticks @ 60 Hz, every P0 OS job) is its CI form. - `laige-fuzz` — deterministic bounded fuzz runner (minimal form from M0-CORE-07, in `tools/fuzz`: the `json_parse` target, `--runs`/`--seed`, built with `LAIGE_BUILD_TESTS=ON`, registered as the `fuzz_json_parse` diff --git a/tools/run/CMakeLists.txt b/tools/run/CMakeLists.txt new file mode 100644 index 0000000..d90ac72 --- /dev/null +++ b/tools/run/CMakeLists.txt @@ -0,0 +1,43 @@ +# laige-run (M1-HEAD-01): the headless engine run binary. +# +# Canonical command (docs/getting-started/building.md): +# +# ./build/bin/laige-run --headless [--ticks N] +# [--replay ] +# +# Loads the declarative config (the provisional M1-HEAD-01 JSON +# surface), builds the laige::Engine, and runs the headless loop for +# the requested ticks (the server form: 0/omitted). The windowed mode +# is M2. Gated with the test suite like the other tools: a +# library-only build does not need it. +# +# Headless invariants (ARCH-003, NFR-8.11): the include-graph lint +# guarantees this target's include closure (laige-sim + laige-core) +# touches no GL/window/audio/input API. + +add_executable(laige-run laige-run.cpp) +laige_apply_engine_policy(laige-run) +# laige-sim is the only internal dependency (PRD §10.1: arrows only +# downward); laige-core's public headers come through it (CPP-010). +target_link_libraries(laige-run PRIVATE laige-sim) + +# Smoke test — the M1-HEAD-01 Verify clause in executable form: a +# bounded 1000-tick headless run (60 Hz, the committed fixture config) +# exits 0 and prints the one-line summary. This entry runs in EVERY +# P0 OS job's ctest (the roadmap clause "exits 0 in CI on all P0 +# OSes"). The run is wall-clock paced (~16.7 s at 60 Hz) — the +# timeout carries the margin for loaded runners; a dropped tick +# (overload) still exits 0 (the GameLoop's bounded catch-up). +add_test(NAME laige_run_smoke + COMMAND laige-run --headless + ${CMAKE_SOURCE_DIR}/tests/laige-sim/fixtures/headless_smoke.json + --ticks 1000) +set_tests_properties(laige_run_smoke PROPERTIES TIMEOUT 300 + PASS_REGULAR_EXPRESSION "status=ok") + +if(LAIGE_TSAN) + # Same first-report-fatal policy as the other tool smoke tests + # (NFR-8.2). + set_tests_properties(laige_run_smoke + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") +endif() diff --git a/tools/run/laige-run.cpp b/tools/run/laige-run.cpp new file mode 100644 index 0000000..79e4176 --- /dev/null +++ b/tools/run/laige-run.cpp @@ -0,0 +1,243 @@ +// laige-run (M1-HEAD-01): the headless engine run binary. +// +// 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. +// +// Usage (docs/api/engine.md, the "laige-run" section): +// +// laige-run --headless [--ticks N] [--replay ] +// +// --headless run the engine headless with the given +// JSON config (required; the windowed mode +// is M2) +// --ticks N run until N completed ticks (N = 0 or +// omitted: the server form — run until the +// process ends) +// --replay STUB (M1-DET-02): the flag is accepted so +// the CLI is stable from M1, and a +// structured warn explains that replay +// recording is not implemented yet +// (never silent — CORE-008) +// +// Exit codes (documented, stable for CI grepping): +// 0 the run completed (the requested ticks reached; the summary +// line carries the loop accounting) +// 1 the engine run reported a failure Status (a failed frame — +// the engine still shut down, CONC-006) +// 2 usage, IO, config-parse, or engine-create error (the message +// carries the NFR-13.3 5-field error text where one applies) +// +// The one-line summary goes to stdout (machine-greppable, detcheck +// precedent): +// +// laige-run headless ticks= dropped_ticks= +// dropped_frames= status=ok| +// +// Headless invariants (ARCH-003, verified by the include-graph lint +// — NFR-8.11): this binary and everything it links (laige-sim, +// laige-core) touch no GL, window, audio, or input API; the +// PresentationSnapshot it drives is the headless-compatible +// presentation state (positions + alpha), not a GPU. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/json.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim/engine.h" +#include "laige/sim/game_loop.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; + +// NFR-13.3 5-field grammar for the replay stub (stable text; the log +// path is a structured field — LOG-005, never raw message text). +inline constexpr const char* kReplayDeferredMessage = + "replay_deferred | the --replay flag was accepted but replay " + "recording is not implemented | replay recording lands with " + "M1-DET-02 (M1-HEAD-01 wires only the flag, keeping the CLI " + "stable) | remove --replay, or wait for M1-DET-02 | " + "docs/api/engine.md"; + +void printUsage(std::FILE* out) { + std::fprintf(out, + "Usage: laige-run --headless [--ticks N] " + "[--replay ]\n" + "\n" + " --headless run the engine headless with the " + "given\n" + " JSON config (required; the windowed\n" + " mode is M2)\n" + " --ticks N run until N completed ticks (N = 0\n" + " or omitted: the server form — run\n" + " until the process ends)\n" + " --replay STUB (M1-DET-02): accepted; replay\n" + " recording is not implemented yet\n" + " --help, -h this help\n" + "\n" + "Exit codes: 0 = ok, 1 = engine run failure, 2 = usage / IO / " + "config error.\n"); +} + +// True when `text` parses as an unsigned 64-bit decimal integer +// (digits only, no overflow); stores the value in `out` on success. +bool parseTicks(std::string_view text, std::uint64_t* out) { + if (text.empty()) return false; + for (const char c : text) { + if (!std::isdigit(static_cast(c))) return false; + } + errno = 0; + char* end = nullptr; + const unsigned long long v = std::strtoull(text.data(), &end, 10); + if (errno == ERANGE || end == nullptr || *end != '\0') return false; + *out = static_cast(v); + return true; +} + +// 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 = std::fopen(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) { + bool headless = false; + std::string configPath; + std::uint64_t maxTicks = 0; + std::string replayPath; + + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--headless") { + if (i + 1 >= argc) { + std::fprintf(stderr, "laige-run: --headless needs a config path\n"); + printUsage(stderr); + return 2; + } + headless = true; + configPath = argv[++i]; + } else if (arg == "--ticks") { + if (i + 1 >= argc || !parseTicks(argv[++i], &maxTicks)) { + std::fprintf(stderr, "laige-run: --ticks needs a non-negative " + "integer\n"); + printUsage(stderr); + return 2; + } + } else if (arg == "--replay") { + if (i + 1 >= argc) { + std::fprintf(stderr, "laige-run: --replay needs a log path\n"); + printUsage(stderr); + return 2; + } + replayPath = argv[++i]; + } else if (arg == "--help" || arg == "-h") { + printUsage(stdout); + return 0; + } else { + std::fprintf(stderr, "laige-run: unknown option '%s'\n", + arg.c_str()); + printUsage(stderr); + return 2; + } + } + if (!headless || configPath.empty()) { + std::fprintf(stderr, "laige-run: --headless is required " + "(the windowed mode is M2)\n"); + printUsage(stderr); + return 2; + } + + // The replay stub (M1-DET-02): accepted, WARNED, ignored — never + // silent (CORE-008). + if (!replayPath.empty()) { + LAIGE_LOG_WARN("replay", "replay_deferred", kReplayDeferredMessage, + laige::log::field("log", replayPath)); + } + + std::string document; + const laige::Status readStatus = readConfigFile(configPath, &document); + if (readStatus.isError()) { + 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; + } + const laige::Result config = + laige::parseEngineConfig(parsed.value()); + if (config.isError()) { + std::fprintf(stderr, "laige-run: config: %s\n", + laige::errorText(config.error())); + return 2; + } + laige::Result engineResult = + laige::Engine::create(config.value()); + if (engineResult.isError()) { + std::fprintf(stderr, "laige-run: engine: %s\n", + laige::errorText(engineResult.error())); + return 2; + } + laige::Engine engine = std::move(engineResult).takeValue(); + const laige::Status runStatus = + engine.run_headless(maxTicks, laige::kDefaultMaxCatchUpTicks); + const laige::GameLoopStats stats = engine.stats(); + std::fprintf(stdout, + "laige-run headless ticks=%llu dropped_ticks=%llu " + "dropped_frames=%llu status=%s\n", + static_cast(stats.ticks), + static_cast(stats.droppedTicks), + static_cast(stats.droppedFrames), + runStatus.ok() ? "ok" : laige::errorName(runStatus.error())); + // The run always ends in the ordered shutdown (CONC-006); this + // second call exercises the idempotency (the M1-HEAD-01 test). + engine.shutdown(); + return runStatus.ok() ? 0 : 1; +} From 26bc283b0b6150b366b637c59c3f1b104c180c29 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 11:32:56 +0200 Subject: [PATCH 2/2] [M1-HEAD-01] Change log: fill in commit + PR reference (e80ccc8 / PR #31) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 5a58f72..e4d0ce6 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -207,7 +207,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-SYS-03 | `a63d6b9` | Per-system timing + budget enforcement (PRD §9.3 G-R5; FR-11.1/11.2, FR-12.3; M1-SYS-03 scope, nothing else): `World::runSystems` now times each system's own run (the M0-CORE-08 `TimeIt` steady_clock scope around the run function — two steady_clock reads per system, the context built outside the window) and hands the sample to `World::checkSystemBudget` (new `src/laige-sim/system_timing.cpp`): it records into the system's rolling window — a fixed-capacity `Histogram` (`kSystemTimingWindowSamples` = 64 samples ≈ 1.1 s at 60 Hz; O(1) record, no allocation, drops the OLDEST on overflow, `totalRecorded()` keeps counting; the window rolls across TICKS — `beginFrame()` does not touch it) — and enforces the declared budget: `measured > 1× budget` → `system/budget_overrun` (Warn), `measured >= 3× budget` (`kBudgetCriticalMultiplier`, PRD "over 3× → error event") → `system/budget_critical` (Error); a 3× run fires BOTH in the same tick. Both events: NFR-13.3 5-field grammar (build-stable message text; dynamic values as structured fields `system`/`id`/`measured_ms`/`budget_ms`/`p99_ms`/`window_samples` — never message text), rate-limited per (subsystem, event, severity) with the 1 s window (LOG-004; the `rate_limited` summary carries the suppressed count), and count in `SystemTimingStats` (`warns`/`errors`) even when suppressed; the p99 comes from one cold O(W log W) `stats()` pass (no allocation) only while the breach persists. An over-budget system is STILL RUN — observation and reporting, never an execution gate (FR-12.3). New public API (additive): `SystemTimingStats` (runs/lastMs/warns/errors), `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats(SystemId)` (Result; O(1) pure query; invalid id or moved-from → `InvalidArgument`, no warn — the `World::system` precedent) and `World::systemTimingWindow(SystemId)` (const Histogram* — the M1-PROF-02 frame graph's budgetCheck feed; nullptr for invalid); `detail::SystemTimingRecord` (unique_ptr Histogram — the Histogram has no default ctor — + the cheap scalars) in a fixed kMaxSystems table parallel to the registry (allocated in `create()` even for zero-capacity worlds, travels with the world on move, survives `clear()`); `World::checkSystemBudget` (private hook, called per system per tick). Determinism: measured times are DIAGNOSTIC only (ARCH-009) — they never enter authoritative state, hashes, or replays. Hot path: two clock reads + one ring write + two comparisons per system per tick — no allocation, no logging on success (the `SchedulingAndTicksAllocateNothing` window still proves 0 allocs over 100 ticks). New `SystemTiming` suite (8 tests) + CTest entry `system_timing` (the step's Verify command; TSan property list): healthy ticks log nothing + track stats (runs/lastMs/window count/totalRecorded); over-budget synthetic system warns at the documented multiplier (1 warn, measured ≥ 6.5 ms against a 5 ms budget; second tick rate-limited; shutdown `rate_limited` summary `suppressed` = 1); critical synthetic system (1 ms budget, 7 ms burn) fires the warn THEN the error in one tick; rolling window drops oldest (5W-sample fast/slow/fast phases with min/max/p99 bounds + machine-greppable `system-timing window` line); NFR-13.3 grammar check (5-field split on `" | "`, fields[0] = event, doc anchor `docs/api/system_timing.md`; the second over-budget system's warn is rate-suppressed and summarized at shutdown); query validation (id 0 / above count / moved-from → `InvalidArgument`/nullptr; no-systems world → 0-count stats ok); state travels with move (5 ticks pre-move, moved world keeps stats + window, moved-from queries fail); zero-allocation window (test-only operator-new counter, non-sanitizer trees only — 100 ticks × 2 systems → `allocs=0`, machine-greppable `system-timing-zeroalloc` line). Verified: `ctest -R system_timing` green + full suite 43/43 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang`, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (28 source files, 1/10 vendored deps), `laige-api.json` regenerated (496 → 505 symbols; +9: `SystemTimingStats` + 4 members, `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats`, `World::systemTimingWindow`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/system_timing.md` (measurement scope, the rolling window, the thresholds + event fields, the profiler feed, the ARCH-009 determinism scope, the Performance section, misuse warnings) + cross-refs in `docs/api/scheduler.md`, `docs/api/system_registry.md`, `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-01 | `30f3013` | Fixed-timestep game loop core (FR-1.1, ARCH-002, PRD §10.2/§10.3; M1-LOOP-01 scope, nothing else): new `GameLoop` (public header `src/laige-sim/include/laige/sim/game_loop.h`, implementation `src/laige-sim/game_loop.cpp`) — the accumulator loop that advances the simulation in INTEGER ticks, decoupled from the presentation frame cadence: `GameLoop::create(world, schedule, options)` validates the typed config (first failure wins; every rejection = `InvalidArgument` + one rate-limited warn, FR-12.3/CORE-008 — `loop/tick_rate_invalid` for `tickRateHz` outside 20–120 (`kMinTickRateHz`/`kDefaultTickRateHz` = 60 / `kMaxTickRateHz`), `loop/catchup_invalid` for `maxCatchUpTicks == 0` (default `kDefaultMaxCatchUpTicks` = 5 — bounds per-frame work, not rate)) and holds non-owning world/schedule views (both outlive the loop; one live loop per world); `frame()` is the hot path (one clock read, a few integer ops, up to `maxCatchUpTicks` BOUNDED `runSystems` dispatches — PERF-002; no allocation, no logging on success — PERF-003/LOG-003) and runs exactly `min(due − ticksRun, maxCatchUpTicks)` ticks where `due(now) = floor(elapsedNs × rate / 10⁹)` is computed in EXACT integer arithmetic (the seconds/sub-seconds split keeps every product overflow-free; no floating point, no rounding drift — ARCH-010) and the unrun remainder is re-derived from the clock every frame (no stored accumulator state: a synthetic 10 s clock at 60 Hz yields EXACTLY 600 ticks — a float ms accumulator floors to 599); the first frame establishes the start reference (zero ticks); `beginFrame()` is driven once per FRAME (the entity.h contract: the G-R3/G-R4 per-frame windows are per presentation frame — a catch-up frame of N ticks counts against one per-frame budget, the documented overload signal) and `runSystems` once per tick; overload: when `want > maxCatchUpTicks` the frame runs exactly `maxCatchUpTicks` and DROPS exactly `want − maxCatchUpTicks` (counted in `droppedTicks`/`droppedFrames` — never silent) with one rate-limited `loop/tick_dropped` warn (NFR-13.3 5-field build-stable message; structured fields `dropped`/`total_dropped`/`max_catch_up`/`tick_rate_hz`; one event per rate window + the `rate_limited` summary at shutdown — LOG-004), and the per-frame work stays bounded so the accumulator never grows unboundedly (PERF-008 backpressure); failure: a stale/malformed schedule surfaces the `runSystems` `InvalidArgument` (`system/schedule_stale`/`schedule_invalid` — the loop adds no event), a failed tick is not counted (its system phase did not complete; no system runs in a failed frame — validation precedes dispatch), the tick count freezes and each later frame fails the same way (rate-limited) until the caller recreates the loop with a recomputed schedule; a moved-from loop is STOPPED (`frame()` → `InvalidArgument`, no log, no world access — the moved-from-world pure-failure precedent) while move transfers the tick state (the factory's `Result` move); the clock source is `Options::nowNs` (nanoseconds on a monotonic epoch time base; `nullptr` → the headless monotonic `steady_clock` — the LoggerOptions::ClockFn precedent; a backward reading below the start reference asserts in debug / clamps in release — never UB); `GameLoopStats` (frames/ticks/droppedTicks/droppedFrames) is the since-construction profiler feed (pure O(1) query — the `World::stats()` precedent; the M1-PROF-01 feed). Determinism scope (ARCH-009/010): the tick sequence is a pure function of (clock readings, rate, cap) — integer-only, bit-identical across builds for the same clock sequence (replay state — M1-DET-01/02 include the tick counter in the hash); clock readings are wall-clock facts (the windowed clock M2-GL-02 / replay runner M1-DET-03 supply the canonical time base); frames/drops are presentation/diagnostic state, never authoritative. New `GameLoop` suite (11 tests) + CTest entry `game_loop` (the step's Verify command; TSan property list): config validation + warns + read-back (the 121 Hz repeat is rate-limited and summarized at shutdown — `suppressed = 1`), the first frame runs zero ticks, exact 600 ticks over a synthetic 10 s clock (400 steps of 16666667 ns + 200 of 16666666 ns = 10¹⁰ ns; machine-greppable `game-loop exact` line), the overload drops EXACTLY 8/16/24 (48 total) over three 10-tick demands against a cap of 2 and logs once per episode (the NFR-13.3 grammar check + the `rate_limited` summary `suppressed = 2`; machine-greppable `game-loop drops` line), the healthy cadence runs 120 ticks / zero drops / silent with one `runSystems` dispatch per tick (the M1-SYS-03 feed tracks the ticks exactly), a stale schedule freezes the tick count and surfaces the `Status` (the `system/schedule_stale` warn rate-limited), a backward clock jump (release clamps to the start reference — no tick, no new event; debug asserts — forked SIGABRT child, POSIX jobs), the default `steady_clock` drives real frames (50 ms sleep → ≥ 3 ticks at 60 Hz), move transfers the state and stops the source (the stopped loop's `frame()` → `InvalidArgument`, no log, world untouched), and the zero-allocation window (300 frames × 2 ticks = 600 ticks, zero drops → `allocs = 0` — the test-only operator-new counter, non-sanitizer trees; machine-greppable `game-loop-zeroalloc` line; the sanitizer trees prove it leak-free). Verified: `ctest -R game_loop` green + full suite 44/44 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (30 source files, 1/10 vendored deps), `laige-api.json` regenerated (505 → 530 symbols; +25: `GameLoop` + members, `Options` + 3 fields, `GameLoopStats` + 4 fields, `kMinTickRateHz`/`kDefaultTickRateHz`/`kMaxTickRateHz`/`kDefaultMaxCatchUpTicks`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/game_loop.md` (the two cadences, the exact due computation, config + validation, the overload behavior, the beginFrame wiring, the failure behavior, the determinism scope, the profiler feed, the Performance section, misuse warnings) + cross-refs in `docs/api/system_timing.md`, `include/laige/sim/system.h` (the scheduler sketch now references `GameLoop`), `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | -| 2026-09-15 | M1-HEAD-01 | — | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | +| 2026-09-15 | M1-HEAD-01 | `e80ccc8` / PR #31 | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | ---