diff --git a/docs/README.md b/docs/README.md index 15bcf7a..f054940 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,9 @@ M1-ECS-02: the component type registry; M1-ECS-03: archetype SoA component storage; M1-ECS-04: the query API + iteration legality; M1-ECS-05: the deterministic iteration contract; M1-ECS-06: the ECS guardrails G-R3/G-R4; M1-ECS-07: the ECS stress + memory -accounting suite; M1-SYS-01: the system registry). +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). 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. @@ -70,6 +72,11 @@ still to land. (`system/budget_overrun` warn, `system/budget_critical` error), and the `World::systemTimingStats`/`systemTimingWindow` profiler feed (M1-SYS-03; `laige-sim`). +- [Fixed-timestep game loop core](api/game_loop.md) — the `GameLoop` + accumulator loop: integer ticks at a validated 20–120 Hz rate + (default 60), the exact due computation, the bounded catch-up with + the `loop/tick_dropped` overload warn (drop, never silent), and the + `GameLoopStats` profiler feed (M1-LOOP-01; `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, @@ -161,7 +168,8 @@ still to land. [iteration_order.md](api/iteration_order.md), [system_registry.md](api/system_registry.md), [scheduler.md](api/scheduler.md), - [system_timing.md](api/system_timing.md).) + [system_timing.md](api/system_timing.md), + [game_loop.md](api/game_loop.md).) ## Related diff --git a/docs/api/game_loop.md b/docs/api/game_loop.md new file mode 100644 index 0000000..0f7573d --- /dev/null +++ b/docs/api/game_loop.md @@ -0,0 +1,217 @@ +# Fixed-timestep game loop core (`GameLoop`, M1-LOOP-01) + +The M1 game loop's fixed-timestep core (M1-LOOP-01; PRD FR-1.1, +ARCH-002, PRD §10.2/§10.3; AGENTS CORE-005/008, PERF-002/003, +ARCH-009/010): advances the simulation in **integer ticks** at a +fixed, validated rate (default 60 Hz, 20–120 Hz), decoupled from the +presentation frame cadence, and runs the world's scheduled systems +once per tick. Public header: +`src/laige-sim/include/laige/sim/game_loop.h` (`GameLoop`, +`GameLoop::Options`, `GameLoopStats`, the range constants, the full +contract); implementation: `src/laige-sim/game_loop.cpp`. Unit suite: +`ctest -R game_loop` (`tests/laige-sim/game_loop_tests.cpp`). + +The loop is the first consumer of the M1 system framework: it owns +the `beginFrame()`/`runSystems()` cadence the scheduler docs sketch +("M1-LOOP-01 owns this" — `include/laige/sim/system.h`): + +```cpp +World world = ...; // M1-ECS +world.registerSystem(...); // M1-SYS-01 +SystemSchedule sched; +world.scheduleSystems(sched); // M1-SYS-02 + +GameLoop loop = GameLoop::create(world, sched, opts).value(); +while (running) { + if (!loop.frame().ok()) { /* setup error: recreate the loop */ } + // presentation state for this frame: loop.currentTick(), ... +} +``` + +## The two cadences (FR-1.1, ARCH-002) + +The loop separates two clocks: + +- **Presentation frames** — the caller invokes `frame()` once per + presentation frame (the headless engine run loop from M1-HEAD-01, + the windowed frame pipeline from M2). A frame is a presentation + boundary; it carries no simulation time. +- **Simulation ticks** — the simulation advances in **integer ticks** + at `Options::tickRateHz`: one tick per `1/tickRateHz` seconds, all + simulation time in integer ticks (PRD §10.3). The render frame rate + never enters the simulation (ARCH-002): frames faster than the tick + rate run zero ticks, slower frames run several (catch-up). + +## The exact due computation (no floating point, ARCH-010) + +The accumulator is `due(now) − ticksRun`, where + +``` +due(now) = floor(elapsedNs × tickRateHz / 10⁹) +``` + +is evaluated in **pure integer arithmetic** — +`(elapsedNs / 10⁹) × rate + (elapsedNs mod 10⁹) × rate / 10⁹` — so +no intermediate product overflows and no rounding drift accumulates. +The remainder (the unrun due ticks) needs no stored state: it is +re-derived from the clock on every frame. A synthetic 10 s clock at +60 Hz yields **exactly 600 ticks** (the `ExactTicksOverTenSeconds` +test; a floating ms accumulator floors to 599 over the same +sequence). + +## Configuration and validation (FR-1.1, API-006) + +| Field | Range | Default | Reject (all: `InvalidArgument` + one rate-limited warn) | +|---|---|---|---| +| `tickRateHz` | 20–120 Hz (`kMinTickRateHz`–`kMaxTickRateHz`) | `kDefaultTickRateHz` (60) | `loop/tick_rate_invalid` (field `tick_rate_hz`) | +| `maxCatchUpTicks` | ≥ 1 | `kDefaultMaxCatchUpTicks` (5) | `loop/catchup_invalid` (field `max_catch_up`) | + +The clock source is `Options::nowNs` — a function returning +nanoseconds on a monotonic epoch time base; `nullptr` uses the +headless monotonic clock (`steady_clock`). A synthetic clock (tests) +or the M2 windowed clock (M2-GL-02) supplies its own. + +`maxCatchUpTicks` bounds **per-frame work**, not rate: at 60 Hz one +catch-up frame may run at most 5 ticks (~83 ms of simulation time). +A healthy 30 Hz machine runs 2 ticks per frame — no drops; a drop +fires only when a frame exceeds `(maxCatchUpTicks + 1)` ticks of +simulation time, i.e. a real overload. + +## Overload behavior: drops, never silent (FR-12.3, PERF-008) + +When a frame's due-tick demand (`want = due − ticksRun`) exceeds the +max catch-up limit, the frame runs exactly `maxCatchUpTicks` ticks +and the rest are **dropped** — exactly `want − maxCatchUpTicks` +of them — counted and logged (the `OverloadDropsExactlyTheDocumentedAmountAndLogsOnce` +test pins the amounts: demands of 10/18/26 with a cap of 2 drop +8/16/24, total 48): + +| Condition | Event | Severity | +|---|---|---| +| `want > maxCatchUpTicks` (one frame) | `loop/tick_dropped` | Warn | + +Event fields (structured, never message text — the NFR-13.3 +5-field message grammar is build-stable): `dropped` (this frame's +amount), `total_dropped` (since construction), `max_catch_up`, +`tick_rate_hz`. The event is rate-limited per (subsystem, event, +severity) by the logging facade (LOG-004): a sustained overload logs +**once per episode** (one event per rate window) plus a +`rate_limited` summary at shutdown — never a log storm. The loop's +counters keep counting every drop even while the event is suppressed +(observable state, never silent). + +The frame work stays **bounded** by `maxCatchUpTicks` (PERF-002: no +unbounded loop; PERF-008: backpressure). After a drop, the unrun +demand is the sub-tick remainder plus the next frame's time, so the +accumulator never grows unboundedly: a permanently overloaded machine +drops a bounded number of ticks per frame and the degradation is +always visible. + +## beginFrame wiring (M1-ECS-06 guardrails) + +`frame()` drives `World::beginFrame()` exactly **once per frame** — +before the frame's ticks — and `World::runSystems(schedule)` once per +tick (the entity.h contract: "the owning loop drives it once per +frame"). The G-R3/G-R4 per-frame windows are therefore per +**presentation frame**: a catch-up frame running N ticks counts all N +ticks of churn against one per-frame budget (the guardrail flags the +heavier work — the documented overload signal). Before this loop +existed, the per-tick `beginFrame()` pattern in the scheduler docs was +the manual form; it remains the test form (one frame per tick). + +## Failure behavior (CORE-008) + +`frame()` returns the `runSystems` Status: + +- A stale or malformed schedule (a system registered after + scheduling, a hand-built schedule) is `InvalidArgument` — raised by + `runSystems` (`system/schedule_stale` / `system/schedule_invalid`); + the loop adds no event of its own. A failed tick is **not counted** + in `currentTick()` (its system phase did not complete), and no + system runs in a failed frame (validation precedes dispatch — the + world is untouched). +- The loop is otherwise untouched: the tick count freezes and each + later `frame()` re-derives the demand from the clock and fails the + same way (rate-limited) until the caller recreates the loop with a + recomputed schedule. +- A **moved-from loop is stopped**: `frame()` returns `InvalidArgument` + without touching the world and without logging (the moved-from-world + pure-failure precedent, entity.h). Move transfers the tick state — + the source becomes a valid but stopped loop (the `World` move + precedent: the source is left in a well-defined state). + +## Determinism scope (ARCH-009/010) + +The tick sequence — `currentTick()` after any frame sequence — is a +**pure function of (the clock readings, tickRateHz, maxCatchUpTicks)**: +integer arithmetic only, no floating point, no randomness, no +platform state. Two runs over the same clock sequence produce +bit-identical tick counts (the tick counter is replay state — +M1-DET-01/02 include it in the state hash). The clock readings +themselves are wall-clock facts (platform-sensitive, ARCH-009): with +the default steady clock, cross-platform identity over real time is +not promised; the windowed clock (M2-GL-02) and the replay runner +(M1-DET-03) supply the canonical time base. `frames` / +`droppedTicks` / `droppedFrames` are presentation/diagnostic state +(the frame cadence is the caller's, not the simulation's) — never +authoritative. + +## Profiler feed (M1-PROF-01) + +`GameLoop::stats()` returns the since-construction `GameLoopStats` +snapshot (`frames`, `ticks`, `droppedTicks`, `droppedFrames`): a pure +O(1) query, no allocation (the `World::stats()` / +`SystemTimingStats` precedent). The tick-time percentiles +(p50/p95/p99/mean/min/max over a rolling window) land with the +profiler core (M1-PROF-01), which consumes this feed plus the +per-system windows (M1-SYS-03). + +## Performance (DOC-004) + +- **Per frame (hot path):** one clock read, a few integer ops (the + exact due computation), and up to `maxCatchUpTicks` `runSystems` + dispatches — **bounded by the config** (PERF-002: no unbounded + loop). No allocation, no logging on the success path (PERF-003, + LOG-003); the `HealthyFramesAllocateNothing` test asserts + `allocs == 0` over 300 catch-up frames (600 ticks, zero drops). +- **Per tick:** the loop bookkeeping is a few integer ops (the due + computation, the cap compare, the counter bump) — no allocation, no + lock, no I/O — negligible against the 3 ms `sim_tick_avg` budget + (PRD §8.1; `budgets.json`) next to the `runSystems` dispatch cost, + which M1-SYS-03 measures. +- **Cold path (overload):** one rate-limited `tick_dropped` warn with + field construction — only while a frame exceeds the catch-up bound. +- **Complexity:** `frame()` is O(maxCatchUpTicks × per-tick system + work); `currentTick()`/`tickRateHz()`/`maxCatchUpTicks()`/`stats()` + are O(1). No operation scales with entities or components beyond + the systems' own declared cost (PERF-007). + +## Threading (CONC-001, PRD §10.2) + +The loop has exactly **one owner thread**: `frame()` and the queries +run on the world's single owner thread, strictly interleaved with the +systems' execution (API-004: the system phase). The non-owning +world/schedule views point at single-owner state and are never +dereferenced off-thread. + +## Misuse warnings + +- **One live loop per world.** Two live loops on one world double-tick + the simulation (two `runSystems` per tick, two `beginFrame()` per + frame) — an API-004 violation the engine cannot detect; the + moved-from stop is what makes the factory's move safe. +- **The clock source must be monotonic** (`steady_clock` is by + definition; a synthetic test clock advances, never rewinds). A + backward reading **below the start reference** asserts in debug + builds and clamps to the start reference in release (the frame + contributes no time — never undefined behavior). +- **The world and the schedule must outlive the loop** (non-owning + views). Recomputing the schedule after a registration change + without recreating the loop leaves the old schedule stale — + `frame()` then fails every frame (`system/schedule_stale`). +- **`maxCatchUpTicks` is not a rate knob**: it bounds per-frame work; + it cannot make the simulation run faster. +- **A dropped tick is a lost simulation step** (documented overload + degradation, FR-12.3): logged and counted, but the game continues + without it — the fix is the tick rate, the per-tick work, or the + catch-up bound (the event's `{fix}` field). diff --git a/docs/api/system_timing.md b/docs/api/system_timing.md index fb11cef..bbbe2b7 100644 --- a/docs/api/system_timing.md +++ b/docs/api/system_timing.md @@ -15,13 +15,14 @@ per-system measurement inside `World::runSystems` (`src/laige-sim/systems.cpp`). Unit suite: `ctest -R system_timing` (`tests/laige-sim/system_timing_tests.cpp`). -The loop (M1-LOOP-01) runs the schedule every tick, and the timing -is automatic — nothing per system is wired by the game: +The loop (M1-LOOP-01, `GameLoop`) runs the schedule every tick, and +the timing is automatic — nothing per system is wired by the game: ```cpp -for (tick) { - world.beginFrame(); - world.runSystems(schedule); // measures every system (M1-SYS-03) +GameLoop loop = GameLoop::create(world, sched, opts).value(); +while (running) { + loop.frame(); // beginFrame once per frame; runSystems per tick — + // measures every system (M1-SYS-03) // ... read the feed, e.g. for the frame graph: // auto st = world.systemTimingStats(id); } diff --git a/laige-api.json b/laige-api.json index 706b8ff..2a8b6ca 100644 --- a/laige-api.json +++ b/laige-api.json @@ -15,6 +15,7 @@ "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/entity.h", + "src/laige-sim/include/laige/sim/game_loop.h", "src/laige-sim/include/laige/sim/query.h", "src/laige-sim/include/laige/sim/system.h" ], @@ -483,6 +484,31 @@ {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 667, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 668, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 673, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, + {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 226, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, + {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 229, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, + {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 231, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultMaxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 241, "signature": "inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5", "summary": "The default max catch-up ticks per frame (CORE-005): at the default 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of simulation time) before the frame's demand is dropped and logged. A healthy machine runs 1 tick per frame (frames slower than the tick rate run 2–3, still under the bound); a drop fires only when a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — a real overload, not a cadence difference. Raising it is typed configuration (an ADR if the engine default changes), not a knob.", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 253, "signature": "struct GameLoopStats", "summary": "The since-construction accounting snapshot of one GameLoop (M1-PROF-01 feed; a plain value, the EntityStats/ SystemTimingStats precedent):", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 254, "signature": "std::uint64_t frames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 255, "signature": "std::uint64_t ticks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 256, "signature": "std::uint64_t droppedTicks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 257, "signature": "std::uint64_t droppedFrames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop", "kind": "class", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 263, "signature": "class GameLoop", "summary": "The fixed-timestep accumulator loop (M1-LOOP-01): see the header preamble for the accumulator, configuration, overload, beginFrame, failure, determinism, performance, and threading contracts.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 268, "signature": "struct Options", "summary": "The typed loop configuration (API-006): the tick rate (20–120 Hz, validated at construction), the max catch-up ticks per frame (>= 1, validated), and the clock source.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 271, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; default kDefaultTickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::maxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 274, "signature": "std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}", "summary": "The max ticks one frame may run before its due-tick demand is dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::ClockFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 280, "signature": "using ClockFn = std::int64_t (*)()", "summary": "The clock source: nanoseconds since a fixed monotonic epoch (the same time base as the default clock below). nullptr uses the headless monotonic clock (steady_clock); a test or the M2 windowed clock supplies its own (injectable for tests — the LoggerOptions::ClockFn precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::nowNs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 281, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 293, "signature": "[[nodiscard]] static Result create(World& world, const SystemSchedule& schedule, Options options) noexcept", "summary": "Construct the loop on `world` running `schedule` (setup phase, after World::scheduleSystems — the schedule must describe the world's CURRENT registry, and both must outlive the loop). O(1); no allocation (the loop state is fixed scalars).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::frame", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 311, "signature": "[[nodiscard]] Status frame() noexcept", "summary": "Advance one presentation frame (the hot path; see the preamble \"Performance\"): read the clock, run the frame's due ticks (up to maxCatchUpTicks), drop the excess with a rate-limited warn.", "budget": "O(maxCatchUpTicks × per-tick system work); bounded, no allocation.", "experimental": false}, + {"name": "laige::GameLoop::currentTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 315, "signature": "[[nodiscard]] std::uint64_t currentTick() const noexcept", "summary": "The number of completed ticks (0 before the first; the first tick to complete is tick 1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::tickRateHz", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 318, "signature": "[[nodiscard]] std::uint32_t tickRateHz() const noexcept", "summary": "The configured tick rate (Hz). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::maxCatchUpTicks", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 322, "signature": "[[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept", "summary": "The configured max catch-up ticks per frame. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 327, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The since-construction accounting snapshot (GameLoopStats). O(1), no allocation, no side effects (a pure query, the World::stats() precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 333, "signature": "GameLoop(GameLoop&& other) noexcept", "summary": "Move transfers the tick state; the source becomes a valid but STOPPED loop (frame() returns InvalidArgument, no log — see the preamble \"Failure behavior\"; the World moved-from precedent: the source is left in a well-defined state).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 334, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 335, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 336, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, @@ -490,39 +516,39 @@ {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 376, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, - {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 377, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 382, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 384, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 387, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 395, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, - {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 402, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, - {"name": "laige::kSystemTimingWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 409, "signature": "inline constexpr std::uint32_t kSystemTimingWindowSamples = 64", "summary": "The per-system rolling window capacity (M1-SYS-03; CORE-005): the number of measured run times (ms) kept per system in the rolling histogram — ~1.1 s of samples at the default 60 Hz tick rate. The window is fixed at world construction (a setup-path allocation); raising the capacity is an ADR, not a knob.", "budget": null, "experimental": false}, - {"name": "laige::kBudgetCriticalMultiplier", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 415, "signature": "inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3", "summary": "The over-budget multiplier that escalates the budget_overrun warn into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: \"over 3× → error event\"; CORE-005). The warn fires strictly above 1× the declared budget; the error at 3× or more.", "budget": null, "experimental": false}, - {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 421, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, - {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 436, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, - {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 437, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 438, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 439, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 440, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 451, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 452, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 453, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 463, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 465, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 474, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, - {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 491, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, - {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 494, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 504, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 505, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 506, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 510, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 514, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::SystemTimingStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 536, "signature": "struct SystemTimingStats", "summary": "One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the cheap per-frame snapshot the profiler (M1-PROF-01) and the frame graph (M1-PROF-02) pull through World::systemTimingStats(id) — O(1), no allocation, no side effects. The window SAMPLES are not here (the rolling histogram is read cold through World::systemTimingWindow(id) — its stats() is O(n log n)). All counters are since-construction; the window rolls across ticks (kSystemTimingWindowSamples, not per-frame).", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::runs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 538, "signature": "std::uint64_t runs{}", "summary": "Measured runs of the system since world construction.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::lastMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 544, "signature": "double lastMs{}", "summary": "The measured time (ms) of the most recent run (0 before the first run). A run shorter than the platform's steady_clock tick measures as exactly 0.0 ms — a legitimate sub-resolution reading (wall-clock resolution is platform-sensitive; ARCH-009), not a failure state.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::warns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 546, "signature": "std::uint32_t warns{}", "summary": "The system/budget_overrun warns issued since construction.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::errors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 549, "signature": "std::uint32_t errors{}", "summary": "The system/budget_critical error events issued since construction.", "budget": null, "experimental": false}, - {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 573, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "experimental": false} + {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 377, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, + {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 378, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 383, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 385, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 388, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 396, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, + {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 403, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, + {"name": "laige::kSystemTimingWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 410, "signature": "inline constexpr std::uint32_t kSystemTimingWindowSamples = 64", "summary": "The per-system rolling window capacity (M1-SYS-03; CORE-005): the number of measured run times (ms) kept per system in the rolling histogram — ~1.1 s of samples at the default 60 Hz tick rate. The window is fixed at world construction (a setup-path allocation); raising the capacity is an ADR, not a knob.", "budget": null, "experimental": false}, + {"name": "laige::kBudgetCriticalMultiplier", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 416, "signature": "inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3", "summary": "The over-budget multiplier that escalates the budget_overrun warn into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: \"over 3× → error event\"; CORE-005). The warn fires strictly above 1× the declared budget; the error at 3× or more.", "budget": null, "experimental": false}, + {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 422, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, + {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 437, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, + {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 438, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 439, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 440, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 441, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 452, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 453, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 454, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 464, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 466, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 475, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, + {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 492, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, + {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 495, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 505, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 506, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 507, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 511, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 515, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemTimingStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 537, "signature": "struct SystemTimingStats", "summary": "One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the cheap per-frame snapshot the profiler (M1-PROF-01) and the frame graph (M1-PROF-02) pull through World::systemTimingStats(id) — O(1), no allocation, no side effects. The window SAMPLES are not here (the rolling histogram is read cold through World::systemTimingWindow(id) — its stats() is O(n log n)). All counters are since-construction; the window rolls across ticks (kSystemTimingWindowSamples, not per-frame).", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::runs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 539, "signature": "std::uint64_t runs{}", "summary": "Measured runs of the system since world construction.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::lastMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 545, "signature": "double lastMs{}", "summary": "The measured time (ms) of the most recent run (0 before the first run). A run shorter than the platform's steady_clock tick measures as exactly 0.0 ms — a legitimate sub-resolution reading (wall-clock resolution is platform-sensitive; ARCH-009), not a failure state.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::warns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 547, "signature": "std::uint32_t warns{}", "summary": "The system/budget_overrun warns issued since construction.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::errors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 550, "signature": "std::uint32_t errors{}", "summary": "The system/budget_critical error events issued since construction.", "budget": null, "experimental": false}, + {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 574, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 7df2eb2..85aebc3 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -126,7 +126,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A ## Game loop -- [ ] **M1-LOOP-01 · Fixed-timestep core** +- [x] **M1-LOOP-01 · Fixed-timestep core** - **Refs:** FR-1.1 (default 60 Hz, 20–120 Hz configurable); ARCH-002; PRD §10.2 - **Depends:** M1-SYS-03, M0-CORE-08 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 23a4762..9e26cb3 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 | 10 | 🚧 in progress (M1-SYS-03) | +| M1 | 25 | 11 | 🚧 in progress (M1-LOOP-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** | **30** | | +| **Total** | **193** | **31** | | --- @@ -205,6 +205,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-SYS-01 | `115d28c` | System registry (FR-1.3: plain registered functions with declared time budgets and declared component I/O; M1-SYS-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/system.h` — `SystemId` (32-bit dense id from 1, registration order, per-world, deterministic — component.h id contract), `SystemDef` (name + `SystemFn` = `void(*)(World&, SystemContext&)` + `budgetMs` in ms as `fpx16_16` — exact, ADR 0002, keeps the future sim source scan float-free), the `LAIGE_SYSTEM(Name, budget_ms)` macro (namespace scope: the plain function declaration + the `Name##Def` def variable — no class, no inheritance), `SystemContext` (per-tick world view; `each(fn, Read/Write tags...)` delegates to `World::each` — definition out-of-line in entity.h where World is complete), `Io` (the per-component declared I/O tag; a component appears at most once per system, any access combination — the I/O is a set, not a multiset, stored as disjoint read/write id sets; ascending-id enumeration order documented), `SystemInfo` (the def value copy + `declaresRead`/`declaresWrite`, the M1-SYS-02/M1-PROF-01 feed), `kMaxSystems = 256` (engine-level bound, CORE-005), `detail::SystemRecord` + the `IsIoTag`/`IsIoComponent`/`IoComponent` traits (class form — the api scanner parses class partial specializations; variable templates are an unsupported scanner construct); `World::registerSystem(def, Io<...>...)` (header-defined template, entity.h), `World::systemCount()`, `World::system(id)` (`systems.cpp`); the fixed record table is allocated in `create()` like the component registry, travels with the world on move, and survives `clear()`; validation (first failure wins; every failure one rate-limited structured warn + `Status` — FR-12.3/LOG-004): moved-from world → `InvalidArgument` (no warn, the registerComponent precedent), null/empty name → `system/name_invalid`, null run → `system/run_invalid`, budget ≤ 0 → `system/budget_invalid` (the budget must be explicit and positive), duplicate name → `system/duplicate` (the roadmap's named property), Io T not a component → compile error (static_assert), Io T unregistered in this world → `system/io_unregistered`, same component twice (any access) → `system/io_duplicate`, > kMaxSystems → `BudgetExhausted` + `system/budget_exhausted`; the def is value-copied into the record table: no allocation at registration (setup path — PERF-003); new `SystemRegistry` suite (25 tests, CTest entry `system_registry`, added to the TSan property list): registration, ids dense from 1, the def value copy, the I/O sets + zero-I/O pack, context delegation (write + read paths through the plain functions), every validation error, the kMaxSystems budget (257 distinct names), `system()` id validation, id stability across two worlds + registration-order-determines-ids (ARCH-010), move/clear/moved-from-world lifetime, the zero-alloc registration window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free), warn-once + `rate_limited` sink checks (`system/duplicate` name/existing_system_id fields, `system/budget_invalid` budget_raw field); docs: `docs/api/system_registry.md` (full contract + Performance section) linked from `docs/README.md`, `src/laige-sim/README.md` status updated (incl. the M1-ECS-06/07 lines), entity.h preamble + member docs carry the M1-SYS-01 note; `laige-api.json` regenerated (489 symbols; `api-real-tree` green); local Verify: `ctest -R system_registry` green on `build` (25/25 incl. the zero-alloc window), full suite 41/41 on `build`/`build-asan` (leak-free)/`build-tsan`/`build-clang`/`build-release`/`build-shared`, zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (27 source files, 1/10 vendored deps) | | 2026-09-14 | M1-SYS-02 | `84c5c06` | System scheduler (M1-SYS-02 scope, nothing else): the scheduler turns the M1-SYS-01 registry (registration order + declared depends_on + declared component I/O) into the per-tick execution order and runs the systems in it — `SystemSchedule` (the systemCount plus the dense SystemId order array), `World::scheduleSystems(SystemSchedule&) const` (setup phase; pure registry read; the STABLE topological sort of the registration order plus the depends_on edges — Kahn's algorithm with a min-id tie-break: repeatedly place the smallest unrun id whose dependencies are all placed, so a system only moves LATER, behind its dependencies, and no dependencies = exactly the registration order), and `World::runSystems(const SystemSchedule&)` (one sim tick's system phase: the systems run STRICTLY one at a time in schedule order on the world's single owner thread, a fresh non-owning SystemContext per system — PRD §10.2/API-004); `SystemDef` gains `dependsOn` (the raw comma-separated registration-name spec; nullptr/"" = none) and `LAIGE_SYSTEM(Name, budget_ms, Dep..., ...)` becomes variadic (the optional trailing names stringized verbatim into the spec — `LAIGE_SYSTEM(Health, 1, Spawner)` = spec "Spawner"); `kMaxSystemDependencies = 16` (the direct-dep bound, CORE-005 — a barrier is registration position, not a dependency list); `detail::DepSpecParse`/`DepSpecError`/`parseDepSpec`/`depSpecErrorName` (system.h; defined in systems.cpp — tokens point into the spec literal, no copy, no allocation); validation (first failure wins; every failure one rate-limited structured warn, subsystem `system`, + Status — FR-12.3): at REGISTRATION (the def-level form, before the duplicate-name check — def fields first): malformed spec (empty token/trailing comma, duplicate name, > 16 deps) → `system/dep_spec_invalid` (fields name/error); at SCHEDULING (normative order): unknown dependency name (first in ascending (system id, spec position)) → `system/dep_missing` (fields system/missing_dep/position; the token logged bounded to 64 chars — LOG-005), dependency cycle → `system/dependency_cycle` (ONE concrete cycle reported: the deterministic walk from the smallest remaining id following each system's first spec-listed dependency that is still remaining — a remaining system always has one, the Kahn invariant; the `cycle` field is the walk order, comma-joined, bounded to 256 chars — independents already scheduled are excluded), two systems both declaring Write of the same component in one tick (order-independent: the last write would silently win; first conflict in ascending component-id then writer-id) → `system/double_writer` (fields component_id/first_writer/second_writer); WARN ONLY (scheduling succeeds): a declared read that the computed order places BEFORE a declared write of the same component (the reader sees the previous tick's value; each (reader, writer, component) triple once, ascending component/reader/writer; fix advice in the message: declare depends_on or register the writer earlier) → `system/read_before_write` (fields reader/writer/component_id); at RUNNING: schedule.systemCount ≠ the current systemCount (registry changed since scheduling, or another world's schedule) → `system/schedule_stale` (fields scheduled_systems/current_systems), an order entry that is 0 / above the count / a duplicate id (hand-built schedule) → `system/schedule_invalid` (fields slot/id), empty schedule → ok and runs nothing; the success paths log nothing (LOG-003); determinism (ARCH-010): pure integer/string bookkeeping — no floating point, no randomness, no addresses in the order or the warning set (two worlds, two runs, two builds → bit-identical schedules + warning sequences); no allocation at scheduling or per tick (PERF-003 — all state fixed-size stack/world arrays); new `SystemScheduler` suite (26 tests, CTest entry `scheduler`, added to the TSan property list): registration order = execution order, forward/backward deps, the chain and the diamond (reversed spec list — the dependency set is orderless, the tie-break is the min id), the macro spec stringization (1-dep and 2-dep macro forms + the no-dep "" spec), running in scheduled order with state flow (writer before reader → the reader sees the fresh 0x1234; reader before writer → the stale value 0x9999 is observed), reader-before-writer warns (sink: reader/writer/component_id fields; schedule still succeeds), writer-before-reader is clean (the sink stays EMPTY — the success path logs nothing), double-writer rejected (sink: component_id/first_writer/second_writer + LOG-004 rate_limited summary suppressed=2 on shutdown), missing dependency (sink: system/missing_dep fields), the 2-cycle + the self-dependency (cycle [self]) + the cycle among independent systems (the reported cycle excludes the independents that scheduled first), spec validation (empty token, trailing comma, duplicate name, the 17-dep bound, whitespace trimming is legal — " TrimA , TrimB " resolves), the empty + moved-from world schedules and runs empty, the stale schedule is rejected (recompute → usable; nothing ran), the hand-built malformed schedules (duplicate id, id above the count) are rejected (nothing ran), the order bit-identical across two worlds with the same registrations (ARCH-010 memcmp over the order arrays), the known-answer pin (the fixed 5-system scenario WITH a forward edge: order B,A,C,D,E — machine-greppable `scheduler-order systems=5 fnv1a=0xaef3282f393ab332`, pinned in the test), and the zero-alloc window (100 ticks × 3 systems over 4 entities: schedule + every runSystems allocate nothing — the test-only operator-new counter, non-sanitizer trees; machine-greppable `scheduler-zeroalloc ticks=100 allocs=0`; the sanitizer trees prove it leak-free); docs: `docs/api/scheduler.md` (full contract + Performance section) linked from `docs/README.md` (the API list + the per-module laige-sim list, which gains the previously missing system_registry.md entry), `docs/api/system_registry.md` updated (the macro is variadic now, the validation table gains the dep_spec_invalid row, cross-refs to scheduler.md), `src/laige-sim/README.md` status updated, system.h/entity.h preambles + member docs carry the M1-SYS-02 note; no new source file (the scheduler lands in systems.cpp — the sim CMake comment updated); `laige-api.json` regenerated (496 symbols, +7: kMaxSystemDependencies, SystemDef::dependsOn, SystemSchedule + systemCount + order, World::scheduleSystems + World::runSystems; `api-real-tree` green); local Verify: `ctest -R scheduler` green on `build` (26/26 incl. the zero-alloc window), full suite 42/42 on `build`/`build-asan` (leak-free)/`build-tsan`/`build-clang`/`build-release`/`build-shared`, zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (27 source files, 1/10 vendored deps) | | 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. --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 443ebc2..f9afe03 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -34,9 +34,13 @@ # enforcement (the World::systemTimingStats/systemTimingWindow # queries and the World::checkSystemBudget hook that runSystems # calls per system per tick; the public types and contract live in -# include/laige/sim/system.h). +# include/laige/sim/system.h). M1-LOOP-01 adds game_loop.cpp: the +# fixed-timestep accumulator loop (GameLoop::create/frame/runOneTick +# — the exact-ticks due computation, the bounded catch-up run loop, +# the tick_dropped overload warn; the public types and contract live +# in include/laige/sim/game_loop.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp - systems.cpp system_timing.cpp) + systems.cpp system_timing.cpp game_loop.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 0822098..43b030e 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -66,5 +66,12 @@ G-R5 budget enforcement (the `system/budget_overrun` warn and measurement in `runSystems` (`systems.cpp`); API contract in [docs/api/system_timing.md](../docs/api/system_timing.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry -`system_timing`). The game loop (M1-LOOP) and the profiler land in -the remaining M1 steps; physics, input, and animation in M3. +`system_timing`). M1-LOOP-01 landed the fixed-timestep game loop +core — the `GameLoop` accumulator loop (integer ticks at a validated +20–120 Hz rate, the exact due computation, the bounded catch-up with +the `loop/tick_dropped` overload warn, the `GameLoopStats` profiler +feed; `include/laige/sim/game_loop.h`, `game_loop.cpp`; API contract +in [docs/api/game_loop.md](../docs/api/game_loop.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `game_loop`). +The profiler, determinism/replay, headless engine, and the remaining +M1 steps land next; physics, input, and animation in M3. diff --git a/src/laige-sim/game_loop.cpp b/src/laige-sim/game_loop.cpp new file mode 100644 index 0000000..6dd1254 --- /dev/null +++ b/src/laige-sim/game_loop.cpp @@ -0,0 +1,234 @@ +// laige-sim fixed-timestep game loop core (M1-LOOP-01; FR-1.1, +// ARCH-002, PRD §10.2). +// +// Implementation of the GameLoop members declared in +// include/laige/sim/game_loop.h — see that header (the member docs, +// the accumulator contract, the overload behavior, the beginFrame +// wiring, the failure and determinism scopes) and +// docs/api/game_loop.md for the full API contract. +// +// Hot-path cost (per frame): one clock read, a few integer ops (the +// exact due computation, the bounded run loop), and up to +// maxCatchUpTicks runSystems dispatches — no allocation and no +// logging on the success path (PERF-003, LOG-003). The tick_dropped +// warn is cold (an overload episode). + +#include "laige/sim/game_loop.h" // the GameLoop contract (this header) + +#include +#include +#include +#include + +#include "laige/logging.h" + +namespace laige { + +namespace { + +// Nanoseconds per second (the clock time base; CORE-005 named +// constant). +inline constexpr std::int64_t kNanosecondsPerSecond = 1000000000LL; + +// The stable subsystem name for game-loop events (LOG-001). +inline constexpr const char* kLoopSubsystem = "loop"; + +// The headless clock source (M1-LOOP-01): the monotonic steady_clock +// as nanoseconds since its epoch (the windowed clock arrives with +// M2-GL-02; the LoggerOptions::clock injection precedent supplies the +// test seam — Options::nowNs). +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* kTickDroppedMessage = + "tick_dropped | the frame's due-tick demand exceeded the max " + "catch-up tick budget | the frame took longer than max_catch_up " + "ticks of simulation time (sim work too heavy, a long frame, or a " + "spiking clock source) | reduce per-tick work, lower the tick " + "rate, or raise the max catch-up ticks through typed " + "configuration | docs/api/game_loop.md"; + +inline constexpr const char* kTickRateInvalidMessage = + "tick_rate_invalid | the configured tick rate is outside the " + "supported 20-120 Hz range | the tick rate is validated at loop " + "construction | pass a rate in the 20-120 Hz range (the default " + "is 60) | docs/api/game_loop.md"; + +inline constexpr const char* kCatchUpInvalidMessage = + "catchup_invalid | the configured max catch-up tick count is not " + "positive | a zero limit would never run a tick | pass a max " + "catch-up tick count of 1 or more (the default is 5) | " + "docs/api/game_loop.md"; + +// The number of complete ticks within elapsedNs at tickRateHz — +// floor(elapsedNs × rate / 10⁹) in EXACT integer arithmetic: the +// seconds part (elapsedNs / 10⁹) times the rate plus the sub-seconds +// remainder part (the remainder × rate < 10⁹ × 120, so no +// intermediate product overflows; the split is an identity — floor +// distributes over the seconds/remainder decomposition). No +// floating point (ARCH-010: the tick count is replay state). +std::uint64_t ticksDue(std::int64_t elapsedNs, std::uint32_t tickRateHz) { + const std::int64_t seconds = elapsedNs / kNanosecondsPerSecond; + const std::int64_t remainder = elapsedNs % kNanosecondsPerSecond; + return static_cast(seconds) * tickRateHz + + static_cast(remainder) * tickRateHz / + static_cast(kNanosecondsPerSecond); +} + +} // namespace + +Result +GameLoop::create(World& world, const SystemSchedule& schedule, + Options options) noexcept { + // Configuration validation (normative order, first failure wins; + // every failure one rate-limited structured warn — FR-12.3/CORE-008: + // never silent). + if (options.tickRateHz < kMinTickRateHz || + options.tickRateHz > kMaxTickRateHz) { + LAIGE_LOG_WARN(kLoopSubsystem, "tick_rate_invalid", + kTickRateInvalidMessage, + laige::log::field("tick_rate_hz", options.tickRateHz)); + return ErrorCode::InvalidArgument; + } + if (options.maxCatchUpTicks == 0) { + LAIGE_LOG_WARN(kLoopSubsystem, "catchup_invalid", kCatchUpInvalidMessage, + laige::log::field("max_catch_up", + options.maxCatchUpTicks)); + return ErrorCode::InvalidArgument; + } + return GameLoop(world, schedule, std::move(options)); +} + +GameLoop::GameLoop(World& world, const SystemSchedule& schedule, + Options options) noexcept + : world_(&world), + schedule_(&schedule), + options_(std::move(options)), + valid_(true), + started_(false), + startNs_(0), + frames_(0), + ticks_(0), + droppedTicks_(0), + droppedFrames_(0) {} + +GameLoop::GameLoop(GameLoop&& other) noexcept + : world_(other.world_), + schedule_(other.schedule_), + options_(std::move(other.options_)), + valid_(other.valid_), + started_(other.started_), + startNs_(other.startNs_), + frames_(other.frames_), + ticks_(other.ticks_), + droppedTicks_(other.droppedTicks_), + droppedFrames_(other.droppedFrames_) { + other.valid_ = false; // the source becomes a stopped loop +} + +GameLoop& GameLoop::operator=(GameLoop&& other) noexcept { + if (this != &other) { + world_ = other.world_; + schedule_ = other.schedule_; + options_ = std::move(other.options_); + valid_ = other.valid_; + started_ = other.started_; + startNs_ = other.startNs_; + frames_ = other.frames_; + ticks_ = other.ticks_; + droppedTicks_ = other.droppedTicks_; + droppedFrames_ = other.droppedFrames_; + other.valid_ = false; // the source becomes a stopped loop + } + return *this; +} + +Status GameLoop::frame() noexcept { + if (!valid_) { + // A moved-from loop is stopped (preamble "Failure behavior"): a + // pure-failure Status, no world access, no log (the + // moved-from-world precedent, entity.h). + return ErrorCode::InvalidArgument; + } + std::int64_t nowNs = + (options_.nowNs != nullptr) ? options_.nowNs() : steadyNowNs(); + if (!started_) { + // The first call establishes the start reference: zero ticks. + started_ = true; + startNs_ = nowNs; + } else { + // The clock source contract is MONOTONIC (steady_clock is by + // definition; the synthetic test clocks advance, never rewind). + // A backward reading is misuse: assert loudly in debug (S-9 + // style); in release, clamp to the start reference — the frame + // contributes no time (degraded, never undefined behavior). + assert(nowNs >= startNs_ && + "GameLoop clock source must be monotonic"); + if (nowNs < startNs_) nowNs = startNs_; + } + ++frames_; + if (started_ && nowNs == startNs_) return Status{}; // no time elapsed + const std::int64_t elapsedNs = nowNs - startNs_; + const std::uint64_t due = ticksDue(elapsedNs, options_.tickRateHz); + // Invariant: ticks_ <= due (a frame only adds min(want, cap) ticks, + // and due is monotone in the clock) — the want below is >= 0. + if (due <= ticks_) return Status{}; + const std::uint64_t want = due - ticks_; + const std::uint64_t toRun = + (want < options_.maxCatchUpTicks) ? want + : options_.maxCatchUpTicks; + for (std::uint64_t i = 0; i < toRun; ++i) { + const Status s = runOneTick(); + if (!s.ok()) return s; // failed tick: not counted, world untouched + } + if (want > options_.maxCatchUpTicks) { + // The frame's due demand exceeded the max catch-up bound: the + // unrun due ticks are dropped (preamble "Overload behavior") — + // exactly want − maxCatchUpTicks of them, counted and logged + // (rate-limited per (subsystem, event, severity), LOG-004). + const std::uint64_t dropped = want - options_.maxCatchUpTicks; + droppedTicks_ += dropped; + ++droppedFrames_; + LAIGE_LOG_WARN(kLoopSubsystem, "tick_dropped", kTickDroppedMessage, + laige::log::field("dropped", dropped), + laige::log::field("total_dropped", droppedTicks_), + laige::log::field("max_catch_up", + options_.maxCatchUpTicks), + laige::log::field("tick_rate_hz", options_.tickRateHz)); + } + return Status{}; +} + +Status GameLoop::runOneTick() noexcept { + // One tick: the frame's beginFrame() (once per frame — the preamble + // "beginFrame wiring") plus one system-phase dispatch. The tick + // counts only when the system phase completed (preamble "Failure + // behavior"). + world_->beginFrame(); + const Status s = world_->runSystems(*schedule_); + if (s.ok()) ++ticks_; + return s; +} + +std::uint64_t GameLoop::currentTick() const noexcept { return ticks_; } + +std::uint32_t GameLoop::tickRateHz() const noexcept { + return options_.tickRateHz; +} + +std::uint32_t GameLoop::maxCatchUpTicks() const noexcept { + return options_.maxCatchUpTicks; +} + +GameLoopStats GameLoop::stats() const noexcept { + return GameLoopStats{frames_, ticks_, droppedTicks_, droppedFrames_}; +} + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/game_loop.h b/src/laige-sim/include/laige/sim/game_loop.h new file mode 100644 index 0000000..eec58b5 --- /dev/null +++ b/src/laige-sim/include/laige/sim/game_loop.h @@ -0,0 +1,366 @@ +// laige-sim fixed-timestep game loop core (M1-LOOP-01). +// +// FR-1.1 (fixed-timestep simulation — default 60 Hz, configurable +// 20–120 Hz — decoupled from the presentation cadence); ARCH-002 +// (the simulation MUST NOT depend on render frame rate); PRD §10.2 +// (simulation is single-threaded). This header ships the fixed- +// timestep core of the M1 game loop — the accumulator loop that +// advances the simulation in integer ticks and runs the world's +// scheduled systems once per tick: +// +// GameLoop The accumulator loop: owns the tick state (the +// completed tick count, the drop counters) and +// drives World::beginFrame + World::runSystems. +// GameLoop::Options +// The typed configuration: the tick rate (20–120 Hz, +// validated) and the max catch-up ticks per frame. +// GameLoopStats The since-construction snapshot (frames, ticks, +// dropped ticks, dropped frames) for the profiler +// (M1-PROF-01). +// kMinTickRateHz / kDefaultTickRateHz / kMaxTickRateHz +// The documented tick-rate range (FR-1.1). +// kDefaultMaxCatchUpTicks +// The documented max catch-up default. +// +// --------------------------------------------------------------------------- +// The accumulator contract (FR-1.1, ARCH-002) +// --------------------------------------------------------------------------- +// +// The loop decouples two cadences: +// +// - Presentation frames — the caller invokes frame() once per +// presentation frame (the headless engine run loop from M1-HEAD-01, +// the windowed frame pipeline from M2 on). A frame is a +// presentation boundary; it carries no simulation time. +// - Simulation ticks — the simulation advances in INTEGER ticks at +// a fixed rate (Options::tickRateHz): one tick per 1/tickRateHz +// seconds, all simulation time in integer ticks (PRD §10.3). +// +// The accumulator is the difference between the ticks that are DUE +// at the current clock reading and the ticks already run — computed +// EXACTLY, with no floating point and no rounding drift: +// +// due(now) = floor(elapsedNs × tickRateHz / 10⁹) +// +// evaluated in pure integer arithmetic as +// +// (elapsedNs / 10⁹) × rate + (elapsedNs mod 10⁹) × rate / 10⁹ +// +// (the seconds/sub-seconds split keeps every intermediate product +// overflow-free). Each frame() runs min(due − ticksRun, +// maxCatchUpTicks) ticks — the classic fixed-timestep accumulator: +// one tick per frame at a healthy cadence, several ticks in a catch- +// up frame, zero ticks in a frame faster than the tick rate. The +// remainder (due − ticksRun − toRun) carries over automatically — it +// is re-derived from the clock on the next frame, so no remainder +// state is stored (no floating accumulator to drift; ARCH-010). +// +// The first frame() call establishes the start reference and runs +// zero ticks; every later frame advances the simulation. +// +// --------------------------------------------------------------------------- +// Configuration and validation (FR-1.1, API-006/008) +// --------------------------------------------------------------------------- +// +// GameLoop::create(world, schedule, options) validates the typed +// configuration (normative order, first failure wins; every failure +// is one rate-limited structured warn — subsystem "loop" — plus +// InvalidArgument, FR-12.3/CORE-008: never silent): +// +// tickRateHz < kMinTickRateHz (20) or +// tickRateHz > kMaxTickRateHz (120) +// -> InvalidArgument + warn +// (loop/tick_rate_invalid) +// maxCatchUpTicks == 0 -> InvalidArgument + warn +// (loop/catchup_invalid) — a zero +// limit would never run a tick +// +// The loop holds NON-OWNING views of the world and the schedule: +// both must outlive the loop (the engine object owns all three — +// M1-HEAD-01). One live loop per world (misuse warning below). +// +// --------------------------------------------------------------------------- +// Overload behavior (the drop path, FR-12.3: never silent) +// --------------------------------------------------------------------------- +// +// When a frame's due-tick demand exceeds the max catch-up limit, the +// frame runs exactly maxCatchUpTicks ticks and the remaining due +// ticks are DROPPED — never run silently later in the same frame, +// never silently swallowed: +// +// dropped = want − maxCatchUpTicks (want = due − ticksRun) +// +// One structured warn per overload frame — loop/tick_dropped (the +// NFR-13.3 5-field message grammar, build-stable; the dynamic values +// are structured fields) — carries the per-frame drop amount and the +// running total, and is rate-limited per (subsystem, event, severity) +// by the logging facade (LOG-004): a sustained overload logs once per +// rate window plus a rate_limited summary, never a log storm. The +// loop's counters (GameLoopStats: droppedTicks, droppedFrames) keep +// counting every drop even when the event is suppressed — the state +// is observable, never silent. +// +// The frame work stays BOUNDED by maxCatchUpTicks (PERF-002/008): a +// permanently overloaded machine drops ticks every frame (one rate- +// limited event per window) and the accumulator never grows unbound +// — after each drop the unrun demand is the sub-tick remainder +// (due mod the tick time) plus the next frame's own time. +// +// --------------------------------------------------------------------------- +// beginFrame wiring (M1-ECS-06 guardrails) +// --------------------------------------------------------------------------- +// +// frame() drives World::beginFrame() exactly ONCE per frame — before +// the frame's ticks — and World::runSystems(schedule) once per tick +// (entity.h: "the owning loop drives it once per frame"). The G-R3/ +// G-R4 per-frame windows are therefore per PRESENTATION frame: a +// catch-up frame running N ticks counts all N ticks of churn against +// one per-frame budget (the guardrail flags the heavier work — the +// documented overload signal, entity.h "Guardrails"). Before this +// loop existed, the per-tick beginFrame() pattern in the scheduler +// docs was the manual form; it remains the test form (one frame per +// tick). +// +// --------------------------------------------------------------------------- +// Failure behavior (CORE-008) +// --------------------------------------------------------------------------- +// +// frame() returns the runSystems Status: a stale or malformed +// schedule (a system registered after scheduling, a hand-built +// schedule) is InvalidArgument — the failure is raised by runSystems +// (system/schedule_stale, system/schedule_invalid; the loop adds no +// event of its own). A failed tick is NOT counted in currentTick(): +// the tick's system phase did not complete. The loop is otherwise +// untouched — the tick count freezes, the next frame() re-derives the +// demand from the clock and fails the same way (rate-limited), until +// the caller recreates the loop with a recomputed schedule. The +// failure path never runs a system (runSystems validates before +// dispatch), so the world is not mutated by a failed frame. +// +// A moved-from loop is a valid but STOPPED loop: frame() returns +// InvalidArgument without touching the world and without logging (the +// moved-from-world pure-failure precedent, entity.h). Move transfers +// the tick state (the factory's Result move, the World move +// precedent: the source is left in a well-defined state, never +// usable for driving). +// +// --------------------------------------------------------------------------- +// Determinism scope (ARCH-009/ARCH-010) +// --------------------------------------------------------------------------- +// +// The tick sequence — currentTick() after any frame sequence — is a +// pure function of (the clock readings, tickRateHz, maxCatchUpTicks): +// integer arithmetic only, no floating point, no randomness, no +// platform state. Two runs (two processes, two builds) over the same +// clock sequence produce bit-identical tick counts (replay state from +// M1-DET-01/02: the tick counter is part of the state hash). The +// clock READINGS themselves are wall-clock facts (platform- +// sensitive — ARCH-009): with the default steady clock, cross- +// platform identity of tick counts over real time is not promised; +// the windowed clock (M2-GL-02) and the replay runner (M1-DET-03) +// supply the canonical time base. frames/droppedTicks/droppedFrames +// are presentation/diagnostic state (the frame cadence is the +// caller's, not the simulation's) — never authoritative. +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003) +// --------------------------------------------------------------------------- +// +// Per frame (the hot path): one clock read (the injected ClockFn or +// the steady clock), a few integer ops (the due computation, the +// bounded run loop), and up to maxCatchUpTicks runSystems dispatches +// — BOUNDED by the config (PERF-002: no unbounded loop). No +// allocation and no logging on the success path (PERF-003, LOG-003; +// the HealthyFramesAllocateNothing test asserts it). The loop +// bookkeeping per tick is a few integer ops — negligible against the +// 3 ms sim_tick_avg budget (PRD §8.1) next to the runSystems dispatch +// cost, which M1-SYS-03 measures. The drop path is cold (an overload +// episode): one rate-limited warn with field construction. +// +// --------------------------------------------------------------------------- +// Threading +// --------------------------------------------------------------------------- +// +// The loop has exactly one owner thread (CONC-001; PRD §10.2): +// frame() and the queries run on the world's single owner thread, +// strictly interleaved with the systems' execution (API-004: the +// system phase). The non-owning world/schedule views are never +// dereferenced off-thread (the views point at single-owner state). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - One live loop per world. Two live loops on one world double- +// tick the simulation (two runSystems per tick, two beginFrame() +// per frame) — an API-004 violation the engine cannot detect; +// the moved-from stop is what makes the factory's move safe. +// - The clock source must be MONOTONIC (steady_clock is by +// definition; a synthetic test clock must be advanced, never +// rewound). A backward reading asserts in debug builds and +// clamps to the start reference in release (the frame +// contributes no time) — never undefined behavior. +// - The world and the schedule must outlive the loop (non-owning +// views). Recomputing the schedule after a registration change +// without recreating the loop leaves the old schedule stale — +// frame() then fails every frame (system/schedule_stale). +// - maxCatchUpTicks is a bound on PER-FRAME work, not a rate +// knob: it cannot make the simulation run faster; it only bounds +// how many ticks one frame may run. +// - A dropped tick is a lost simulation step (documented overload +// degradation, FR-12.3): it is logged and counted, but the game +// continues without it — the fix is the tick rate, the per-tick +// work, or the catch-up bound (the event's {fix} field). + +#pragma once + +#include + +#include "laige/sim/entity.h" // World, SystemSchedule (via system.h), Result + +namespace laige { + +// The supported tick-rate range (FR-1.1: default 60 Hz, configurable +// 20–120 Hz). Named constants (CORE-005): a rate outside this range +// is rejected at loop construction. +inline constexpr std::uint32_t kMinTickRateHz = 20; + +// The default tick rate (FR-1.1). +inline constexpr std::uint32_t kDefaultTickRateHz = 60; + +inline constexpr std::uint32_t kMaxTickRateHz = 120; + +// The default max catch-up ticks per frame (CORE-005): at the default +// 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of +// simulation time) before the frame's demand is dropped and logged. +// A healthy machine runs 1 tick per frame (frames slower than the +// tick rate run 2–3, still under the bound); a drop fires only when +// a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — +// a real overload, not a cadence difference. Raising it is typed +// configuration (an ADR if the engine default changes), not a knob. +inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5; + +// The since-construction accounting snapshot of one GameLoop +// (M1-PROF-01 feed; a plain value, the EntityStats/ +// SystemTimingStats precedent): +// +// frames frame() calls since construction (including the +// first, start-establishing call) +// ticks completed ticks (== currentTick()) +// droppedTicks ticks dropped since construction (the sum of the +// per-frame drop amounts) +// droppedFrames frames in which a drop occurred +struct GameLoopStats { + std::uint64_t frames{}; + std::uint64_t ticks{}; + std::uint64_t droppedTicks{}; + std::uint64_t droppedFrames{}; +}; + +// The fixed-timestep accumulator loop (M1-LOOP-01): see the header +// preamble for the accumulator, configuration, overload, beginFrame, +// failure, determinism, performance, and threading contracts. +class GameLoop { + public: + // The typed loop configuration (API-006): the tick rate (20–120 Hz, + // validated at construction), the max catch-up ticks per frame + // (>= 1, validated), and the clock source. + struct Options { + // The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; + // default kDefaultTickRateHz). + std::uint32_t tickRateHz{kDefaultTickRateHz}; + // The max ticks one frame may run before its due-tick demand is + // dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks). + std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}; + // The clock source: nanoseconds since a fixed monotonic epoch + // (the same time base as the default clock below). nullptr uses + // the headless monotonic clock (steady_clock); a test or the M2 + // windowed clock supplies its own (injectable for tests — the + // LoggerOptions::ClockFn precedent). + using ClockFn = std::int64_t (*)(); + ClockFn nowNs{nullptr}; + }; + + // Construct the loop on `world` running `schedule` (setup phase, + // after World::scheduleSystems — the schedule must describe the + // world's CURRENT registry, and both must outlive the loop). + // O(1); no allocation (the loop state is fixed scalars). + // + // tickRateHz outside 20–120 -> InvalidArgument + warn + // (loop/tick_rate_invalid) + // maxCatchUpTicks == 0 -> InvalidArgument + warn + // (loop/catchup_invalid) + [[nodiscard]] static Result + create(World& world, const SystemSchedule& schedule, + Options options) noexcept; + + // Advance one presentation frame (the hot path; see the preamble + // "Performance"): read the clock, run the frame's due ticks (up to + // maxCatchUpTicks), drop the excess with a rate-limited warn. + // + // moved-from loop -> InvalidArgument (no log, no + // world access — the stopped + // state) + // a system phase failure -> the runSystems Status (the + // tick is not counted; the + // world is not mutated) + // success -> ok + // O(maxCatchUpTicks × the systems' own work) — BOUNDED (PERF-002); + // no allocation, no logging on the success path. + // @budget O(maxCatchUpTicks × per-tick system work); bounded, no allocation. + [[nodiscard]] Status frame() noexcept; + + // The number of completed ticks (0 before the first; the first + // tick to complete is tick 1). O(1), no side effects. + [[nodiscard]] std::uint64_t currentTick() const noexcept; + + // The configured tick rate (Hz). O(1), no side effects. + [[nodiscard]] std::uint32_t tickRateHz() const noexcept; + + // The configured max catch-up ticks per frame. O(1), no side + // effects. + [[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept; + + // The since-construction accounting snapshot (GameLoopStats). O(1), + // no allocation, no side effects (a pure query, the + // World::stats() precedent). + [[nodiscard]] GameLoopStats stats() const noexcept; + + // Move transfers the tick state; the source becomes a valid but + // STOPPED loop (frame() returns InvalidArgument, no log — see the + // preamble "Failure behavior"; the World moved-from precedent: the + // source is left in a well-defined state). + GameLoop(GameLoop&& other) noexcept; + GameLoop& operator=(GameLoop&& other) noexcept; + GameLoop(const GameLoop&) = delete; + GameLoop& operator=(const GameLoop&) = delete; + + private: + // The factory path (create): the configuration is already + // validated (API-008: an invalid configuration is unrepresentable). + GameLoop(World& world, const SystemSchedule& schedule, + Options options) noexcept; + + // One simulation tick: the frame's beginFrame() (once per frame — + // see the preamble "beginFrame wiring") plus one runSystems + // dispatch. A successful tick is counted; a failed tick is not. + [[nodiscard]] Status runOneTick() noexcept; + + // Non-owning views (the world and the schedule outlive the loop). + World* world_; + const SystemSchedule* schedule_; + Options options_; + // Cleared on move-out: a stopped loop drives nothing. + bool valid_{true}; + // True after the first frame() established the start reference. + bool started_{false}; + // The clock reading of the first frame (the time base origin). + std::int64_t startNs_{0}; + // Since-construction counters (GameLoopStats feed). + std::uint64_t frames_{0}; + std::uint64_t ticks_{0}; + std::uint64_t droppedTicks_{0}; + std::uint64_t droppedFrames_{0}; +}; + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/system.h b/src/laige-sim/include/laige/sim/system.h index b86a74c..7bdb999 100644 --- a/src/laige-sim/include/laige/sim/system.h +++ b/src/laige-sim/include/laige/sim/system.h @@ -156,10 +156,11 @@ // SystemSchedule sched; // Status s = world.scheduleSystems(sched); // setup phase, once // ... // before the loop -// for (tick) { // M1-LOOP-01 owns this -// world.beginFrame(); -// world.runSystems(sched); -// } +// GameLoop loop = // M1-LOOP-01 owns this +// GameLoop::create(world, sched, opts).value(); +// while (running) loop.frame(); // beginFrame once per +// // frame, runSystems per +// // tick (game_loop.h) // // Execution order: // diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 730d8b9..6c7cd1c 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,26 +1,30 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03): +# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03 +# + M1-LOOP-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 # ECS stress + memory accounting suite, the system registry (plain # registered functions, declared budgets + component I/O), the system # scheduler (execution order, depends_on, the declared-I/O pre-run -# validation), and per-system timing + budget enforcement (the -# rolling window, the G-R5 warn/error events, the profiler feed). +# validation), per-system timing + budget enforcement (the rolling +# window, the G-R5 warn/error events, the profiler feed), and the +# fixed-timestep game loop core (the accumulator, the tick-rate +# validation, the bounded catch-up + tick_dropped overload behavior). # # 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`, and -# `system_timing` 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, -# and M1-SYS-03 Verify commands (`ctest -R entity`, -# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R -# query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, -# `ctest -R ecs_stress`, `ctest -R system_registry`, -# `ctest -R scheduler`, `ctest -R system_timing`), selecting exactly -# the suites below from the shared executable. +# `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, +# `system_timing`, and `game_loop` 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, and M1-LOOP-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`), 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 @@ -28,7 +32,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp ecs_stress_tests.cpp system_registry_tests.cpp scheduler_tests.cpp - system_timing_tests.cpp) + system_timing_tests.cpp + game_loop_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, @@ -150,10 +155,20 @@ add_test(NAME system_timing COMMAND laige-sim_tests --gtest_filter=SystemTiming.*) +# M1-LOOP-01: fixed-timestep game loop core (FR-1.1, ARCH-002). +# The step's Verify command is `ctest -R game_loop`; this entry +# selects exactly the GameLoop suites from the shared laige-sim_tests +# executable (the machine-greppable game-loop drops / +# game-loop-zeroalloc lines land in the ctest output). +add_test(NAME game_loop + COMMAND laige-sim_tests + --gtest_filter=GameLoop.*) + 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 PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + system_timing game_loop PROPERTIES + ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/game_loop_tests.cpp b/tests/laige-sim/game_loop_tests.cpp new file mode 100644 index 0000000..593ec3f --- /dev/null +++ b/tests/laige-sim/game_loop_tests.cpp @@ -0,0 +1,785 @@ +// laige-sim fixed-timestep game loop core suite (M1-LOOP-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - fixed 60 Hz over a synthetic 10 s clock -> EXACT tick count +// (600 ticks at exactly 10.000000 s — the integer due +// computation, no floating-point accumulator drift) +// - the overload path drops EXACTLY the documented amount +// (want - maxCatchUpTicks per frame) and logs once per episode +// (the facade's rate limiting: one tick_dropped per window, +// the suppressed repeats summarized at shutdown) +// - config validation (tick rate 20–120 Hz, max catch-up >= 1) +// with the structured warns (loop/tick_rate_invalid, +// loop/catchup_invalid) +// - the healthy cadence runs one tick per frame, zero drops, the +// success path silent (LOG-003) +// - the first frame establishes the clock reference (zero ticks) +// - a stale schedule freezes the tick count and surfaces the +// runSystems Status (CORE-008: never silent) +// - a backward clock jump asserts in debug / clamps in release +// (the monotonic clock-source contract) +// - the default headless clock (steady_clock) drives real frames +// - move transfers the tick state; the moved-from loop is stopped +// - no heap allocation on the healthy frame path (test-only +// operator-new counter, non-sanitizer trees; the sanitizer +// trees prove it leak-free) +// +// Runs as CTest `game_loop` (the step's Verify command: +// `ctest -R game_loop`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites below. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__unix__) +#include +#include +#include +#endif + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim/entity.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, + "game_loop_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "game_loop_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "game_loop_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define GAME_LOOP_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define GAME_LOOP_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +static_assert(GAME_LOOP_TESTS_ACTIVE_CPLUSPLUS >= 202002L, + "game_loop_tests must be built with C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); + +namespace { + +using laige::ErrorCode; +using laige::GameLoop; +using laige::GameLoopStats; +using laige::Status; +using laige::SystemDef; +using laige::SystemSchedule; +using laige::World; + +// --------------------------------------------------------------------------- +// The synthetic clock (the Options::nowNs injection seam — the +// LoggerOptions::ClockFn precedent). Monotonic by construction: the +// tests only advance it (the backward-jump tests drive the documented +// failure path explicitly). +// --------------------------------------------------------------------------- + +std::int64_t gSynthClockNs = 0; + +std::int64_t synthNowNs() { + return gSynthClockNs; +} + +// One 60 Hz tick, in whole nanoseconds (16666667 ns — the exact-tick +// tests use steps of this size so every clock reading is an integer +// number of nanoseconds; 60 * 16666667 = 1000000020 ns > 1 s by +// exactly 20 ns, which is why the ten-second test mixes 16666667-ns +// and 16666666-ns steps to land on exactly 10.000000 s). +inline constexpr std::int64_t kSynthTickNs = 16666667; + +// --------------------------------------------------------------------------- +// The test systems (zero-I/O noops — a system that touches no +// components, the M1-SYS-01 zero-Io-pack form) +// --------------------------------------------------------------------------- + +void fnNoopA(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +void fnNoopB(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +SystemDef makeDef(const char* name, laige::SystemFn fn) { + return SystemDef{name, fn, laige::fpx16_16::fromInt32(1), nullptr}; +} + +// --------------------------------------------------------------------------- +// World + schedule + loop builders +// --------------------------------------------------------------------------- + +World makeWorld() { + auto w = World::create(World::Options{8}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(8) failed: " << laige::errorName(w.error()); + abort(); + } + World world = std::move(w).takeValue(); + if (!world.registerSystem(makeDef("GLNoopA", &fnNoopA)).ok()) { + ADD_FAILURE() << "registerSystem(GLNoopA) failed"; + abort(); + } + return world; +} + +SystemSchedule makeSchedule(World& world) { + SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + return sched; +} + +GameLoop makeLoop(World& world, const SystemSchedule& sched, + std::uint32_t tickRateHz, std::uint32_t maxCatchUp) { + GameLoop::Options opts; + opts.tickRateHz = tickRateHz; + opts.maxCatchUpTicks = maxCatchUp; + opts.nowNs = &synthNowNs; + auto r = GameLoop::create(world, sched, std::move(opts)); + if (!r.ok()) { + ADD_FAILURE() << "GameLoop::create failed: " << laige::errorName(r.error()); + abort(); + } + return std::move(r).takeValue(); +} + +// One frame on the synthetic clock, advancing it by `stepNs` and +// asserting the frame status. +void synthFrame(GameLoop& loop, std::int64_t stepNs) { + gSynthClockNs += stepNs; + ASSERT_TRUE(loop.frame().ok()); +} + +// One frame on the synthetic clock that must FAIL with `expected` +// (the stale-schedule test — the failure must be surfaced, never +// silent, CORE-008). +void synthFrameExpectFailure(GameLoop& loop, std::int64_t stepNs, + ErrorCode expected) { + gSynthClockNs += stepNs; + const Status s = loop.frame(); + ASSERT_FALSE(s.ok()); + EXPECT_EQ(s.error(), expected); +} + +// --------------------------------------------------------------------------- +// Log capture (the logging_tests / scheduler_tests pattern) +// --------------------------------------------------------------------------- + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; the tests that use it restore the +// default console sink at the end — the scheduler_tests pattern). +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 { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* sink = nullptr; + +// Install the capture sink (a 60 s rate window: the tests' repeated +// events stay within one window, so the rate-limited repeats are +// suppressed and summarized at shutdown — the scheduler_tests +// pattern). +MemorySink* installCaptureSink() { + auto mem = std::make_unique(); + MemorySink* memPtr = mem.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(mem); + opts.rateWindow = std::chrono::seconds(60); + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init failed"; + std::abort(); + } + sink = memPtr; + return memPtr; +} + +void restoreConsoleSink() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger re-init with the default console sink failed"; + std::abort(); + } + sink = nullptr; +} + +std::size_t countEvents(const MemorySink& s, const char* event) { + std::size_t n = 0; + for (const auto& e : s.entries) { + if (e.event == event) ++n; + } + return n; +} + +const char* fieldValue(const MemorySink::Entry& entry, const char* key) { + for (const auto& [k, v] : entry.fields) { + if (k == key) return v.c_str(); + } + return ""; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Configuration validation (FR-1.1: 20–120 Hz; catch-up >= 1) +// --------------------------------------------------------------------------- + +TEST(GameLoop, CreateValidatesTheConfig) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + + GameLoop::Options base; + base.nowNs = &synthNowNs; + + // Below the range (19 Hz): rejected, one tick_rate_invalid warn. + { + GameLoop::Options o = base; + o.tickRateHz = 19; + auto r = GameLoop::create(w, sched, o); + ASSERT_FALSE(r.ok()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + } + // Above the range (121 Hz): rejected (the second warn for the same + // key is rate-limited within the 60 s window — the summary at + // shutdown carries it, checked below). + { + GameLoop::Options o = base; + o.tickRateHz = 121; + auto r = GameLoop::create(w, sched, o); + ASSERT_FALSE(r.ok()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + } + // The range endpoints are legal. + { + GameLoop::Options o = base; + o.tickRateHz = 20; + auto r = GameLoop::create(w, sched, o); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(r.value().tickRateHz(), 20u); + } + { + GameLoop::Options o = base; + o.tickRateHz = 120; + auto r = GameLoop::create(w, sched, o); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(r.value().tickRateHz(), 120u); + } + // Zero catch-up: rejected, one catchup_invalid warn. + { + GameLoop::Options o = base; + o.maxCatchUpTicks = 0; + auto r = GameLoop::create(w, sched, o); + ASSERT_FALSE(r.ok()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + } + // Catch-up of 1 is the minimum legal value. + { + GameLoop::Options o = base; + o.maxCatchUpTicks = 1; + auto r = GameLoop::create(w, sched, o); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(r.value().maxCatchUpTicks(), 1u); + } + // The defaults (an empty Options): 60 Hz, 5 catch-up ticks. + { + GameLoop::Options o = base; + auto r = GameLoop::create(w, sched, o); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(r.value().tickRateHz(), laige::kDefaultTickRateHz); + EXPECT_EQ(r.value().maxCatchUpTicks(), laige::kDefaultMaxCatchUpTicks); + } + + // The warns: exactly one tick_rate_invalid (the 19 Hz case; the + // 121 Hz repeat is rate-limited — LOG-004) and one catchup_invalid, + // subsystem "loop", with the rejected value as a structured field. + EXPECT_EQ(countEvents(*mem, "tick_rate_invalid"), 1u); + EXPECT_EQ(countEvents(*mem, "catchup_invalid"), 1u); + ASSERT_EQ(mem->entries.size(), 2u); + const auto& rateEntry = mem->entries[0]; + EXPECT_EQ(rateEntry.subsystem, "loop"); + EXPECT_EQ(rateEntry.severity, laige::log::Severity::Warn); + EXPECT_STREQ(fieldValue(rateEntry, "tick_rate_hz"), "19"); + const auto& catchEntry = mem->entries[1]; + EXPECT_EQ(catchEntry.subsystem, "loop"); + EXPECT_EQ(catchEntry.severity, laige::log::Severity::Warn); + EXPECT_STREQ(fieldValue(catchEntry, "max_catch_up"), "0"); + + // Shutdown: the suppressed 121 Hz repeat is summarized. + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(mem->entries.size(), 3u); + EXPECT_EQ(mem->entries[2].event, "rate_limited"); + EXPECT_STREQ(fieldValue(mem->entries[2], "event"), "tick_rate_invalid"); + EXPECT_STREQ(fieldValue(mem->entries[2], "suppressed"), "1"); + sink = nullptr; +} + +// --------------------------------------------------------------------------- +// The first frame: start reference, zero ticks +// --------------------------------------------------------------------------- + +TEST(GameLoop, FirstFrameEstablishesTheClockAndRunsNothing) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + ASSERT_TRUE(loop.frame().ok()); + EXPECT_EQ(loop.currentTick(), 0u); + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 1u); + EXPECT_EQ(st.ticks, 0u); + EXPECT_EQ(st.droppedTicks, 0u); + EXPECT_EQ(st.droppedFrames, 0u); + // The success path is silent (LOG-003): no event at all. + EXPECT_EQ(mem->entries.size(), 0u); + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// Exact tick count over a synthetic 10 s clock (the step's Verify: +// fixed 60 Hz -> exact tick count) +// --------------------------------------------------------------------------- + +TEST(GameLoop, ExactTicksOverTenSeconds) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + // Frame 0 at t = 0 establishes the start reference. + ASSERT_TRUE(loop.frame().ok()); + EXPECT_EQ(loop.currentTick(), 0u); + + // 600 frames advancing exactly 10.000000 s (1e10 ns) in whole + // nanoseconds: 400 steps of 16666667 ns plus 200 of 16666666 ns + // (= 400*16666667 + 200*16666666 = 10^10). The intermediate + // readings are NOT whole multiples of the (non-integer-ns) tick + // period — a floating accumulator would drift; the integer due + // computation must not. + for (std::uint32_t f = 1; f <= 400; ++f) { + synthFrame(loop, 16666667); + } + // t = 400 * 16666667 = 6666666800 ns = 6.6666668 s: + // floor(6666666800 * 60 / 10^9) = floor(400.000008) = 400 ticks. + EXPECT_EQ(loop.currentTick(), 400u); + for (std::uint32_t f = 401; f <= 600; ++f) { + synthFrame(loop, 16666666); + } + // t = 10^10 ns exactly: due = (10^10 / 10^9) * 60 = 600 ticks — + // EXACTLY 600 (a float accumulator over ms would floor to 599). + EXPECT_EQ(loop.currentTick(), 600u); + + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 601u); // the start frame + 600 advancing frames + EXPECT_EQ(st.ticks, 600u); + EXPECT_EQ(st.droppedTicks, 0u); + EXPECT_EQ(st.droppedFrames, 0u); + // The success path stayed silent over all 601 frames. + EXPECT_EQ(mem->entries.size(), 0u); + + std::printf("game-loop exact frames=%llu ticks=%llu dropped=%llu\n", + static_cast(st.frames), + static_cast(st.ticks), + static_cast(st.droppedTicks)); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The overload path: drop exactly want - maxCatchUp per frame, log +// once per episode (rate-limited) +// --------------------------------------------------------------------------- + +TEST(GameLoop, OverloadDropsExactlyTheDocumentedAmountAndLogsOnce) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 2); // max catch-up: 2 ticks + + // Frame 0 at t = 0. + ASSERT_TRUE(loop.frame().ok()); + // Three overloaded frames, each demanding 10 ticks + // (10 * 16666667 ns): want = 10, 18, 26; toRun = 2 every frame; + // dropped = 8, 16, 24 (EXACTLY want - maxCatchUp each time). + for (int f = 0; f < 3; ++f) { + synthFrame(loop, 10 * kSynthTickNs); + } + EXPECT_EQ(loop.currentTick(), 6u); // 2 ticks per frame, 3 frames + + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 4u); + EXPECT_EQ(st.ticks, 6u); + EXPECT_EQ(st.droppedTicks, 48u); // 8 + 16 + 24 + EXPECT_EQ(st.droppedFrames, 3u); + + // The overload logged ONCE (LOG-004: one event per rate window per + // (subsystem, event, severity); the two repeats are suppressed and + // summarized at shutdown — checked below). + EXPECT_EQ(countEvents(*mem, "tick_dropped"), 1u); + ASSERT_EQ(mem->entries.size(), 1u); + const auto& e = mem->entries[0]; + EXPECT_EQ(e.subsystem, "loop"); + EXPECT_EQ(e.severity, laige::log::Severity::Warn); + EXPECT_STREQ(fieldValue(e, "dropped"), "8"); // the first frame's amount + EXPECT_STREQ(fieldValue(e, "total_dropped"), "8"); + EXPECT_STREQ(fieldValue(e, "max_catch_up"), "2"); + EXPECT_STREQ(fieldValue(e, "tick_rate_hz"), "60"); + + // The NFR-13.3 5-field grammar (build-stable message text; the + // dynamic values are structured fields — the system_timing + // precedent): split on " | ", exactly 5 fields, none empty. + std::vector fields; + std::size_t start = 0; + for (;;) { + const std::size_t pos = e.message.find(" | ", start); + if (pos == std::string::npos) { + fields.push_back(e.message.substr(start)); + break; + } + fields.push_back(e.message.substr(start, pos - start)); + start = pos + 3; + } + ASSERT_EQ(fields.size(), 5u) << "message: " << e.message; + for (const auto& f : fields) { + EXPECT_FALSE(f.empty()) << "message: " << e.message; + } + EXPECT_EQ(fields[0], "tick_dropped"); + EXPECT_EQ(fields[4], "docs/api/game_loop.md"); + + // Shutdown: the two suppressed repeats are summarized. + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(mem->entries.size(), 2u); + EXPECT_EQ(mem->entries[1].event, "rate_limited"); + EXPECT_STREQ(fieldValue(mem->entries[1], "event"), "tick_dropped"); + EXPECT_STREQ(fieldValue(mem->entries[1], "suppressed"), "2"); + + std::printf("game-loop drops frames=%llu ticks=%llu dropped=%llu\n", + static_cast(st.droppedFrames), + static_cast(st.ticks), + static_cast(st.droppedTicks)); + sink = nullptr; +} + +// --------------------------------------------------------------------------- +// The healthy cadence: one tick per frame, zero drops, silent +// --------------------------------------------------------------------------- + +TEST(GameLoop, HealthyCadenceRunsOneTickPerFrame) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, laige::kDefaultMaxCatchUpTicks); + + ASSERT_TRUE(loop.frame().ok()); + for (int f = 0; f < 120; ++f) { + synthFrame(loop, kSynthTickNs); // one tick's time per frame + } + // One tick due per frame: exactly 120 ticks, nothing dropped. + EXPECT_EQ(loop.currentTick(), 120u); + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 121u); + EXPECT_EQ(st.ticks, 120u); + EXPECT_EQ(st.droppedTicks, 0u); + // The loop drives runSystems once per tick: the system's measured + // run count tracks the ticks exactly (the M1-SYS-03 feed). + auto ts = w.systemTimingStats(laige::SystemId{1}); + ASSERT_TRUE(ts.ok()); + EXPECT_EQ(ts.value().runs, 120u); + // The success path is silent: no budget events either (the noop + // system runs in microseconds against its 1 ms budget). + EXPECT_EQ(mem->entries.size(), 0u); + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// A stale schedule: the tick count freezes, the Status surfaces +// --------------------------------------------------------------------------- + +TEST(GameLoop, StaleScheduleFreezesTheLoop) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); // GLNoopA registered + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + ASSERT_TRUE(loop.frame().ok()); + synthFrame(loop, kSynthTickNs); // tick 1 + EXPECT_EQ(loop.currentTick(), 1u); + + // A registration after scheduling makes the schedule stale (the + // M1-SYS-02 contract). The loop's next frame must surface the + // runSystems failure — never silent (CORE-008). + ASSERT_TRUE(w.registerSystem(makeDef("GLNoopB", &fnNoopB)).ok()); + synthFrameExpectFailure(loop, kSynthTickNs, ErrorCode::InvalidArgument); + // The failed tick is not counted: the tick count froze at 1. + EXPECT_EQ(loop.currentTick(), 1u); + // runSystems raised its own warn (system/schedule_stale) — the loop + // adds no event of its own. + EXPECT_EQ(countEvents(*mem, "schedule_stale"), 1u); + + // The failure persists: the next frame re-derives the demand from + // the clock and fails the same way (rate-limited: no new event). + synthFrameExpectFailure(loop, kSynthTickNs, ErrorCode::InvalidArgument); + EXPECT_EQ(loop.currentTick(), 1u); + EXPECT_EQ(countEvents(*mem, "schedule_stale"), 1u); + // No system ran in either failed frame: one measured run total. + auto ts = w.systemTimingStats(laige::SystemId{1}); + ASSERT_TRUE(ts.ok()); + EXPECT_EQ(ts.value().runs, 1u); + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The monotonic clock-source contract +// --------------------------------------------------------------------------- + +TEST(GameLoop, BackwardClockJumpClampsInRelease) { +#ifdef NDEBUG + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + ASSERT_TRUE(loop.frame().ok()); + // A forward frame: 10 ticks due, cap 5 -> 5 ticks run, 5 dropped. + gSynthClockNs += 10 * kSynthTickNs; + ASSERT_TRUE(loop.frame().ok()); + EXPECT_EQ(loop.currentTick(), 5u); + // A backward jump below the start reference (misuse): release + // clamps to the start reference — the frame contributes no time, no + // tick, no new event (never undefined behavior). + gSynthClockNs = -1000; + ASSERT_TRUE(loop.frame().ok()); + EXPECT_EQ(loop.currentTick(), 5u); + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 3u); + EXPECT_EQ(st.ticks, 5u); + EXPECT_EQ(st.droppedTicks, 5u); + EXPECT_EQ(countEvents(*mem, "tick_dropped"), 1u); + + restoreConsoleSink(); +#else + GTEST_SKIP() << "the backward-jump clamp is a release-build property " + "(debug asserts — see BackwardClockJumpAbortsInDebug)."; +#endif +} + +TEST(GameLoop, BackwardClockJumpAbortsInDebug) { +#if defined(__unix__) +# if defined(NDEBUG) + GTEST_SKIP() << "assert-based monotonicity is a debug-build property"; +# else + // The debug assert must fire (S-9 style). Exercised in a forked + // child so the test process survives (the entity_tests + // DestroyStaleAbortsInDebug pattern). + const pid_t pid = fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child: a forward frame, then a backward jump. + auto w = World::create(World::Options{8}); + if (!w.ok()) _exit(117); + World world = std::move(w).takeValue(); + if (!world.registerSystem(makeDef("GLNoopA", &fnNoopA)).ok()) _exit(117); + SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) _exit(117); + GameLoop::Options opts; + opts.nowNs = &synthNowNs; + auto r = GameLoop::create(world, sched, opts); + if (!r.ok()) _exit(117); + GameLoop loop = std::move(r).takeValue(); + gSynthClockNs = 0; + if (!loop.frame().ok()) _exit(117); + gSynthClockNs = 10 * kSynthTickNs; + if (!loop.frame().ok()) _exit(117); + // Backward below the start reference: the debug assert must fire. + gSynthClockNs = -1000; + (void)loop.frame(); + _exit(1); // unreachable: the parent fails below without the assert + } + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT) + << "expected the monotonic-clock assert to abort the child (SIGABRT)"; +# endif +#else + GTEST_SKIP() << "fork() is not available on Windows; the monotonic-clock " + "assert is exercised on the POSIX jobs."; +#endif +} + +// --------------------------------------------------------------------------- +// The default headless clock (steady_clock) drives real frames +// --------------------------------------------------------------------------- + +TEST(GameLoop, DefaultClockDrivesRealFrames) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop::Options opts; // nowNs null -> the steady_clock default + auto r = GameLoop::create(w, sched, opts); + ASSERT_TRUE(r.ok()); + GameLoop loop = std::move(r).takeValue(); + + ASSERT_TRUE(loop.frame().ok()); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + ASSERT_TRUE(loop.frame().ok()); + + const GameLoopStats st = loop.stats(); + EXPECT_EQ(st.frames, 2u); + // 50 ms at 60 Hz is 3 ticks; allow preemption slack (the elapsed + // time can only grow, so at least the 50 ms floor of 3 ticks). + EXPECT_GE(st.ticks, 3u); + // The world saw exactly the ticks the loop ran. + auto ts = w.systemTimingStats(laige::SystemId{1}); + ASSERT_TRUE(ts.ok()); + EXPECT_EQ(ts.value().runs, st.ticks); + // The success path is silent unless preemption overloaded the frame + // (the only event that may then appear is the tick_dropped warn). + for (const auto& e : mem->entries) { + EXPECT_EQ(e.event, "tick_dropped"); + } + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// Move: the state transfers, the source stops +// --------------------------------------------------------------------------- + +TEST(GameLoop, MoveTransfersStateAndStopsTheSource) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + ASSERT_TRUE(loop.frame().ok()); + for (int f = 0; f < 3; ++f) { + synthFrame(loop, kSynthTickNs); + } + EXPECT_EQ(loop.currentTick(), 3u); + + GameLoop moved = std::move(loop); + // The tick state traveled with the move. + EXPECT_EQ(moved.currentTick(), 3u); + const GameLoopStats st = moved.stats(); + EXPECT_EQ(st.frames, 4u); + EXPECT_EQ(st.ticks, 3u); + // The moved loop keeps driving from where the source left off. + synthFrame(moved, kSynthTickNs); + EXPECT_EQ(moved.currentTick(), 4u); + + // The moved-from loop is STOPPED: frame() fails without touching + // the world and without logging (the moved-from-world precedent). + auto s = loop.frame(); + ASSERT_FALSE(s.ok()); + EXPECT_EQ(s.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(mem->entries.size(), 0u); + auto ts = w.systemTimingStats(laige::SystemId{1}); + ASSERT_TRUE(ts.ok()); + EXPECT_EQ(ts.value().runs, 4u); // unchanged: the stopped loop ran none + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The zero-allocation healthy frame path (PERF-003, the M1 +// zero-allocation property; the M1-ECS-03/07 pattern) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(GameLoop, HealthyFramesAllocateNothing) { + // The test-only operator-new counter (non-sanitizer trees; the + // sanitizer trees prove the loop leak-free). + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(); + SystemSchedule sched = makeSchedule(w); + GameLoop loop = makeLoop(w, sched, 60, 5); + + ASSERT_TRUE(loop.frame().ok()); + laige::test::resetAllocCounter(); + // 300 frames each demanding exactly 2 ticks (under the cap of 5): + // 600 ticks, zero drops — the healthy catch-up path. + for (int f = 0; f < 300; ++f) { + synthFrame(loop, 2 * kSynthTickNs); + } + const std::uint64_t allocs = laige::test::allocCounter(); + // Per frame: one clock read, a few integer ops, up to 2 runSystems + // dispatches (two clock reads + one ring write + two comparisons + // per system) — nothing touches the heap while the systems stay + // under budget and no drop fires. + std::printf("game-loop-zeroalloc frames=300 ticks=%llu allocs=%llu\n", + static_cast(loop.currentTick()), + static_cast(allocs)); + EXPECT_EQ(loop.currentTick(), 600u); + EXPECT_EQ(allocs, 0u); + EXPECT_EQ(mem->entries.size(), 0u); + restoreConsoleSink(); +} +#endif