Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<T,E>`,
`laige::Status`, the stable `ErrorCode` registry (M0-CORE-01).
- [Structured logging](api/logging.md) — the `laige::log` facade, sinks,
Expand Down Expand Up @@ -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

Expand Down
28 changes: 27 additions & 1 deletion docs/api/game_loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
293 changes: 293 additions & 0 deletions docs/api/presentation.md
Original file line number Diff line number Diff line change
@@ -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<Position2DFpx16>();
Entity e = world.create().value();
world.addComponent<Position2DFpx16>(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<PresentationSnapshot<laige::sim::Fpx16_16>*>(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<Backend>` — 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<T>`). 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<Backend>::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<T>()` 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<Backend>::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<Position2D>` 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).
Loading
Loading