From 7d4cc0dc4e67c6d8b0213135c24bc224fb2d7be1 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 02:10:00 +0200 Subject: [PATCH 1/2] [M1-LOOP-02] Presentation snapshot + interpolation state Per-tick presentation state (M1-LOOP-02 scope, nothing else): - Position2D: the first built-in component (the entity's 2D simulation-space position as the selected SimMath backend's Vec2, ADR 0002); both backends registered (fpx16_16 default, fp32_pinned opt-in). - PresentationSnapshot (header-only class template, the M1-ECS-02 pattern): per-completed-tick prev/curr capture over a pre-reserved per-slot table (no per-tick/per-frame heap, PERF-003); the exact-integer anchored alpha alpha = (R - A(T)) * rate / 1e9 clamped to [0, 1] - never extrapolates; sample_position(e) returns the SimMath lerp (synced entities), snaps entities added between ticks to their current value (documented), and rejects stale handles (warn-once) / live handles without a Position2D (no warn); moved-from snapshots are stopped (no world access, no logging). - GameLoop: Options::onTick per-completed-tick hook (fires only for completed ticks) + startReferenceNs() - the M1-HEAD-01 wiring shape. - Docs: docs/api/presentation.md (new, full contract + Performance), game_loop.md (hook + startReferenceNs), docs/README.md, the module README. - Tests: tests/laige-sim/presentation_tests.cpp (suite Presentation; CTest entry 'presentation' = the step's Verify), 10 cases incl. the exact Q16.16 linear-interpolation values, the alpha clamp matrix, new-entity snapping, catch-up per-tick refresh, the GameLoop hook integration, and the 500-entity x 100-frame zero-alloc window. - laige-api.json regenerated (555 symbols; api-real-tree green). Verify: ctest -R presentation green; full ctest 45/45 on the canonical g++ tree plus zero-warning 45/45 on build-asan, build-tsan, build-clang, build-release, build-shared; tools/laige-include-lint OK. --- docs/README.md | 12 +- docs/api/game_loop.md | 28 +- docs/api/presentation.md | 293 +++++ laige-api.json | 76 +- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 4 +- src/laige-sim/CMakeLists.txt | 9 +- src/laige-sim/README.md | 11 + src/laige-sim/game_loop.cpp | 15 +- src/laige-sim/include/laige/sim/game_loop.h | 54 +- .../include/laige/sim/presentation.h | 568 +++++++++ tests/laige-sim/CMakeLists.txt | 38 +- tests/laige-sim/presentation_tests.cpp | 1017 +++++++++++++++++ 13 files changed, 2080 insertions(+), 47 deletions(-) create mode 100644 docs/api/presentation.md create mode 100644 src/laige-sim/include/laige/sim/presentation.h create mode 100644 tests/laige-sim/presentation_tests.cpp diff --git a/docs/README.md b/docs/README.md index f054940..51dc1b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,9 @@ 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; M1-SYS-02: the system scheduler; M1-SYS-03: the per-system timing + budget -enforcement; M1-LOOP-01: the fixed-timestep game loop core). +enforcement; M1-LOOP-01: the fixed-timestep game loop core; +M1-LOOP-02: the per-tick presentation snapshot + interpolation +state). 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. @@ -77,6 +79,11 @@ still to land. (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`). +- [Presentation snapshot and interpolation state](api/presentation.md) + — `Position2D` (the first built-in component) and + `PresentationSnapshot`: the per-tick `prev`/`curr` capture, the + exact-integer anchored alpha (clamped to [0, 1], never + extrapolates), and `sample_position` (M1-LOOP-02; `laige-sim`). - [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, @@ -169,7 +176,8 @@ still to land. [system_registry.md](api/system_registry.md), [scheduler.md](api/scheduler.md), [system_timing.md](api/system_timing.md), - [game_loop.md](api/game_loop.md).) + [game_loop.md](api/game_loop.md), + [presentation.md](api/presentation.md).) ## Related diff --git a/docs/api/game_loop.md b/docs/api/game_loop.md index 0f7573d..e871356 100644 --- a/docs/api/game_loop.md +++ b/docs/api/game_loop.md @@ -65,6 +65,7 @@ sequence). |---|---|---|---| | `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`) | +| `onTick` / `onTickContext` | a `noexcept` tick callback + context (see below) | `nullptr` / `nullptr` | — (no validation: the callback's contract is the caller's) | The clock source is `Options::nowNs` — a function returning nanoseconds on a monotonic epoch time base; `nullptr` uses the @@ -119,6 +120,29 @@ 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). +## The per-tick presentation hook (M1-LOOP-02) + +`Options::onTick` is a `void (*)(void* context, World&, +std::uint64_t tick) noexcept` callback fired **after every completed +tick** — after `runSystems` for that tick succeeded, with the new +tick number (`currentTick()` already incremented). A failed tick does +**not** fire it (the tick is not counted and the state it would have +observed never happened — a stale schedule's tick, for example). + +This is the M1 presentation-state wiring seam (M1-LOOP-02): the +`PresentationSnapshot` (presentation.md) refreshes its `prev`/`curr` +pair per completed tick by wrapping its `onTick` in a static thunk +behind `onTickContext` — the headless engine (M1-HEAD-01) does exactly +this. The callback must not allocate or block (it runs inside the +tick's budget, PERF-002/003), and its ownership of the context is +the caller's (the context must outlive the loop). `nullptr` (the +default) fires nothing — the loop is unchanged. + +`GameLoop::startReferenceNs()` exposes the loop's clock reading at +its first frame — the anchor base the presentation snapshot's alpha +is computed against (presentation.md, "The alpha contract"). Read it +after the loop's first frame. + ## Failure behavior (CORE-008) `frame()` returns the `runSystems` Status: @@ -178,7 +202,9 @@ per-system windows (M1-SYS-03). 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. + which M1-SYS-03 measures. The `onTick` hook, when set, adds one + indirect call per completed tick (the snapshot's own cost is + presentation.md's — bounded, allocation-free). - **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 diff --git a/docs/api/presentation.md b/docs/api/presentation.md new file mode 100644 index 0000000..3933a58 --- /dev/null +++ b/docs/api/presentation.md @@ -0,0 +1,293 @@ +# Per-tick presentation snapshot and interpolation state (`PresentationSnapshot`, M1-LOOP-02) + +The M1 presentation-state half of the game loop (M1-LOOP-02; PRD +FR-1.1 — the 2D-aware half of render interpolation; ARCH-009 — +presentation state is separated from authoritative simulation state; +PRD §4 — depth is presentation-only, the logical simulation is 2D): +once per **completed** tick, the world's `Position2D` state is +captured as a `prev`/`curr` pair, and each render frame computes the +interpolation **alpha** between the last two tick states — clamped to +[0, 1], **never extrapolating**. Public header: +`src/laige-sim/include/laige/sim/presentation.h` (`Position2D`, +`PresentationSnapshot`, the aliases, the full contract). Header-only +(class template — one instantiation per SimMath backend, ADR 0002). +Unit suite: `ctest -R presentation` +(`tests/laige-sim/presentation_tests.cpp`). + +```cpp +World world = ...; // M1-ECS +world.registerComponent(); +Entity e = world.create().value(); +world.addComponent(e, ...); + +// The loop drives the refresh (the M1-HEAD-01 engine wiring): +GameLoop::Options lopts; +lopts.onTick = [](void* ctx, laige::World&, std::uint64_t tick) noexcept { + static_cast*>(ctx)->onTick(tick); +}; +lopts.onTickContext = &snap; +GameLoop loop = GameLoop::create(world, sched, lopts).value(); +loop.frame(); // establishes the start reference + +while (running) { + const std::int64_t nowNs = clockNow(); // ONE reading, same clock as the loop + loop.frame(); + snap.onRenderFrame(nowNs); // alpha := (nowNs − anchor) / tick_dt + auto pos = snap.sample_position(e); // interpolated 2D position + // ... render `pos` (projection-correct rendering is M2's job) +} +``` + +## The first built-in component: `Position2D` + +`laige::Position2D` — the entity's 2D simulation-space +position as the selected SimMath backend's `Vec2` (ADR 0002). The +two `LAIGE_COMPONENT` marks register both instantiations: + +- `Position2DFpx16` — the default backend (fpx16_16, Q16.16): + deterministic/lockstep zones; +- `Position2DFp32` — the opt-in IEEE-float backend (fp32_pinned) for + float zones. + +It is the first built-in component of `laige-sim`: a trivially +copyable 16-byte data carrier (one `Vec2`), registered per world like +any component (`World::registerComponent`). The 2.5D depth axis +(`z`) stays presentation-side (PRD §4): the simulation logic is 2D, +and M2's camera/projection is the boundary that adds the third +dimension (RENDER-006). + +## The per-tick snapshot production (FR-1.1, ARCH-009) + +`PresentationSnapshot::onTick(tick)` refreshes, for **every +live entity with a `Position2D`**: + +- `prev ← curr` (the end-of-tick T−1 value), +- `curr ← the world's current value` (the end-of-tick T value), + +and records `lastTick = T`. The refresh is O(entities with a +`Position2D`) over the bounded archetype scan (`World::each`) — one +visit per entity, no allocation (the `SlotRecord` table is pre- +reserved; see Performance). + +The `GameLoop` drives it: `Options::onTick` (game_loop.md) fires +**after every completed tick** — a failed tick is not counted and +does not fire the hook (the state it would have captured never +happened; the previous `prev`/`curr` are retained). Headless tests +drive `onTick` manually with the same world and tick numbers. + +The snapshot **never mutates the world**: `prev`/`curr` are pure +copies of authoritative state (ARCH-009). Interpolation reads +authoritative state; it never writes it. + +## New entities snap to `curr` + +An entity first seen at a refresh — created before the first tick, or +added **between** ticks (after the last `onTick`, before the next +one) — has no end-of-tick T−1 state: both `prev` and `curr` are set +to its **current** value. It renders at its spawn position (no +phantom interpolation from an older state), and from the second tick +after creation it interpolates normally (`sample_position` returns +the exact current value until then — the +`EntityAddedBetweenTicksSnapsToCurr` test pins it). + +The same rule re-applies after a slot recycle: `destroy()` bumps the +slot's generation, so a new occupant of a recycled slot is a new +entity (it snaps). The record stores the occupant's generation at +activation and checks it on every refresh — slot recycling +self-heals. (The 2^16 generation wraparound carries the same +accepted caveat as the entity handles themselves, entity.md.) + +## The alpha contract (exact integer math, clamped, ARCH-010) + +The anchor of tick `T` is the clock time at which tick `T` is due — +the same exact rational the `GameLoop`'s due computation uses +(game_loop.md, "The exact due computation"): + +``` +A(T) = startNs + T × 10⁹ / rate (nanoseconds, rational) +``` + +where `startNs` is the loop's start reference (the clock reading of +the loop's first frame — `GameLoop::startReferenceNs()`). At render +time `R`, after `lastTick = T` completed ticks, the presented state +lags the simulation by exactly one tick (the classic fixed-timestep +interpolation — it never shows a state the simulation has not yet +produced): + +``` +alpha = (R − A(T)) / tick_dt = (R − A(T)) × rate / 10⁹ ∈ [0, 1) +``` + +- `alpha = 0` at `R = A(T)` (render `prev` — the end-of-tick T−1 + state); +- `alpha → 1` as `R → A(T+1)` (render `curr` — the end-of-tick T + state). + +The arithmetic is **exact integer math** — no floating accumulator +(ARCH-010): with `elapsed = R − startNs` split as +`seconds`/`remainder`, + +``` +alpha × 10⁹ = (seconds × rate − T) × 10⁹ + remainder × rate +``` + +and the seconds/remainder split keeps every intermediate product +overflow-free for any 64-bit clock reading (the `ticksDue` +precedent, game_loop.cpp). The result is **clamped to [0, 1] — +never extrapolates**: + +| Render time `R` (with `lastTick = T`) | Result | +|---|---| +| `R < A(T)` (earlier than the tick's due time — mismatched start reference / non-monotonic render clock) | alpha clamps to **0** (render `prev`) | +| `A(T) ≤ R < A(T+1)` (the normal window) | the exact fraction — preserved | +| `R ≥ A(T+1)` (a full tick or more past the anchor — a clock jump) | alpha clamps to **1** (render `curr`) | +| before the first completed tick | alpha **0**, every sample snaps (no refresh has run) | + +A clamped sample still lies on the segment between the two known +states: the lerp of `prev`/`curr` never produces a position the +simulation did not occupy. + +**Time base:** `R` (the `onRenderFrame` argument) and `startNs` must +be on the **same monotonic epoch** as the loop's clock +(`GameLoop::Options::nowNs`). The engine reads the clock **once per +frame** and passes the same reading to both the loop and the +snapshot (the M1-HEAD-01 wiring). A backward or off-base reading is +a wiring misuse — the clamp bounds the damage to a stale-but-bounded +sample, never undefined behavior (a reading below `startNs` clamps to +the start). + +**Storage:** the alpha is stored as the backend scalar — one +documented rounding per backend (`detail::AlphaConversion`): +Fp32Pinned, one binary32 division (the pinned IEEE op); Fpx16_16, one +integer round-to-nearest into Q16.16 raw units (no float +intermediate). The alpha is a **wall-clock fact** (the render time): +non-deterministic by design, and never part of replay state or the +simulation state hash (ARCH-009/010; M1-DET-03 excludes it). + +## Sample semantics (`sample_position`) + +`PresentationSnapshot::sample_position(e)` — the name is the +roadmap's exact API; the engine is otherwise camelCase: + +| Input | Result | +|---|---| +| live handle + synced record (refreshed after its last tick) | `lerp(prev, curr, alpha)` — SimMath ops only (S-7, ADR 0002) | +| live handle, first seen since the last `onTick` (added between ticks) | **SNAP**: the entity's current `Position2D` value (the documented "new entities snap to `curr`") | +| stale/invalid handle | `InvalidArgument` + **warn-once** (`ecs/stale_entity_access`, the `World::check` precedent — FR-12.3: never silent) | +| live handle **without** a `Position2D` | `InvalidArgument` — a negative query, like `has()` reading false: no warn | +| moved-from snapshot | `InvalidArgument` — no world access, no logging (the `GameLoop` moved-out precedent) | + +The lerp is the SimMath backend's `lerp` (exactly +`a + (b − a) × t`, ADR 0002) — linear in `alpha` by construction; +the `LinearInterpolationBetweenTicks` test pins the exact Q16.16 +results at alpha 0, 0.5, 0.75, and the single rounding of a near-1 +alpha. + +## Configuration and validation (create) + +`PresentationSnapshot::create(world, startReferenceNs, +options)`: + +| Parameter | Contract | Reject | +|---|---|---| +| `world` | outlives the snapshot (non-owning view) | — | +| `startReferenceNs` | the loop's start reference after its first frame | a mismatch shifts the anchor; the clamp bounds it to a stale-but-valid sample (no reject) | +| `options.tickRateHz` | 20–120 Hz (`kMinTickRateHz`–`kMaxTickRateHz`, the loop's documented range, FR-1.1); must **equal** the driven loop's rate (the engine's wiring guarantee) | `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid` (field `tick_rate_hz`) | + +The allocation is one per-world `SlotRecord` table, reserved **once +at `create()`** from the world's capacity (a setup path, never a hot +path). A zero-capacity world yields a zero-size table (legal — +C++20 [ptr.arith]). + +## Wiring with the `GameLoop` (M1-HEAD-01 shape) + +- **`GameLoop::Options::onTick` / `onTickContext`** — a + `noexcept` callback fired after every **completed** tick with the + tick number; the engine wraps + `PresentationSnapshot::onTick` in a static thunk behind the + `void*` context (the headless engine does exactly this). The + callback must not allocate or block (PERF-002/003) — the snapshot's + `onTick` is bounded and allocation-free. +- **`GameLoop::startReferenceNs()`** — the loop's clock reading at + its first frame: the anchor base for the snapshot's alpha. Read it + **after** the loop's first frame (before, it is 0 and undefined- + useful). +- **`onRenderFrame(nowNs)`** — called once per frame with the + frame's clock reading, **after** `loop.frame()`. + +## Ownership, threading, lifetime + +The snapshot holds a **non-owning** `World` view (the world +outlives the snapshot — the engine owns both; the `GameLoop` +precedent). One owner thread (PRD §10.2: the simulation thread); not +thread-safe, no synchronization. Move is an O(1) pointer swap: a +moved-from snapshot is **stopped** — every operation fails with +`InvalidArgument`, no world access, no logging (the `GameLoop` +moved-out precedent). Copies are deleted (the unique backing table). + +## Determinism scope (ARCH-009/010) + +The `prev`/`curr` capture is a pure function of the tick sequence +(bit-exact under the backend's policy — ADR 0002): replaying the same +ticks reproduces the records exactly. The **alpha is not +deterministic** — it is the render-time fact (wall clock), and it is +never part of the simulation state hash or replay state +(M1-DET-03). `sample_position`'s lerp is deterministic **given** the +alpha (backend-exact, ADR 0002). + +## Performance (DOC-004) + +- **Per tick (`onTick`):** one `World::each` pass — + O(1) bookkeeping per visiting entity (a `prev ← curr` copy, two + store writes), plus the bounded archetype scan (the + `World::each` cost, query.md). **No allocation, no logging** + (PERF-003, LOG-003) — the table is pre-reserved at `create()`. +- **Per frame (`onRenderFrame`):** a few 64-bit integer ops (the + exact alpha computation, the clamp) — O(1), no allocation, no + logging. +- **Per sample (`sample_position`):** O(1) — the handle check + (`World::check`, warn-once on stale), the component lookup + (`World::get`, O(1) in the entity count), and one 2D lerp + (two backend ops). No allocation; logging only on the stale-handle + warn path (rate-limited). +- **Memory:** one `SlotRecord` per entity slot of the world's + capacity — 24 bytes each (two 8-byte `Vec2`s + generation + flag, + both backends), reserved once at setup. A 10k-entity world holds + ~240 KB — bounded by the scene budget (G-R3), never grown per + frame. +- **No per-frame heap** on any of the three paths: the + `HealthyFramesAllocateNothing` test asserts `allocs == 0` over a + 500-entity × 100-frame refresh/sample window (test-only operator- + new counter; the sanitizer trees prove the same loop leak-free). + +## Threading (CONC-001, PRD §10.2) + +The snapshot has exactly **one owner thread**: `onTick`/ +`onRenderFrame`/`sample_position` run on the world's single owner +thread, strictly interleaved with the tick and frame phases — +`onTick` inside the tick (after the systems), `onRenderFrame` after +`loop.frame()`, `sample_position` in the render phase. The non- +owning world view points at single-owner state and is never +dereferenced off-thread. + +## Misuse warnings + +- **`tickRateHz` must equal the driven loop's rate.** `create()` + validates the shared 20–120 Hz range; equality is the engine's + wiring guarantee — a rate mismatch makes the alpha wrong in a way + the clamp cannot fix (a stale-but-bounded sample). +- **`startReferenceNs` must be the loop's** — + `loop.startReferenceNs()` read after the loop's first frame. A + mismatch shifts the anchor; the clamp bounds the result (a + stale-but-valid sample), never UB. +- **`onRenderFrame`'s reading must come from the same monotonic + clock the loop reads** — the engine reads it once per frame and + passes it to both. A backward reading (below the start reference) + clamps to the start; an off-base reading yields a stale sample. +- **One snapshot per world is the intended wiring.** Multiple + snapshots on one world are independent (each drives its own + refresh) — not a failure, just redundant work. +- **`sample_position` is a render-phase query**: it reads the + `prev`/`curr` pair and the alpha — call it after + `onRenderFrame` for the frame's state, never to drive simulation + (ARCH-009: presentation never feeds back into authoritative state). diff --git a/laige-api.json b/laige-api.json index 2a8b6ca..31f0412 100644 --- a/laige-api.json +++ b/laige-api.json @@ -16,6 +16,7 @@ "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/presentation.h", "src/laige-sim/include/laige/sim/query.h", "src/laige-sim/include/laige/sim/system.h" ], @@ -484,31 +485,56 @@ {"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::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 253, "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": 256, "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": 258, "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": 268, "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": 280, "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": 281, "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": 282, "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": 283, "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": 284, "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": 290, "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": 295, "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": 298, "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": 301, "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": 307, "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": 308, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::TickFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 316, "signature": "using TickFn = void (*)(void* context, World& world, std::uint64_t tick) noexcept", "summary": "Optional per-completed-tick callback (M1-LOOP-02; see the preamble \"Per-tick presentation hook\"): fires after every completed tick as onTick(context, world, tick). nullptr (default): no hook (the M1-LOOP-01 behavior). Plain function pointer — no std::function (PERF-006); the callback must be bounded and allocation-free (the snapshot's onTick is the reference contract).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 318, "signature": "TickFn onTick{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTickContext", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 321, "signature": "void* onTickContext{nullptr}", "summary": "The onTick callback's user context (opaque; must outlive the loop — the engine passes the PresentationSnapshot, M1-HEAD-01).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 333, "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": 351, "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": 355, "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::startReferenceNs", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 364, "signature": "[[nodiscard]] std::int64_t startReferenceNs() const noexcept", "summary": "The clock reading that established the start reference (0 before the first frame) — the time-base origin of the due computation (the preamble \"The exact due computation\"). The M1-LOOP-02 PresentationSnapshot takes this as its start reference (presentation.h: the tick anchors A(T) = startNs + T × 10⁹ / rate must use the loop's own time base). 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": 367, "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": 371, "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": 376, "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": 382, "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": 383, "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": 384, "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": 385, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 257, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, + {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 259, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 262, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Position2DFpx16", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 267, "signature": "using Position2DFpx16 = Position2D", "summary": "The two backend instantiations: a game registers the one matching its init-time backend selection (ADR 0002, `determinism.math`).", "budget": null, "experimental": false}, + {"name": "laige::Position2DFp32", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 268, "signature": "using Position2DFp32 = Position2D", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot", "kind": "class", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 274, "signature": "template class PresentationSnapshot", "summary": "PresentationSnapshot — the per-tick presentation state (M1-LOOP-02)", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Vec2", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 277, "signature": "using Vec2 = sim::SimMath::Vec2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Scalar", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 278, "signature": "using Scalar = sim::SimMath::Scalar", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 284, "signature": "struct Options", "summary": "The typed configuration (API-006): the tick rate, validated to the loop's documented 20–120 Hz range at create() — it must EQUAL the driven GameLoop's rate (the preamble \"Misuse warnings\").", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 285, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 296, "signature": "[[nodiscard]] static Result create(World& world, std::int64_t startReferenceNs, Options options) noexcept", "summary": "Setup path (the only backing allocation: the per-slot record table, sized by world.capacity()). The world outlives the snapshot. startReferenceNs is the driven GameLoop's start reference (0 before the loop's first frame; the preamble \"alpha contract\"). Rejection: tickRateHz outside 20–120 → ErrorCode::InvalidArgument + one rate-limited warn (presentation/tick_rate_invalid) — FR-12.3/CORE-008, never silent.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::onTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 309, "signature": "void onTick(std::uint64_t tick) noexcept", "summary": "One COMPLETED tick (the GameLoop's onTick hook fires this after every completed tick; tests may drive it manually): rolls prev ← curr and refreshes curr from the world's current Position2D values (the preamble \"per-tick snapshot production\"). Cost: O(bounded archetype scan + matching live entities), no allocation, no logging (LOG-003). A rejected refresh (a nested iteration — a caller misuse) leaves the previous tick's prev/curr in place (the guard's event carries the failure); lastTick still records the tick.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::onRenderFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 315, "signature": "void onRenderFrame(std::int64_t renderNs) noexcept", "summary": "One presentation frame: recomputes the stored alpha from renderNs (the preamble \"alpha contract\"): exact integer anchor arithmetic, clamped to [0, 1] (never extrapolates). A few integer ops; no allocation, no logging.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::alpha", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 320, "signature": "[[nodiscard]] Scalar alpha() const noexcept", "summary": "The frame's interpolation alpha (the backend scalar in [0, 1]; 0 before the first completed tick). The M1-PROF-01 / debug overlay feed.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::lastTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 324, "signature": "[[nodiscard]] std::uint64_t lastTick() const noexcept", "summary": "The number of completed ticks the snapshot has seen (0 before the first onTick). The M1-PROF-01 feed; the engine's sync check.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::sample_position", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 332, "signature": "[[nodiscard]] Result sample_position(Entity e) const noexcept", "summary": "The interpolated 2D position of `e` (the preamble \"Sample semantics\"): lerp(prev, curr, alpha) for a synced entity, the current value for one added between ticks (snaps), Invalid- Argument for stale handles (warn-once) and live handles without a Position2D. Cost: O(1) (handle check + component lookup + one 2D lerp); no allocation, no logging (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 337, "signature": "PresentationSnapshot(PresentationSnapshot&& other) noexcept", "summary": "Move: O(1) pointer swap; the moved-from snapshot is stopped (every operation fails with InvalidArgument; no world access, no logging — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 338, "signature": "PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 341, "signature": "PresentationSnapshot(const PresentationSnapshot&) = delete", "summary": "No copies (the unique backing table).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 342, "signature": "PresentationSnapshot& operator=(const PresentationSnapshot&) = 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}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 85aebc3..7606e2e 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -137,7 +137,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R game_loop` green. - **Size:** ~200 lines + tests -- [ ] **M1-LOOP-02 · Presentation snapshot + interpolation state** +- [x] **M1-LOOP-02 · Presentation snapshot + interpolation state** - **Refs:** FR-1.1 (render interpolation, 2D-aware); PRD §4 (depth is presentation-only) - **Depends:** M1-LOOP-01, M1-ECS-04 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 9e26cb3..90b9de8 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 | 11 | 🚧 in progress (M1-LOOP-01) | +| M1 | 25 | 12 | 🚧 in progress (M1-LOOP-02) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **31** | | +| **Total** | **193** | **32** | | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index f9afe03..30bc250 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -38,7 +38,14 @@ # 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). +# in include/laige/sim/game_loop.h). M1-LOOP-02 adds +# include/laige/sim/presentation.h (header-only — a class template, +# the M1-ECS-02 pattern): the first built-in component (Position2D, +# one template instantiation per SimMath backend, ADR 0002) and the +# PresentationSnapshot (per-tick prev/curr presentation state, the +# exact-integer clamped alpha, sample_position — no new .cpp; the +# GameLoop gains the Options::onTick per-completed-tick hook and +# startReferenceNs() in game_loop.h/.cpp to drive it). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 43b030e..c5aa721 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -73,5 +73,16 @@ 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`). +M1-LOOP-02 landed the per-tick presentation snapshot + +interpolation state — `Position2D` (the first built-in component, +both SimMath backends), `PresentationSnapshot` (the per-completed- +tick `prev`/`curr` capture, the exact-integer anchored alpha clamped +to [0, 1] — never extrapolates — and `sample_position`, new +entities snap to `curr`), the `GameLoop`'s `onTick` hook + +`startReferenceNs` (the M1-HEAD-01 wiring shape), header-only (the +class-template pattern, ADR 0002) (`include/laige/sim/presentation.h` ++ the `game_loop.h`/`game_loop.cpp` hook; API contract in +[docs/api/presentation.md](../docs/api/presentation.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `presentation`). The profiler, determinism/replay, headless engine, and the remaining M1 steps land next; physics, input, and animation in M3. diff --git a/src/laige-sim/game_loop.cpp b/src/laige-sim/game_loop.cpp index 6dd1254..44aba4c 100644 --- a/src/laige-sim/game_loop.cpp +++ b/src/laige-sim/game_loop.cpp @@ -210,15 +210,26 @@ 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"). + // behavior"); the onTick hook (M1-LOOP-02, preamble "Per-tick + // presentation hook") fires only for completed ticks — a failed + // tick's state never completed, so nothing observes it. world_->beginFrame(); const Status s = world_->runSystems(*schedule_); - if (s.ok()) ++ticks_; + if (s.ok()) { + ++ticks_; + if (options_.onTick != nullptr) { + options_.onTick(options_.onTickContext, *world_, ticks_); + } + } return s; } std::uint64_t GameLoop::currentTick() const noexcept { return ticks_; } +std::int64_t GameLoop::startReferenceNs() const noexcept { + return startNs_; // 0 before the first frame established the base +} + std::uint32_t GameLoop::tickRateHz() const noexcept { return options_.tickRateHz; } diff --git a/src/laige-sim/include/laige/sim/game_loop.h b/src/laige-sim/include/laige/sim/game_loop.h index eec58b5..1e7c9c2 100644 --- a/src/laige-sim/include/laige/sim/game_loop.h +++ b/src/laige-sim/include/laige/sim/game_loop.h @@ -122,6 +122,28 @@ // tick). // // --------------------------------------------------------------------------- +// Per-tick presentation hook (M1-LOOP-02) +// --------------------------------------------------------------------------- +// +// Options::onTick (a plain function pointer — no std::function, +// PERF-006) fires ONCE per COMPLETED tick, after the tick's system +// phase, as onTick(context, world, tick) with the completed tick +// number (1-based, == currentTick() after the call). A failed tick +// is not counted and does NOT fire the hook (the state it would +// have observed never completed — the preamble "Failure behavior"). +// The hook is the M1-LOOP-02 PresentationSnapshot's per-tick refresh +// driver (presentation.h: the engine wires the snapshot's onTick +// through this callback); M1-HEAD-01 owns the wiring. nullptr +// (the default) is the M1-LOOP-01 behavior — no hook. +// +// The callback runs inside frame(), on the loop's owner thread, in +// the system phase (API-004), strictly between two ticks of the +// frame (world mutations there are legal — no iteration is active): +// it must be bounded and allocation-free on the success path (the +// snapshot's onTick is the reference contract — one bounded +// archetype scan, no allocation, no logging, PERF-002/003). +// +// --------------------------------------------------------------------------- // Failure behavior (CORE-008) // --------------------------------------------------------------------------- // @@ -211,6 +233,11 @@ // 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). +// - The onTick callback is loop-owned for the loop's lifetime: +// its context must outlive the loop (the non-owning-view +// precedent). A callback that blocks or allocates breaks the +// frame budget (PERF-002/003) — the snapshot's onTick is the +// reference contract. #pragma once @@ -279,6 +306,19 @@ class GameLoop { // LoggerOptions::ClockFn precedent). using ClockFn = std::int64_t (*)(); ClockFn nowNs{nullptr}; + // Optional per-completed-tick callback (M1-LOOP-02; see the + // preamble "Per-tick presentation hook"): fires after every + // completed tick as onTick(context, world, tick). nullptr + // (default): no hook (the M1-LOOP-01 behavior). Plain function + // pointer — no std::function (PERF-006); the callback must be + // bounded and allocation-free (the snapshot's onTick is the + // reference contract). + using TickFn = void (*)(void* context, World& world, + std::uint64_t tick) noexcept; + TickFn onTick{nullptr}; + // The onTick callback's user context (opaque; must outlive the + // loop — the engine passes the PresentationSnapshot, M1-HEAD-01). + void* onTickContext{nullptr}; }; // Construct the loop on `world` running `schedule` (setup phase, @@ -314,6 +354,15 @@ class GameLoop { // tick to complete is tick 1). O(1), no side effects. [[nodiscard]] std::uint64_t currentTick() const noexcept; + // The clock reading that established the start reference (0 + // before the first frame) — the time-base origin of the due + // computation (the preamble "The exact due computation"). The + // M1-LOOP-02 PresentationSnapshot takes this as its start + // reference (presentation.h: the tick anchors A(T) = startNs + + // T × 10⁹ / rate must use the loop's own time base). O(1), no + // side effects. + [[nodiscard]] std::int64_t startReferenceNs() const noexcept; + // The configured tick rate (Hz). O(1), no side effects. [[nodiscard]] std::uint32_t tickRateHz() const noexcept; @@ -343,7 +392,10 @@ class GameLoop { // 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. + // dispatch. A successful tick is counted and (if configured, + // M1-LOOP-02) fires the Options::onTick hook after the system + // phase; a failed tick is neither — the hook observes only + // completed ticks. [[nodiscard]] Status runOneTick() noexcept; // Non-owning views (the world and the schedule outlive the loop). diff --git a/src/laige-sim/include/laige/sim/presentation.h b/src/laige-sim/include/laige/sim/presentation.h new file mode 100644 index 0000000..291e21e --- /dev/null +++ b/src/laige-sim/include/laige/sim/presentation.h @@ -0,0 +1,568 @@ +// laige-sim presentation snapshot + interpolation state (M1-LOOP-02). +// +// FR-1.1 (render interpolation of positions — the 2D-aware half; the +// projection-correct rendering is M2); ARCH-009 (presentation state +// is separated from authoritative simulation state — interpolation +// MUST NOT mutate authoritative results); PRD §4 (depth is +// presentation-only: the logical simulation is 2D). This header ships +// the M1 presentation-state half of the game loop: +// +// Position2D +// The FIRST built-in component: the entity's 2D +// simulation-space position as the selected +// SimMath backend's Vec2 (ADR 0002 — one template +// instantiation per backend, factory-selected at +// engine init). The two LAIGE_COMPONENT marks +// below register both instantiations (the default +// fpx16_16 for deterministic/lockstep zones, +// fp32_pinned for opt-in IEEE-float zones). +// PresentationSnapshot +// The per-tick presentation state: for every live +// entity with a Position2D, the `prev`/`curr` +// position pair (the world's state at the end of +// the last two completed ticks) plus the frame's +// interpolation alpha: +// +// alpha = (render_time − last_tick) / tick_dt +// +// clamped to [0, 1] — never extrapolates (see the +// "alpha contract" below). `sample_position(e)` +// returns the interpolated 2D position — pure 2D +// math here (projection-correct rendering is +// M2's job). +// +// --------------------------------------------------------------------------- +// The per-tick snapshot production (FR-1.1, ARCH-009) +// --------------------------------------------------------------------------- +// +// The snapshot is refreshed ONCE PER COMPLETED TICK: +// +// onTick(world, tick) for every live entity with a Position2D: +// prev ← curr (the end-of-tick T−1 value), +// curr ← the world's current value (the +// end-of-tick T value). +// +// The GameLoop drives it: Options::onTick (game_loop.h) fires after +// every COMPLETED tick (a failed tick is not counted and does not +// fire — the state it would have captured never happened). Headless +// tests (and the M1-HEAD-01 engine) may drive onTick manually with +// the same world and tick numbers. +// +// **New entities snap to curr** (documented scope behavior): an +// entity first seen at a refresh — created before the first tick, or +// added BETWEEN ticks (after the last onTick, before the next one) — +// has no end-of-tick T−1 state, so both prev and curr are set to its +// current value. It renders at its spawn position (no phantom +// interpolation from an older state), and from the second tick after +// its creation it interpolates normally. The same rule re-applies +// after a slot recycle: destroy() bumps the slot's generation, so a +// new occupant of a recycled slot is a new entity (snaps) — the +// record's stored generation is checked against the handle's on +// every refresh (the 2^16 wraparound case carries the same accepted +// caveat as the entity handles themselves, entity.h). +// +// The snapshot NEVER mutates the world: the prev/curr pair is a pure +// copy of authoritative state (ARCH-009). +// +// --------------------------------------------------------------------------- +// The alpha contract (FR-1.1: render_time − last_tick over tick_dt) +// --------------------------------------------------------------------------- +// +// The anchor of tick T is the clock time at which tick T is DUE — +// the same exact rational the GameLoop's due computation uses +// (game_loop.h "The exact due computation"): +// +// A(T) = startNs + T × 10⁹ / rate (nanoseconds, rational) +// +// where startNs is the loop's start reference (the clock reading of +// the loop's first frame — `GameLoop::startReferenceNs()`). At render +// time R, after lastTick = T completed ticks, the presented state +// lags the simulation by exactly one tick (the classic +// fixed-timestep interpolation — it never shows a state the +// simulation has not yet produced): +// +// alpha = (R − A(T)) / tickDt = (R − A(T)) × rate / 10⁹ ∈ [0, 1) +// +// alpha = 0 at R = A(T) (render prev — the end-of-tick T−1 state) +// alpha → 1 as R → A(T+1) (render curr — the end-of-tick T state) +// +// The arithmetic is EXACT integer math (ARCH-010: no floating +// accumulator): with elapsed = R − startNs split as seconds/remainder, +// +// alpha × 10⁹ = (seconds × rate − T) × 10⁹ + remainder × rate +// +// the seconds/remainder split keeps every intermediate product +// overflow-free (the ticksDue precedent, game_loop.cpp). The result +// is CLAMPED to [0, 1] — **never extrapolates**: +// +// - R below the anchor (a render reading earlier than the tick's +// due time — a mismatched start reference or a non-monotonic +// render clock): alpha clamps to 0 (render prev). +// - R at or beyond the next anchor (a render reading a full tick or +// more past the anchor — a clock jump): alpha clamps to 1 (render +// curr). +// +// A clamped sample still lies on the segment between the two known +// states — interpolation of prev/curr never produces a position the +// simulation did not occupy. Before the first completed tick the +// alpha is 0 (and every sample snaps, since no refresh has run). +// +// **Time base:** R (onRenderFrame's argument) and startNs must be on +// the SAME monotonic epoch as the loop's clock (GameLoop::Options:: +// nowNs). The engine passes the frame's clock reading; a backward or +// off-base reading is a wiring misuse — the clamp bounds the damage +// (a stale-but-bounded sample), never UB. +// +// **Storage:** the alpha is stored as the backend scalar (one +// documented rounding per backend — `detail::AlphaConversion`: +// Fp32Pinned, one binary32 division; Fpx16_16, one integer +// round-to-nearest into Q16.16). The alpha is a wall-clock fact (the +// render time): it is NON-deterministic by design and never part of +// replay state or the simulation state hash (ARCH-009/010). +// +// --------------------------------------------------------------------------- +// Sample semantics (sample_position) +// --------------------------------------------------------------------------- +// +// live handle + synced record (refreshed after its last tick) +// → lerp(prev, curr, alpha) — SimMath ops only (S-7, ADR 0002) +// live handle, first seen since the last onTick (added between +// ticks) → SNAP: the entity's current Position2D value +// (documented: new entities snap to curr) +// stale/invalid handle → ErrorCode::InvalidArgument + warn-once +// (the World::check precedent, FR-12.3: never silent) +// live handle WITHOUT a Position2D → ErrorCode::InvalidArgument +// (a negative query, like has() reading false — no warn) +// moved-from snapshot → ErrorCode::InvalidArgument +// (no world access, no logging — the GameLoop moved-out precedent) +// +// --------------------------------------------------------------------------- +// Storage and allocation (PERF-003, S-2) +// --------------------------------------------------------------------------- +// +// One SlotRecord per entity slot of the world (direct index by +// slot id — O(1), no hashing, no unordered containers, the World +// per-slot-table precedent, M1-ECS-01). The table is reserved ONCE at +// create() from the world's capacity (a setup path, never a hot path) +// — no per-tick or per-frame heap. sizeof(SlotRecord) is 24 bytes +// (two 8-byte Vec2s + generation + flag, both backends). +// +// --------------------------------------------------------------------------- +// Ownership, threading, lifetime +// --------------------------------------------------------------------------- +// +// The snapshot holds a NON-OWNING World view (the world outlives the +// snapshot — the engine owns both; the GameLoop's non-owning-view +// precedent). One owner thread (PRD §10.2: the simulation thread); +// not thread-safe, no synchronization. Move is an O(1) pointer swap; +// a moved-from snapshot is stopped (every operation fails with +// InvalidArgument, no world access, no logging). Copies are deleted. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Options::tickRateHz must EQUAL the driven GameLoop's tick rate +// (create() validates the shared 20–120 Hz range; equality is the +// engine's wiring guarantee — a rate mismatch makes the alpha +// wrong in a way the clamp cannot fix). +// - create()'s startReferenceNs must be the loop's start reference +// (`loop.startReferenceNs()` after the loop's first frame). A +// mismatch shifts the anchor; the clamp bounds the result to a +// stale-but-valid sample. +// - onRenderFrame()'s renderNs must come from the same monotonic +// clock the loop reads (the engine reads it once per frame and +// passes it to both — M1-HEAD-01 wiring). +// - One snapshot per world is the intended wiring; multiple +// snapshots on one world are independent (each drives its own +// refresh), not a failure. + +#pragma once + +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim_math.h" +#include "laige/sim/entity.h" +#include "laige/sim/game_loop.h" + +namespace laige { + +// --------------------------------------------------------------------------- +// The per-backend alpha conversion (detail: not public API) +// --------------------------------------------------------------------------- + +namespace detail { + +// The exact alpha numerator (alpha × 10⁹, an integer in [0, 10⁹]) as +// the backend scalar — one documented rounding per backend: +// Fp32Pinned: one binary32 division (the pinned IEEE op, ADR 0002). +// Fpx16_16: one integer round-to-nearest (half-up) into Q16.16 +// raw units — no float intermediate (the exact rational +// alphaNum/10⁹ maps to ≤ 65536 raw = 1.0, in range). +template +struct AlphaConversion { + static sim::SimMath::Scalar toScalar(std::uint32_t alphaNum) noexcept; +}; + +template <> +struct AlphaConversion { + static float toScalar(std::uint32_t alphaNum) noexcept { + return static_cast(alphaNum) / 1e9f; + } +}; + +template <> +struct AlphaConversion { + static fpx16_16 toScalar(std::uint32_t alphaNum) noexcept { + const std::int64_t scaled = static_cast(alphaNum) << 16; + const std::int64_t raw = (scaled + 500000000LL) / 1000000000LL; + return fpx16_16{static_cast(raw)}; + } +}; + +// The per-slot record of the presentation snapshot (detail: the +// backing table's layout, not public API). One per entity slot of +// the world (direct index by slot id — O(1), no hashing, no +// unordered containers; the World per-slot-table precedent, +// M1-ECS-01). The table is reserved once at create() — no per- +// tick/per-frame heap (PERF-003). +template +struct SlotRecord { + sim::SimMath::Vec2 prev{}; // the end-of-tick T−1 value + sim::SimMath::Vec2 curr{}; // the end-of-tick T value + std::uint16_t generation{}; // the occupant's generation at activation + bool active{}; +}; + +} // namespace detail + +// --------------------------------------------------------------------------- +// Position2D — the first built-in component (M1-LOOP-02) +// --------------------------------------------------------------------------- + +// The entity's 2D simulation-space position (the ground plane — +// PRD §4; the axes/units contract lands with the concepts docs). The +// value is the selected SimMath backend's Vec2 (ADR 0002: one +// template instantiation per backend, factory-selected at engine +// init). A data carrier (S-8): trivially copyable, no behavior — +// the LAIGE_COMPONENT marks below register both instantiations in +// the same path as user components (M1-ECS-02). +template +struct Position2D { + sim::SimMath::Vec2 pos{}; +}; + +LAIGE_COMPONENT(Position2D); +LAIGE_COMPONENT(Position2D); + +// The two backend instantiations: a game registers the one matching +// its init-time backend selection (ADR 0002, `determinism.math`). +using Position2DFpx16 = Position2D; +using Position2DFp32 = Position2D; + +// --------------------------------------------------------------------------- +// PresentationSnapshot — the per-tick presentation state (M1-LOOP-02) +// --------------------------------------------------------------------------- + +template +class PresentationSnapshot { + public: + using Vec2 = sim::SimMath::Vec2; + using Scalar = sim::SimMath::Scalar; + + // The typed configuration (API-006): the tick rate, validated to + // the loop's documented 20–120 Hz range at create() — it must + // EQUAL the driven GameLoop's rate (the preamble "Misuse + // warnings"). + struct Options { + std::uint32_t tickRateHz{kDefaultTickRateHz}; + }; + + // Setup path (the only backing allocation: the per-slot record + // table, sized by world.capacity()). The world outlives the + // snapshot. startReferenceNs is the driven GameLoop's start + // reference (0 before the loop's first frame; the preamble "alpha + // contract"). Rejection: tickRateHz outside 20–120 → + // ErrorCode::InvalidArgument + one rate-limited warn + // (presentation/tick_rate_invalid) — FR-12.3/CORE-008, never + // silent. + [[nodiscard]] static Result + create(World& world, std::int64_t startReferenceNs, + Options options) noexcept; + + // One COMPLETED tick (the GameLoop's onTick hook fires this after + // every completed tick; tests may drive it manually): rolls prev + // ← curr and refreshes curr from the world's current Position2D + // values (the preamble "per-tick snapshot production"). Cost: + // O(bounded archetype scan + matching live entities), no + // allocation, no logging (LOG-003). A rejected refresh (a nested + // iteration — a caller misuse) leaves the previous tick's + // prev/curr in place (the guard's event carries the failure); + // lastTick still records the tick. + void onTick(std::uint64_t tick) noexcept; + + // One presentation frame: recomputes the stored alpha from + // renderNs (the preamble "alpha contract"): exact integer anchor + // arithmetic, clamped to [0, 1] (never extrapolates). A few + // integer ops; no allocation, no logging. + void onRenderFrame(std::int64_t renderNs) noexcept; + + // The frame's interpolation alpha (the backend scalar in [0, 1]; + // 0 before the first completed tick). The M1-PROF-01 / debug + // overlay feed. + [[nodiscard]] Scalar alpha() const noexcept; + + // The number of completed ticks the snapshot has seen (0 before + // the first onTick). The M1-PROF-01 feed; the engine's sync check. + [[nodiscard]] std::uint64_t lastTick() const noexcept; + + // The interpolated 2D position of `e` (the preamble "Sample + // semantics"): lerp(prev, curr, alpha) for a synced entity, the + // current value for one added between ticks (snaps), Invalid- + // Argument for stale handles (warn-once) and live handles without + // a Position2D. Cost: O(1) (handle check + component lookup + one + // 2D lerp); no allocation, no logging (LOG-003). + [[nodiscard]] Result sample_position(Entity e) const noexcept; + + // Move: O(1) pointer swap; the moved-from snapshot is stopped + // (every operation fails with InvalidArgument; no world access, + // no logging — the GameLoop moved-out precedent). + PresentationSnapshot(PresentationSnapshot&& other) noexcept; + PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept; + + // No copies (the unique backing table). + PresentationSnapshot(const PresentationSnapshot&) = delete; + PresentationSnapshot& operator=(const PresentationSnapshot&) = delete; + + private: + PresentationSnapshot() noexcept = default; + + World* world_{nullptr}; // non-owning view (outlives the snapshot) + std::unique_ptr[]> records_; + std::size_t capacity_{0}; + std::int64_t startNs_{0}; + std::uint32_t rate_{kDefaultTickRateHz}; + std::uint64_t lastTick_{0}; + Scalar alpha_{}; + // Cleared on move-out: a stopped snapshot samples nothing. + bool valid_{true}; +}; + +// --------------------------------------------------------------------------- +// Implementation (header-defined: class template — the M1-ECS-02 +// pattern, like World::registerComponent) +// --------------------------------------------------------------------------- + +namespace detail { + +// Nanoseconds per second (the clock time base; CORE-005 named +// constant — same value and role as the GameLoop's, game_loop.cpp). +inline constexpr std::int64_t kPresentationNsPerSecond = 1000000000LL; + +// The stable subsystem name for presentation events (LOG-001). +inline constexpr const char* kPresentationSubsystem = "presentation"; + +} // namespace detail + +template +Result, ErrorCode> +PresentationSnapshot::create(World& world, + std::int64_t startReferenceNs, + Options options) noexcept { + if (options.tickRateHz < kMinTickRateHz || + options.tickRateHz > kMaxTickRateHz) { + // The tick rate must lie in the loop's documented range (FR-1.1) + // and equal the driven loop's rate (the preamble "Misuse + // warnings"). One rate-limited structured warn (LOG-004). + LAIGE_LOG_WARN(detail::kPresentationSubsystem, "tick_rate_invalid", + "tick_rate_invalid | the presentation snapshot's tick " + "rate is outside the supported 20-120 Hz range | the " + "tick rate must match the driven GameLoop's rate | " + "pass the loop's tick rate (the default is 60) | " + "docs/api/presentation.md", + laige::log::field("tick_rate_hz", options.tickRateHz)); + return ErrorCode::InvalidArgument; + } + PresentationSnapshot snap; + snap.world_ = &world; + snap.capacity_ = world.capacity(); + // The setup-path allocation (PERF-003): one record per entity slot + // of the world (a zero-size table for a zero-capacity world is + // legal — C++20 [ptr.arith]). + snap.records_ = std::make_unique[]>(snap.capacity_); + snap.startNs_ = startReferenceNs; + snap.rate_ = options.tickRateHz; + return snap; +} + +template +void PresentationSnapshot::onTick(std::uint64_t tick) noexcept { + if (!valid_) return; + // Roll prev ← curr and refresh curr for every live entity with a + // Position2D (superset match: an entity matches when its component + // set CONTAINS Position2D — the snapshot tracks that component + // only, whatever else the entity carries). + // First sight (or a generation change after a slot recycle) snaps: + // prev = curr = the current value (the preamble "per-tick snapshot + // production"). + const Status s = world_->each>( + [this](Entity e, const Position2D& pos) { + detail::SlotRecord& rec = records_[e.id]; + if (rec.active && rec.generation == e.generation) { + rec.prev = rec.curr; + } else { + rec.prev = pos.pos; // new entity: snap to curr + } + rec.curr = pos.pos; + rec.generation = e.generation; + rec.active = true; + }, + Read{}); + if (s.ok()) { + lastTick_ = tick; + } else { + // A rejected refresh (a nested iteration — a caller misuse, e.g. + // onTick driven from inside another each's callback): zero + // entities were visited (the guard rejects before the first + // callback, query.h) and it already logged the failure + // (ecs/iteration_nested) — the previous tick's prev/curr and + // lastTick stay in place (a stale-but-bounded sample; the guard's + // event is the report, no duplicate logging, LOG-002). + } +} + +template +void PresentationSnapshot::onRenderFrame(std::int64_t renderNs) noexcept { + if (!valid_) return; + if (renderNs < startNs_) renderNs = startNs_; // no time before the base + const std::int64_t elapsedNs = renderNs - startNs_; + // alpha × 10⁹ = elapsed × rate − lastTick × 10⁹, computed in EXACT + // integer arithmetic with the seconds/remainder split (the ticksDue + // precedent, game_loop.cpp): every intermediate product stays in + // range (elapsed < 2^63 ns ≈ 292 years ⇒ seconds × rate ≤ ~1.1×10¹²; + // lastTick × 10⁹ is never formed). The wiring-correct value lies + // in [0, 10⁹); the clamps below bound every mismatch case to the + // documentable range (the preamble "alpha contract" — never + // extrapolates, never UB). + std::int64_t num = 0; + if (lastTick_ != 0) { + const std::int64_t seconds = elapsedNs / detail::kPresentationNsPerSecond; + const std::int64_t remainder = elapsedNs % detail::kPresentationNsPerSecond; + const std::int64_t ticksElapsed = seconds * static_cast(rate_); + const std::int64_t d = ticksElapsed - static_cast(lastTick_); + // alpha × 10⁹ = d × 10⁹ + remainder × rate. Branch bounds keep + // every product in range (overflow-free, CPP-004): + // d ≥ 10⁹ the render is a billion full ticks past the + // anchor (absurd clock jump): clamp to 1.0 + // without forming the product. + // d < −(rate−1) the sub-second remainder contributes at most + // rate−1 full due ticks (floor((10⁹−1)×rate/10⁹) + // = rate−1 for rate < 10⁹; worst case 119 over + // the validated 20–120 Hz range), so d below + // that is unambiguously before the anchor + // (the exact value is negative for every + // remainder): clamp to 0.0 without the product. + // otherwise d ∈ [−119, 10⁹−1]: |d × 10⁹| < 10¹⁸ and the + // remainder term < 1.2 × 10¹¹ — exact, then + // clamped to [0, 10⁹] (the wiring-correct value + // is already in range; the clamp bounds a + // broken wiring — clock jump / mismatched base). + if (d >= detail::kPresentationNsPerSecond) { + num = detail::kPresentationNsPerSecond; + } else if (d < -static_cast(kMaxTickRateHz - 1)) { + num = 0; + } else { + num = d * detail::kPresentationNsPerSecond + + remainder * static_cast(rate_); + if (num < 0) num = 0; + if (num > detail::kPresentationNsPerSecond) num = detail::kPresentationNsPerSecond; + } + } + alpha_ = detail::AlphaConversion::toScalar( + static_cast(num)); +} + +template +typename PresentationSnapshot::Scalar +PresentationSnapshot::alpha() const noexcept { + return valid_ ? alpha_ : Scalar{}; +} + +template +std::uint64_t PresentationSnapshot::lastTick() const noexcept { + return valid_ ? lastTick_ : 0; +} + +template +Result::Vec2, ErrorCode> +PresentationSnapshot::sample_position(Entity e) const noexcept { + if (!valid_) return ErrorCode::InvalidArgument; // stopped: no world access + if (!world_->isValid(e)) { + // Stale/invalid handle: the World::check precedent (FR-12.3) — + // warn-once through the facade's rate limiting, then fail. + static_cast(world_->check(e)); + return ErrorCode::InvalidArgument; + } + const Position2D* pos = world_->get>(e); + if (pos == nullptr) { + // A live handle without the component: a negative query (like + // has() reading false — no warn; the Result IS the report). + return ErrorCode::InvalidArgument; + } + const detail::SlotRecord& rec = records_[e.id]; + if (rec.active && rec.generation == e.generation) { + // Synced (refreshed after its last tick): the interpolated state. + return sim::SimMath::lerp(rec.prev, rec.curr, alpha_); + } + // First seen since the last onTick (added between ticks): snap to + // the current authoritative value (documented scope behavior — + // new entities snap to curr). + return pos->pos; +} + +template +PresentationSnapshot::PresentationSnapshot( + PresentationSnapshot&& other) noexcept + : world_(other.world_), + records_(std::move(other.records_)), + capacity_(other.capacity_), + startNs_(other.startNs_), + rate_(other.rate_), + lastTick_(other.lastTick_), + alpha_(other.alpha_), + valid_(other.valid_) { + other.world_ = nullptr; + other.capacity_ = 0; + other.lastTick_ = 0; + other.valid_ = false; +} + +template +PresentationSnapshot& +PresentationSnapshot::operator=( + PresentationSnapshot&& other) noexcept { + if (this != &other) { + world_ = other.world_; + records_ = std::move(other.records_); + capacity_ = other.capacity_; + startNs_ = other.startNs_; + rate_ = other.rate_; + lastTick_ = other.lastTick_; + alpha_ = other.alpha_; + valid_ = other.valid_; + other.world_ = nullptr; + other.capacity_ = 0; + other.lastTick_ = 0; + other.valid_ = false; + } + return *this; +} + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 6c7cd1c..4426153 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,5 +1,5 @@ # laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03 -# + M1-LOOP-01): +# + M1-LOOP-01/02): # entity handle + World entity storage, component type registry, # archetype SoA storage, query API + iteration legality, # deterministic iteration order, the ECS guardrails (G-R3/G-R4), the @@ -9,21 +9,24 @@ # 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). +# validation, the bounded catch-up + tick_dropped overload behavior), +# and the presentation snapshot + interpolation state (the per-tick +# prev/curr refresh, the anchored clamped alpha, sample_position). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus # gtest_main. The unfiltered entry runs the whole module; the `entity`, # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, -# `system_timing`, 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 +# `system_timing`, `game_loop`, and `presentation` entries are the +# M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, +# M1-ECS-07, M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, and +# M1-LOOP-02 Verify commands (`ctest -R entity`, `ctest -R +# component_registry`, `ctest -R archetype`, `ctest -R query`, +# `ctest -R iter_order`, `ctest -R ecs_guardrails`, `ctest -R +# ecs_stress`, `ctest -R system_registry`, `ctest -R scheduler`, +# `ctest -R system_timing`, `ctest -R game_loop`, `ctest -R +# presentation`), selecting exactly the suites below from the shared # executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp @@ -33,7 +36,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp system_registry_tests.cpp scheduler_tests.cpp system_timing_tests.cpp - game_loop_tests.cpp) + game_loop_tests.cpp + presentation_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, @@ -164,11 +168,21 @@ add_test(NAME game_loop COMMAND laige-sim_tests --gtest_filter=GameLoop.*) +# M1-LOOP-02: per-tick presentation snapshot + interpolation state +# (ARCH-009: presentation is never authoritative). The step's Verify +# command is `ctest -R presentation`; this entry selects exactly the +# Presentation suites from the shared laige-sim_tests executable (the +# machine-greppable presentation linear / presentation-zeroalloc +# lines land in the ctest output). +add_test(NAME presentation + COMMAND laige-sim_tests + --gtest_filter=Presentation.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. set_tests_properties(laige-sim_tests entity component_registry archetype query iter_order ecs_guardrails ecs_stress system_registry scheduler - system_timing game_loop PROPERTIES + system_timing game_loop presentation PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/presentation_tests.cpp b/tests/laige-sim/presentation_tests.cpp new file mode 100644 index 0000000..6126082 --- /dev/null +++ b/tests/laige-sim/presentation_tests.cpp @@ -0,0 +1,1017 @@ +// laige-sim presentation snapshot + interpolation state suite +// (M1-LOOP-02). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - interpolation is LINEAR between ticks (exact Q16.16 lerp values +// at alpha 0, 0.5, 0.75, and the Q16.16 rounding of a near-1 +// alpha — all expectations in exact raw units, no float +// round-trips) +// - the alpha CLAMPS to [0, 1] — never extrapolates (a render +// before the anchor clamps to 0; a clock jump a full tick or +// more past the anchor clamps to 1; exact values in between are +// preserved) +// - entities ADDED BETWEEN TICKS sample correctly — they snap to +// their current Position2D value (the documented scope behavior), +// then interpolate normally from the second tick after creation +// - the catch-up case: one frame running several ticks refreshes +// prev/curr per tick (the sample interpolates the LATEST tick's +// interval, not a multi-tick span) +// - the GameLoop's onTick hook drives the snapshot once per +// COMPLETED tick (a failed tick does not fire it); the +// startReferenceNs wiring keeps the alpha anchored to the loop's +// time base +// - sample_position rejects stale handles (warn-once, the +// World::check precedent) and live handles without a Position2D +// (a negative query, no warn) +// - create() validates the tick rate (20–120 Hz, the loop's range) +// with one rate-limited warn (presentation/tick_rate_invalid) +// - a moved snapshot transfers its state; the source is stopped +// (no world access, no logging) +// - no heap allocation on the per-frame refresh/sample path +// (test-only operator-new counter, non-sanitizer trees; the +// sanitizer trees prove the same loop leak-free) +// - the fp32_pinned backend instantiates the same contract (one +// linear-interpolation check on the float alpha path) +// +// Runs as CTest `presentation` (the step's Verify command: +// `ctest -R presentation`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites below. +// +// Q16.16 raw reference (value = raw / 65536): 0.5 = 32768, +// 0.75 = 49152, 1.0 = 65536, 1.5 = 98304, 1.75 = 114688, +// 2.0 = 131072, 2.5 = 163840, 3.5 = 229376, 6.5 = 425984. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim_math.h" +#include "laige/sim/entity.h" +#include "laige/sim/game_loop.h" +#include "laige/sim/presentation.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, + "presentation_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "presentation_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, + "presentation_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 PRESENTATION_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define PRESENTATION_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +static_assert(PRESENTATION_TESTS_ACTIVE_CPLUSPLUS >= 202002L, + "presentation_tests must be built with C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); + +namespace { + +using laige::Access; +using laige::Entity; +using laige::ErrorCode; +using laige::GameLoop; +using laige::Io; +using laige::Position2DFp32; +using laige::Position2DFpx16; +using laige::PresentationSnapshot; +using laige::Status; +using laige::SystemDef; +using laige::SystemFn; +using laige::SystemSchedule; +using laige::World; +using laige::Write; +using laige::fpx16_16; +using laige::sim::Fp32Pinned; +using laige::sim::Fpx16_16; +using laige::sim::SimMathFpx16; +using Vec2 = laige::sim::SimMathFpx16::Vec2; + +// The test tick rate: 100 Hz — the tick period is EXACTLY 1e7 ns +// (1e9 / 100, integer), so every anchor A(T) = T × 1e7 ns and every +// expected alpha below is an exact rational of small numerator (no +// floating-point expectations anywhere in the suite). +constexpr std::uint32_t kTestRateHz = 100; +constexpr std::int64_t kTickNs = 10000000; // 1e9 / kTestRateHz + +// Q16.16 raw constructors (the exact expected values are expressed in +// raw units — no float round-trip in the test). +fpx16_16 q16(std::int32_t raw) { return fpx16_16{raw}; } +fpx16_16 fx(std::int32_t v) { return fpx16_16::fromInt32(v); } +Vec2 vec(std::int32_t xRaw, std::int32_t yRaw) { + return Vec2{q16(xRaw), q16(yRaw)}; +} + +// The expected-value helper (Vec2 has no operator==; the expectations +// are exact bit equality on the Q16.16 raw units). +void expectVec(const char* what, Vec2 v, std::int32_t xRaw, + std::int32_t yRaw) { + EXPECT_EQ(v.x.raw, xRaw) << what; + EXPECT_EQ(v.y.raw, yRaw) << what; +} + +// --------------------------------------------------------------------------- +// The synthetic clock (the Options::nowNs injection seam — the +// LoggerOptions::ClockFn precedent). Monotonic by construction: the +// tests only advance it. +// --------------------------------------------------------------------------- + +std::int64_t gSynthClockNs = 0; + +std::int64_t synthNowNs() { + return gSynthClockNs; +} + +// --------------------------------------------------------------------------- +// World builder (the game_loop_tests pattern) +// --------------------------------------------------------------------------- + +World makeWorld(std::uint32_t capacity) { + auto w = World::create(World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity << ") failed: " + << laige::errorName(w.error()); + std::abort(); + } + World world = std::move(w).takeValue(); + if (!world.registerComponent().ok()) { + ADD_FAILURE() << "registerComponent failed"; + std::abort(); + } + return world; +} + +Entity makeEntity(World& world, Vec2 pos) { + auto r = world.create(); + if (!r.ok()) { + ADD_FAILURE() << "World::create() failed: " << laige::errorName(r.error()); + std::abort(); + } + Entity e = std::move(r).takeValue(); + if (!world + .addComponent(e, Position2DFpx16{pos}) + .ok()) { + ADD_FAILURE() << "addComponent failed"; + std::abort(); + } + return e; +} + +void setPos(World& world, Entity e, Vec2 pos) { + if (!world + .addComponent(e, Position2DFpx16{pos}) + .ok()) { + ADD_FAILURE() << "addComponent (overwrite) failed"; + std::abort(); + } +} + +// --------------------------------------------------------------------------- +// The movement system for the hook test: +1 unit on x per tick. +// --------------------------------------------------------------------------- + +void fnPMove(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + const Vec2 step{fx(1), fx(0)}; + // ctx.each can only fail on a nested iteration — the guard logs it + // itself (ecs/iteration_nested); the system has nothing to add + // (the scheduler_tests convention). + static_cast(ctx.each( + [&step](Entity, Position2DFpx16& p) { + p.pos = SimMathFpx16::add(p.pos, step); + }, + Write{})); +} + +void fnPNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +SystemDef makeDef(const char* name, SystemFn fn) { + return SystemDef{name, fn, laige::fpx16_16::fromInt32(1), nullptr}; +} + +// The GameLoop onTick hook's thunk (the M1-HEAD-01 wiring shape): the +// snapshot's onTick behind the void* context. +void onTickThunk(void* ctx, laige::World& world, std::uint64_t tick) noexcept { + static_cast(world); + static_cast*>(ctx)->onTick(tick); +} + +// --------------------------------------------------------------------------- +// Log capture (the logging_tests / game_loop_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 game_loop_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 game_loop_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; +} + +// The events that are NOT the world's own setup-lifecycle events. +// The World emits Info events when a component set first appears +// (ecs/archetype_created) and when its row capacity doubles (bounded +// reservation, ecs/archetype_grow); a burst of setup adds can also +// trip the rate-limited ecs/churn_per_frame warn (the 500 setup adds +// of the zero-alloc test exceed the 256 per-frame budget). Those are +// the setup path's, never the snapshot's — the snapshot's own +// silence (LOG-003) is asserted on this count. +std::size_t snapshotEvents(const MemorySink& s) { + std::size_t n = 0; + for (const auto& e : s.entries) { + if (e.event != "archetype_created" && e.event != "archetype_grow" && + e.event != "churn_per_frame") { + ++n; + } + } + return n; +} + +const MemorySink::Entry* findEvent(const MemorySink& s, const char* event) { + for (const auto& e : s.entries) { + if (e.event == event) return &e; + } + return nullptr; +} + +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 + +// --------------------------------------------------------------------------- +// create(): tick-rate validation (the loop's documented 20–120 Hz +// range; one rate-limited warn) +// --------------------------------------------------------------------------- + +TEST(Presentation, CreateValidatesTheTickRate) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(8); + + // Below the range (19 Hz): rejected, one tick_rate_invalid warn. + { + auto r = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{19}); + 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). + { + auto r = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{121}); + ASSERT_FALSE(r.ok()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + } + // The range endpoints are legal. + { + auto r = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{20}); + ASSERT_TRUE(r.ok()); + } + { + auto r = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{120}); + ASSERT_TRUE(r.ok()); + } + // The default (an empty Options): kDefaultTickRateHz (60). + { + auto r = PresentationSnapshot::create(w, 0, {}); + ASSERT_TRUE(r.ok()); + } + + // The warns: exactly one tick_rate_invalid (the 19 Hz case; the + // 121 Hz repeat is rate-limited — LOG-004), subsystem + // "presentation", with the rejected value as a structured field. + EXPECT_EQ(countEvents(*mem, "tick_rate_invalid"), 1u); + ASSERT_EQ(mem->entries.size(), 1u); + const auto& rateEntry = mem->entries[0]; + EXPECT_EQ(rateEntry.subsystem, "presentation"); + EXPECT_EQ(rateEntry.severity, laige::log::Severity::Warn); + EXPECT_STREQ(fieldValue(rateEntry, "tick_rate_hz"), "19"); + + // Shutdown: the suppressed 121 Hz repeat is 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_rate_invalid"); + EXPECT_STREQ(fieldValue(mem->entries[1], "suppressed"), "1"); + sink = nullptr; +} + +// --------------------------------------------------------------------------- +// Linear interpolation between ticks (the step's core Verify): exact +// Q16.16 lerp values at alpha 0, 0.5, 0.75, and the Q16.16 rounding +// of a near-1 alpha +// --------------------------------------------------------------------------- + +TEST(Presentation, LinearInterpolationBetweenTicks) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e = makeEntity(w, vec(0, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // Before any tick: alpha is 0 and every sample snaps (no refresh + // has run) — the entity's current value, (0,0). + snap.onRenderFrame(5000000); + EXPECT_EQ(snap.alpha().raw, 0); + auto s0 = snap.sample_position(e); + ASSERT_TRUE(s0.ok()); + expectVec("pre-tick snap", s0.value(), 0, 0); + + // Tick 1: the sim moves the entity to (1,0) (manual drive). First + // sight: the snapshot snaps — prev = curr = (1,0). + setPos(w, e, vec(65536, 0)); + snap.onTick(1); + // Render exactly at A(1) = 1e7: alpha 0 -> prev (== curr here). + snap.onRenderFrame(kTickNs); + EXPECT_EQ(snap.alpha().raw, 0); + auto s1 = snap.sample_position(e); + ASSERT_TRUE(s1.ok()); + expectVec("at A(1)", s1.value(), 65536, 0); + + // Tick 2: the entity moves to (2,0). Now prev = (1,0), curr = (2,0). + setPos(w, e, vec(131072, 0)); + snap.onTick(2); + + // Render exactly at A(2) = 2e7: alpha 0 -> prev (1,0). + snap.onRenderFrame(2 * kTickNs); + EXPECT_EQ(snap.alpha().raw, 0); + auto s2 = snap.sample_position(e); + ASSERT_TRUE(s2.ok()); + expectVec("at A(2)", s2.value(), 65536, 0); + + // Render at the MIDPOINT of (A(2), A(3)) — A(2) + 5e6: alpha is + // exactly 0.5 (5e8 / 1e9); the lerp is exactly (1.5, 0) (raw 98304). + snap.onRenderFrame(2 * kTickNs + 5000000); + EXPECT_EQ(snap.alpha().raw, 32768); // exactly 0.5 in Q16.16 + auto s3 = snap.sample_position(e); + ASSERT_TRUE(s3.ok()); + expectVec("midpoint", s3.value(), 98304, 0); + + // Render at the 3/4 point — A(2) + 7500000: alpha exactly 0.75 + // (75e7 / 1e9); the lerp is exactly (1.75, 0) (raw 114688). + snap.onRenderFrame(2 * kTickNs + 7500000); + EXPECT_EQ(snap.alpha().raw, 49152); // exactly 0.75 in Q16.16 + auto s4 = snap.sample_position(e); + ASSERT_TRUE(s4.ok()); + expectVec("3/4 point", s4.value(), 114688, 0); + + // Render 0.01 tick short of A(3) — A(2) + 9900000: alpha exactly + // 0.99 (99e7 / 1e9) — not exactly representable in Q16.16 (raw + // 64881 = 0.989993...): the documented single rounding, then the + // lerp exactly (1 + 1 × 64881/65536, 0) = (raw 130417, 0). + snap.onRenderFrame(2 * kTickNs + 9900000); + EXPECT_EQ(snap.alpha().raw, 64881); + auto s5 = snap.sample_position(e); + ASSERT_TRUE(s5.ok()); + expectVec("near-1 alpha", s5.value(), 130417, 0); + + // Machine-greppable evidence line (the step's Verify). + std::printf("presentation linear rate=100Hz prev=(1,0) curr=(2,0) " + "alpha(0.5)=%d/65536 sample=(%d/65536, 0)\n", + 32768, 98304); + + // The snapshot's own path is silent (LOG-003): the only entry is + // the world's setup archetype_created Info. + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The alpha clamps to [0, 1] — never extrapolates: a render before the +// anchor (or far before it) clamps to 0; a render exactly at the next +// anchor is 1.0; a clock jump a full tick or more past the anchor +// clamps to 1; exact values in between are preserved +// --------------------------------------------------------------------------- + +TEST(Presentation, AlphaClampsToTheUnitInterval) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e = makeEntity(w, vec(65536, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // Drive to lastTick = 2: prev = (1,0), curr = (2,0); anchors + // A(2) = 2e7, A(3) = 3e7. + snap.onTick(1); + setPos(w, e, vec(131072, 0)); + snap.onTick(2); + + // Render at the start reference (before the first anchor): clamps + // to 0 -> prev. + snap.onRenderFrame(0); + EXPECT_EQ(snap.alpha().raw, 0); + auto sStart = snap.sample_position(e); + ASSERT_TRUE(sStart.ok()); + expectVec("at start", sStart.value(), 65536, 0); + + // Render 1 ns before A(2): the exact alpha is -1e-7 — clamps to 0. + snap.onRenderFrame(2 * kTickNs - 1); + EXPECT_EQ(snap.alpha().raw, 0); + auto sBefore = snap.sample_position(e); + ASSERT_TRUE(sBefore.ok()); + expectVec("1ns before anchor", sBefore.value(), 65536, 0); + + // Render 1 ns past A(2): the exact alpha is 1e-7 — below the + // Q16.16 resolution (raw 0), so the sample is still exactly prev. + snap.onRenderFrame(2 * kTickNs + 1); + EXPECT_EQ(snap.alpha().raw, 0); + auto sAfter = snap.sample_position(e); + ASSERT_TRUE(sAfter.ok()); + expectVec("1ns past anchor", sAfter.value(), 65536, 0); + + // Render 10 us past A(2): alpha exactly 0.001 (1e4 * 100 / 1e9 = + // 1e6 / 1e9 -> Q16 raw 66); the lerp x = 1 + 1 × 66/65536 = raw + // 65536 + 66 = 65602 — a non-degenerate small alpha moves the + // sample off prev. + snap.onRenderFrame(2 * kTickNs + 10000); + EXPECT_EQ(snap.alpha().raw, 66); + auto sSmall = snap.sample_position(e); + ASSERT_TRUE(sSmall.ok()); + expectVec("10us past anchor", sSmall.value(), 65602, 0); + + // Render exactly at A(3) — one full tick past A(2): alpha exactly + // 1.0 (raw 65536) -> exactly curr. + snap.onRenderFrame(3 * kTickNs); + EXPECT_EQ(snap.alpha().raw, 65536); // exactly 1.0 in Q16.16 + auto sNext = snap.sample_position(e); + ASSERT_TRUE(sNext.ok()); + expectVec("at A(3)", sNext.value(), 131072, 0); + + // CLOCK JUMP: a render half a tick past A(3) (R = 35 ms) — a full + // tick or more past the anchor A(2): clamps to 1 -> exactly curr. + snap.onRenderFrame(35000000); + EXPECT_EQ(snap.alpha().raw, 65536); + auto sJump = snap.sample_position(e); + ASSERT_TRUE(sJump.ok()); + expectVec("half tick past A(3)", sJump.value(), 131072, 0); + + // Larger jumps: 5 s and 16.7 min past the start — both clamp to 1 + // (the exact value would be ~498 and ~99998 full ticks past the + // anchor). + snap.onRenderFrame(5000000000); + EXPECT_EQ(snap.alpha().raw, 65536); + auto s5s = snap.sample_position(e); + ASSERT_TRUE(s5s.ok()); + expectVec("5s jump", s5s.value(), 131072, 0); + snap.onRenderFrame(1000000000000); + EXPECT_EQ(snap.alpha().raw, 65536); + auto sMin = snap.sample_position(e); + ASSERT_TRUE(sMin.ok()); + expectVec("16.7min jump", sMin.value(), 131072, 0); + + // An absurd reading (285 years) still clamps — the branch guard + // (no overflow, CPP-004) keeps the sample exactly curr. + snap.onRenderFrame(9000000000000000000); + EXPECT_EQ(snap.alpha().raw, 65536); + auto sYear = snap.sample_position(e); + ASSERT_TRUE(sYear.ok()); + expectVec("285y jump", sYear.value(), 131072, 0); + + // A render reading BELOW the start reference (a non-monotonic render + // clock — a wiring misuse): clamps to the start (no time before the + // base) — never UB, alpha 0. + snap.onRenderFrame(-5); + EXPECT_EQ(snap.alpha().raw, 0); + auto sNeg = snap.sample_position(e); + ASSERT_TRUE(sNeg.ok()); + expectVec("below start", sNeg.value(), 65536, 0); + + // The snapshot's own path is silent (LOG-003): the only entry is + // the world's setup archetype_created Info. + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// Entities added between ticks snap to their current value (the +// documented scope behavior), then interpolate normally from the +// second tick after creation +// --------------------------------------------------------------------------- + +TEST(Presentation, EntityAddedBetweenTicksSnapsToCurr) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e1 = makeEntity(w, vec(65536, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // Tick 1 (e1 first sight: snaps to (1,0)); tick 2 (e1 -> (2,0)). + snap.onTick(1); + setPos(w, e1, vec(131072, 0)); + snap.onTick(2); + + // BETWEEN TICKS: e2 is created at (5,5) — after the last onTick. + Entity e2 = makeEntity(w, vec(327680, 327680)); + + // Sample e2 before any refresh has seen it: SNAP — exactly the + // current world value (no phantom interpolation, no error). + snap.onRenderFrame(2 * kTickNs + 5000000); // alpha 0.5 + auto sNew = snap.sample_position(e2); + ASSERT_TRUE(sNew.ok()); + expectVec("new entity snap", sNew.value(), 327680, 327680); + + // e1 interpolates as usual in the same frame. + auto s1 = snap.sample_position(e1); + ASSERT_TRUE(s1.ok()); + expectVec("e1 midpoint", s1.value(), 98304, 0); // (1.5, 0) + + // Tick 3: e1 -> (3,0); e2 -> (6,6). e2 is first sight: snaps to + // (6,6). + setPos(w, e1, vec(196608, 0)); + setPos(w, e2, vec(393216, 393216)); + snap.onTick(3); + snap.onRenderFrame(3 * kTickNs + 5000000); // alpha 0.5 + auto sNew3 = snap.sample_position(e2); + ASSERT_TRUE(sNew3.ok()); + expectVec("e2 tick-3 snap", sNew3.value(), 393216, 393216); + auto s13 = snap.sample_position(e1); + ASSERT_TRUE(s13.ok()); + expectVec("e1 tick-3 midpoint", s13.value(), 163840, 0); // (2.5, 0) + + // Tick 4: e2 -> (7,7). Now e2 has a real prev/curr: it interpolates + // — at alpha 0.5 exactly (6.5, 6.5) (raw 425984). + setPos(w, e2, vec(458752, 458752)); + snap.onTick(4); + snap.onRenderFrame(4 * kTickNs + 5000000); // alpha 0.5 + auto sNew4 = snap.sample_position(e2); + ASSERT_TRUE(sNew4.ok()); + expectVec("e2 tick-4 midpoint", sNew4.value(), 425984, 425984); + + // The snapshot's own path is silent (LOG-003): the only entry is + // the world's setup archetype_created Info (e2 reuses e1's + // archetype). + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The catch-up case (one frame running several ticks): prev/curr +// refresh PER TICK — the sample interpolates the LATEST tick's +// interval, not a multi-tick span +// --------------------------------------------------------------------------- + +TEST(Presentation, CatchUpFrameInterpolatesTheLatestTick) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e = makeEntity(w, vec(0, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // One frame runs TWO ticks (the manual catch-up form): (1,0) then + // (2,0). + setPos(w, e, vec(65536, 0)); + snap.onTick(1); + setPos(w, e, vec(131072, 0)); + snap.onTick(2); + + // Render at A(2) + 5e6: alpha 0.5 against the LATEST interval + // (prev = end of tick 1 = (1,0), curr = end of tick 2 = (2,0)) — + // not (0,0) -> (2,0) over the whole frame. + snap.onRenderFrame(2 * kTickNs + 5000000); + EXPECT_EQ(snap.alpha().raw, 32768); + auto s = snap.sample_position(e); + ASSERT_TRUE(s.ok()); + expectVec("catch-up midpoint", s.value(), 98304, 0); // (1.5, 0) + + // The snapshot's own path is silent (LOG-003): the only entry is + // the world's setup archetype_created Info. + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// sample_position rejects stale handles (warn-once) and live handles +// without a Position2D (a negative query, no warn) +// --------------------------------------------------------------------------- + +TEST(Presentation, SampleRejectsStaleHandlesAndMissingComponents) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e1 = makeEntity(w, vec(65536, 0)); + auto e2R = w.create(); // a live entity WITHOUT a Position2D + ASSERT_TRUE(e2R.ok()); + Entity e2 = std::move(e2R).takeValue(); + Entity e3 = makeEntity(w, vec(131072, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + snap.onTick(1); + snap.onRenderFrame(kTickNs); + + // The synced entity samples fine. + auto sOk = snap.sample_position(e1); + ASSERT_TRUE(sOk.ok()); + expectVec("synced sample", sOk.value(), 65536, 0); + + // Destroy e1: its handle is stale. sample_position fails (the + // World::check precedent: warn-once, every build — never silent). + ASSERT_TRUE(w.destroy(e1).ok()); + auto sStale = snap.sample_position(e1); + ASSERT_FALSE(sStale.ok()); + EXPECT_EQ(sStale.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*mem, "stale_entity_access"), 1u); + + // A second use of the same stale handle: the warn is rate-limited + // (no new event within the window) — the failure is still the + // returned error. + auto sStale2 = snap.sample_position(e1); + ASSERT_FALSE(sStale2.ok()); + EXPECT_EQ(sStale2.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*mem, "stale_entity_access"), 1u); + + // A live handle WITHOUT a Position2D: a negative query (like + // has() reading false) — the error is returned, NO new event. + auto sMissing = snap.sample_position(e2); + ASSERT_FALSE(sMissing.ok()); + EXPECT_EQ(sMissing.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(snapshotEvents(*mem), 1u); // only the stale warn above + + // A live entity that LOST its component since the last refresh: + // also a negative query, no new event. + ASSERT_TRUE(w.removeComponent(e3).ok()); + auto sLost = snap.sample_position(e3); + ASSERT_FALSE(sLost.ok()); + EXPECT_EQ(sLost.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(snapshotEvents(*mem), 1u); + + // The stale warn's shape (subsystem "ecs", the World::check + // precedent). + const MemorySink::Entry* warnEntry = findEvent(*mem, "stale_entity_access"); + ASSERT_NE(warnEntry, nullptr); + EXPECT_EQ(warnEntry->subsystem, "ecs"); + EXPECT_EQ(warnEntry->severity, laige::log::Severity::Warn); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The GameLoop's onTick hook drives the snapshot once per COMPLETED +// tick (the M1-HEAD-01 wiring shape): the alpha stays anchored to the +// loop's time base (startReferenceNs), a catch-up frame refreshes per +// tick, and a failed tick (a stale schedule) does not fire the hook +// --------------------------------------------------------------------------- + +TEST(Presentation, HookDrivesTheSnapshotPerCompletedTick) { + MemorySink* mem = installCaptureSink(); + gSynthClockNs = 0; + World w = makeWorld(64); + Entity e = makeEntity(w, vec(0, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // The loop: 100 Hz on the synthetic clock, the onTick hook wired + // to the snapshot (the engine's wiring shape). + ASSERT_TRUE(w.registerSystem(makeDef("PMove", &fnPMove), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + GameLoop::Options lopts; + lopts.tickRateHz = kTestRateHz; + lopts.nowNs = &synthNowNs; + lopts.onTick = &onTickThunk; + lopts.onTickContext = &snap; + auto loopR = GameLoop::create(w, sched, std::move(lopts)); + ASSERT_TRUE(loopR.ok()); + GameLoop loop = std::move(loopR).takeValue(); + + // Frame 0 at t = 0 establishes the start reference (zero ticks). + ASSERT_TRUE(loop.frame().ok()); + EXPECT_EQ(loop.startReferenceNs(), 0); + EXPECT_EQ(snap.lastTick(), 0u); + + // The engine reads the clock once per frame and passes it to both + // the loop and the snapshot (the documented same-clock wiring). + auto frameAndRender = [&](std::int64_t nowNs) { + gSynthClockNs = nowNs; + ASSERT_TRUE(loop.frame().ok()); + snap.onRenderFrame(gSynthClockNs); + EXPECT_EQ(snap.lastTick(), loop.currentTick()); + }; + + // t = 15 ms: one tick completed (the sim moved the entity to + // (1,0)); alpha = (15ms − A(1)) / 10ms = 0.5 (the sub-anchor + // window). The sample: prev == curr == (1,0) (tick 1 snapped). + frameAndRender(15000000); + EXPECT_EQ(loop.currentTick(), 1u); + EXPECT_EQ(snap.alpha().raw, 32768); + auto s1 = snap.sample_position(e); + ASSERT_TRUE(s1.ok()); + expectVec("tick 1", s1.value(), 65536, 0); + + // t = 25 ms: tick 2 (the entity at (2,0)); alpha 0.5 against the + // LATEST interval (1,0) -> (2,0). + frameAndRender(25000000); + EXPECT_EQ(loop.currentTick(), 2u); + EXPECT_EQ(snap.alpha().raw, 32768); + auto s2 = snap.sample_position(e); + ASSERT_TRUE(s2.ok()); + expectVec("tick 2", s2.value(), 98304, 0); // (1.5, 0) + + // CATCH-UP frame: t = 45 ms jumps 20 ms — the frame runs ticks 3 + // and 4 (both fire the hook); the entity ends at (4,0); alpha 0.5 + // against (3,0) -> (4,0). + frameAndRender(45000000); + EXPECT_EQ(loop.currentTick(), 4u); + EXPECT_EQ(snap.lastTick(), 4u); + EXPECT_EQ(snap.alpha().raw, 32768); + auto s4 = snap.sample_position(e); + ASSERT_TRUE(s4.ok()); + expectVec("tick 4", s4.value(), 229376, 0); // (3.5, 0) + + // A failed tick does NOT fire the hook: registering a system after + // scheduling stales the schedule; frame() fails (rate-limited warn), + // the tick count freezes, and the snapshot stays at tick 4. + ASSERT_TRUE(w.registerSystem(makeDef("PNoop", &fnPNoop)).ok()); + gSynthClockNs = 55000000; + const Status sFail = loop.frame(); + ASSERT_FALSE(sFail.ok()); + EXPECT_EQ(sFail.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(loop.currentTick(), 4u); + EXPECT_EQ(snap.lastTick(), 4u); + EXPECT_EQ(countEvents(*mem, "schedule_stale"), 1u); + + // The success path up to here was silent except the failure's own + // event (no presentation event of the loop's own — LOG-002). + EXPECT_EQ(snapshotEvents(*mem), 1u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// Move transfers the tick state; the moved-from snapshot is stopped +// (no world access, no logging) +// --------------------------------------------------------------------------- + +TEST(Presentation, MovedSnapshotIsStopped) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(64); + Entity e = makeEntity(w, vec(65536, 0)); + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot a = std::move(snapR).takeValue(); + a.onTick(1); + setPos(w, e, vec(131072, 0)); + a.onTick(2); + a.onRenderFrame(2 * kTickNs + 5000000); // alpha 0.5 + + // Move: the state (lastTick, alpha, the record table) transfers. + PresentationSnapshot b = std::move(a); + EXPECT_EQ(b.lastTick(), 2u); + EXPECT_EQ(b.alpha().raw, 32768); + auto sB = b.sample_position(e); + ASSERT_TRUE(sB.ok()); + expectVec("moved-to sample", sB.value(), 98304, 0); // (1.5, 0) + + // The source is stopped: every operation fails with Invalid- + // Argument, NO world access, NO logging. + EXPECT_EQ(a.lastTick(), 0u); + EXPECT_EQ(a.alpha().raw, 0); + auto sA = a.sample_position(e); + ASSERT_FALSE(sA.ok()); + EXPECT_EQ(sA.error(), ErrorCode::InvalidArgument); + // No world access, no log from the stopped snapshot (LOG-003): the + // only entry is the world's setup archetype_created Info. + EXPECT_EQ(snapshotEvents(*mem), 0u); + + // Move assignment: b's state moves into c; b is stopped. + auto snapR2 = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR2.ok()); + PresentationSnapshot c = std::move(snapR2).takeValue(); + c = std::move(b); + EXPECT_EQ(c.lastTick(), 2u); + auto sC = c.sample_position(e); + ASSERT_TRUE(sC.ok()); + expectVec("move-assigned sample", sC.value(), 98304, 0); + auto sB2 = b.sample_position(e); + ASSERT_FALSE(sB2.ok()); + EXPECT_EQ(sB2.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// No heap allocation on the per-frame refresh/sample path (the +// step's "no per-frame heap" claim — the test-only operator-new +// counter, non-sanitizer trees; the sanitizer trees prove the same +// loop leak-free, the game_loop_tests pattern) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(Presentation, HealthyFramesAllocateNothing) { + MemorySink* mem = installCaptureSink(); + const std::uint32_t kEntities = 500; + World w = makeWorld(4096); + std::vector ents; + ents.reserve(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) { + ents.push_back(makeEntity(w, vec(fx(static_cast(i % 7)).raw, + 0))); + } + auto snapR = PresentationSnapshot::create( + w, 0, PresentationSnapshot::Options{kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // Setup is done: reset the counter, then run the 100-frame window — + // per frame: 500 position updates (direct column writes), one + // onTick (500 visits over the bounded archetype scan), one + // onRenderFrame (integer ops), and 100 sample_position calls. + laige::test::resetAllocCounter(); + for (std::uint64_t t = 1; t <= 100; ++t) { + for (std::uint32_t i = 0; i < kEntities; ++i) { + w.get(ents[i])->pos.x = + fx(static_cast((i + t) % 7)); + } + snap.onTick(t); + snap.onRenderFrame(static_cast(t) * kTickNs + 5000000); + for (std::uint32_t i = 0; i < 100; ++i) { + auto s = snap.sample_position(ents[i]); + if (!s.ok()) { + ADD_FAILURE() << "sample_position failed at frame " << t; + } + } + } + const std::uint64_t allocs = laige::test::allocCounter(); + std::printf("presentation-zeroalloc frames=100 ticks=100 entities=%u " + "allocs=%llu\n", + kEntities, static_cast(allocs)); + EXPECT_EQ(allocs, 0u); + // The 500 setup adds emit the world's own lifecycle events (one + // archetype_created, the bounded archetype_grow doublings, and one + // rate-limited churn_per_frame warn — 500 > the 256 per-frame + // budget); the 100-frame window itself adds nothing. + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} +#endif + +// --------------------------------------------------------------------------- +// The fp32_pinned backend instantiates the same contract: one linear- +// interpolation check on the float alpha path (the AlphaConversion +// float division) +// --------------------------------------------------------------------------- + +TEST(Presentation, Fp32BackendInterpolatesLinearly) { + MemorySink* mem = installCaptureSink(); + auto wR = World::create(World::Options{64}); + ASSERT_TRUE(wR.ok()); + World w = std::move(wR).takeValue(); + ASSERT_TRUE(w.registerComponent().ok()); + auto eR = w.create(); + ASSERT_TRUE(eR.ok()); + Entity e = std::move(eR).takeValue(); + ASSERT_TRUE(w + .addComponent(e, Position2DFp32{{1.0f, 2.0f}}) + .ok()); + auto snapR = + PresentationSnapshot::create(w, 0, + PresentationSnapshot::Options{ + kTestRateHz}); + ASSERT_TRUE(snapR.ok()); + PresentationSnapshot snap = std::move(snapR).takeValue(); + + // Tick 1 (snap at (1,2)); tick 2 -> (3,4). + snap.onTick(1); + ASSERT_TRUE(w + .addComponent(e, Position2DFp32{{3.0f, 4.0f}}) + .ok()); + snap.onTick(2); + + // Render at the midpoint: alpha exactly 0.5f (5e8 / 1e9 — exact in + // binary32); the lerp is exactly (2.0, 3.0) (every step exact in + // binary32 under the pinned flag set). + snap.onRenderFrame(2 * kTickNs + 5000000); + EXPECT_FLOAT_EQ(snap.alpha(), 0.5f); + auto sMid = snap.sample_position(e); + ASSERT_TRUE(sMid.ok()); + EXPECT_FLOAT_EQ(sMid.value().x, 2.0f); + EXPECT_FLOAT_EQ(sMid.value().y, 3.0f); + + // Render exactly at A(2): alpha 0 -> prev (1,2). + snap.onRenderFrame(2 * kTickNs); + EXPECT_FLOAT_EQ(snap.alpha(), 0.0f); + auto sPrev = snap.sample_position(e); + ASSERT_TRUE(sPrev.ok()); + EXPECT_FLOAT_EQ(sPrev.value().x, 1.0f); + EXPECT_FLOAT_EQ(sPrev.value().y, 2.0f); + + // The snapshot's own path is silent (LOG-003): the only entry is + // the world's setup archetype_created Info. + EXPECT_EQ(snapshotEvents(*mem), 0u); + restoreConsoleSink(); +} From aad3a5d7743eca8fd4bfe6ac07f4a7f49f403919 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 02:12:05 +0200 Subject: [PATCH 2/2] [M1-LOOP-02] Roadmap: change log entry (refs the code commit 7d4cc0d) --- roadmap/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/roadmap/README.md b/roadmap/README.md index 90b9de8..ce83e89 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -206,6 +206,7 @@ One line per completed (or split/renumbered) step. | 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. +| 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | ---