The M1 headless engine object (M1-HEAD-01; PRD FR-1.6, ARCH-003,
ARCH-009, ARCH-010, CONC-006, PRD §10.2; AGENTS CORE-002/005/008,
PERF-002/003, LOG-003): wires config → world → systems → loop
into one owned object whose run_headless(maxTicks) starts the
simulation, ticks it in real time at the configured rate, and shuts
it down cleanly. It is the first full-stack surface of the engine and
the CI smoke-test target (laige-run --headless, this repo's tools/run).
It also owns the always-on profiler (M1-PROF-01, PRD FR-11.1 —
api/profiler.md): the per-tick and per-frame time
windows, the render/network counters, and the opt-in per-run profile
report.
Public header: src/laige-sim/include/laige/sim/engine.h (Engine,
the range constants, the full contract); implementation:
src/laige-sim/engine.cpp. The config surface (EngineConfig,
loadGameConfig, the override merge, the hot reloader) is
src/laige-sim/include/laige/sim/config.h — see
api/config.md. CLI: tools/run/laige-run.cpp (target
laige-run). Unit suite: ctest -R engine
(tests/laige-sim/engine_tests.cpp) and ctest -R config
(tests/laige-sim/game_config_tests.cpp); smoke test:
ctest -R laige_run_smoke (every P0 OS job, 1000 ticks @ 60 Hz).
laige::EngineConfig config;
config.tickRateHz = 60;
config.entityCapacity = 4096;
config.churnPerFrameBudget = 256;
laige::Engine engine = laige::Engine::create(config).value();
// Game code registers on the world BEFORE the run (the schedule is
// computed once at the start of the run):
engine.world()->registerComponent<MyComponent>();
engine.world()->registerSystem(MySystem_Def);
const laige::Status status = engine.run_headless(10'000);
// The run always ends in the ordered shutdown — success or failure:
// engine.isShutDown() == true, engine.world() == nullptr.Engine::create(config)— validates the config, creates theWorld(capacity =entityCapacity, churn budget =churnPerFrameBudget, seed =seed, deterministic mode =determinism.enabled), constructs the always-on profiler (M1-PROF-01: one object + its two fixed window storages — engine setup, not run setup), and registers the built-in component matching the configured SimMath backend first (ARCH-010: stable component-type ordering):sim::Position2DFpx16forfpx16_16(the ADR 0002 default) orsim::Position2DFp32forfp32_pinned. No frame is run and no loop exists yet; the engine is in the not started state.- Game registration — the game registers its components and
systems on
engine.world()before the run (see Misuse warnings). run_headless(maxTicks)— computes the system schedule, creates theGameLoop(M1-LOOP-01) with the engine's per-tick hook, runs one initialframe()(which runs 0 ticks and establishes the loop's start reference), creates the presentation snapshot anchored on the loop's exact start reference (ARCH-009), and then drives frames at wall-clock pacing untilmaxTicksticks have run (or forever, whenmaxTicks == 0— the server form). The run always ends in the ordered shutdown — a failed start or a failed frame mid-run is not an exception: the engine returns the failureStatusand is already shut down.shutdown()— ordered and idempotent: loop → world clear → snapshot → world release → profiler release → logging flush (CONC-006). A profile report that was started but never finalized (a pre-run teardown) is abandoned with the structuredprofiler/report_abortedwarn (no file to clean up — the write happens only at run end). Calling it after a finished run (or twice) is a safe no-op;world()reads backnullptrandrun_headlesson a stopped engine returnsInvalidArgumentwithout logging (the moved-outGameLoopprecedent, M1-LOOP-01).
The destructor calls shutdown(), so a forgotten shutdown never
leaks the world; the explicit call is the documented teardown (it
retires the logging facade, which Logger::init re-arms for a later
engine in the process).
EngineConfig and the JSON/file loaders now live in
api/config.md (laige/sim/config.h): the version 1
declarative schema (the REQUIRED version key, ARCH-007), the
tick_rate_hz / entity_budget / churn_per_frame_budget / seed
/ determinism keys, and the declared presentation blocks
(budgets, camera, asset_roots — stored now, consumed in M2).
The provisional M1-HEAD-01 surface was folded into it: the five
original EngineConfig members keep their order (existing aggregate
initializers compile unchanged), the provisional JSON keys carried
over, and the migration from a provisional document is adding
"version": 1.
What the engine consumes directly:
Engine::create(config)re-validates the typed config's tick rate (the config surface's rejection,config/tick_rate_invalid, subsystemconfig) — the struct is public, so a hand-built out-of-range config is rejected identically.- The seed is part of replay identity (ADR 0002) and is logged on
engine/run_started;determinism.enabledselects deterministic mode anddeterminism.maththe SimMath backend the engine registers (see concepts/determinism.md for the scope and api/determinism.md for the types). - The replay identity's
configHashcovers only the simulation-affecting fields — the declared presentation values (budgets/camera/asset roots) are excluded, so the hash encoding is unchanged by the final schema (api/replay.md).
The full key table, the versioning rules, the rejection table, the
loadGameConfig file loader, the EngineConfigOverride merge, and
the debug-only ConfigHotReloader are in
api/config.md.
Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks)
maxTicks == 0— the server form: run until the process ends (the headless simulation never self-terminates).maxTicks > 0— the bounded run: complete exactly atmaxTickson a healthy machine. WithframeBudgetTicks == 1each frame runs at most one tick, so a late frame drops a tick rather than overshooting — the tick count lands exactly on the target under any cadence (the M1-LOOP-01 catch-up contract). With the default budget (5), an overloaded frame may run up to 5 ticks to catch up, so a late frame can overshoot by at most budget − 1 ticks before the target check stops the run.- Pacing — one
steady_clockread per frame, one boundedsleep_foruntil the next tick's due time (the exact integer due computation, M1-LOOP-01). 1000 ticks @ 60 Hz ≈ 16.7 s of wall clock — thelaige_run_smokectest budget (TIMEOUT 300) and itsstatus=okassertion (CI asserts the run completed, not the tick count; drops are the documented overload behavior). - Lifecycle logs — one
engine/run_started(Info) before setup (fieldstick_rate_hz,tick_target,frame_budget_ticks,seed,determinism,math— the math field is the backend id stringfpx16_16orfp32_pinned), oneengine/run_finished(Info) after the last frame with the final accounting (ticks,dropped_ticks,dropped_frames,status); both are structured, stable, and machine-greppable (AGENTS §14).
Failure behavior (the run always ends in shutdown):
| condition | result |
|---|---|
| engine already stopped | InvalidArgument, no log (the stopped-state failure is a pure failure) |
frameBudgetTicks == 0 |
InvalidArgument + one loop/catchup_invalid warn (the GameLoop's validation) |
| schedule failure | the scheduleSystems Status (system/* — world-emitted) |
| a failed frame mid-run | that frame's Status (system/* — the loop's runSystems dispatch) |
| success | ok |
The engine owns a PresentationSnapshot<B> for the configured
SimMath backend B (ADR 0002, M1-DET-01): sim::Fpx16_16 for the
default fpx16_16, sim::Fp32Pinned for fp32_pinned — the same
backend the engine registered as the built-in component at init, so
the snapshot and the sim agree. The handle is type-erased on the
engine (detail::PresentationHandle); the backend is fixed at
create and is part of replay identity (a replay must use the same
backend — ADR 0002). Wiring:
- The loop is created with the engine's per-tick hook from the first
frame(), so the snapshot exists before the hook can fire (the first frame runs 0 ticks by the M1-LOOP-01 contract). - The snapshot is created after the first frame, anchored on the
loop's exact
startReferenceNs()(the presentation.h alpha contract:alphais derived from(now − startReference), never from a floating accumulator). - Per frame the engine reads the clock once and passes that reading
to
onRenderFrame(now); the tick path (onTick(tick)) refreshes prev/curr for every live position entity. Presentation state is never authoritative (ARCH-009) —sample_position/interpolation consume only the snapshot.
Headless runs in deterministic mode (the default) are
bit-identical within the same build, platform, architecture, and
compiler: the tick cadence is integer arithmetic, the system order
is the validated schedule, the SimMath backend is the configured one
(fpx16_16 by default — bit-exact by the language standard, ADR
0002), and any randomness is a named input (the per-system PRNG
substreams derived from seed, a fixed call order). Wall-clock
pacing (the sleep) does not enter the simulation — it only
decides when frames run; dropped ticks are the documented, logged
overload behavior, not nondeterminism. The same-build guarantee is
verified by ctest -R determinism_mode (the
DeterminismMode.* suites: identical 256-tick state-hash streams in
two consecutive runs; seed divergence). The full scope statement —
what is deterministic, the per-backend scopes, what is not yet — is
concepts/determinism.md.
Cross-build/platform determinism is M1-DET-04 (the detcheck
matrix); replay recording landed with M1-DET-02
(api/replay.md — Engine::startReplayRecording, the
versioned log format, and laige-run --replay); replay execution
landed with M1-DET-03 — World::stateHash (the deterministic
state hash, api/entity.md) and runReplay / the
laige-replay runner (api/replay.md, "The execution
half").
The engine owns exactly one always-on Profiler
(api/profiler.md for the full contract): created in
Engine::create, handed to the GameLoop as a non-owning view
(per-completed-tick timing), and driven for the frame-time feed by
the engine's run loop (the frame's sim work + the presentation
refresh, excluding the pacing sleep; the first frame — start
reference, zero ticks — is not recorded). A failed frame is not
recorded (the frame did not complete).
const laige::Profiler* p = engine.profiler(); // nullptr after shutdown
const laige::ProfilerStats s = engine.profileStats(); // the last run's cache
// Opt-in per-run report (EVERY build — diagnostics, not replay state):
const laige::Status r = engine.startProfileReport("/tmp/run.json");
// written at the END of the run, version-1 JSON (profiler.md schema);
// a write failure does NOT fail the run — it is sticky in
// engine.profileReportStatus() and logged (profiler/report_write_failed)- When —
startProfileReportonce, after all registration, beforerun_headless(likestartReplayRecording). A second call failsInvalidArgument+profiler/report_already_started; an empty path failsprofiler/report_path_invalid; a stopped engine fails silently. - Finalization — on every run path (success, failed frame,
failed start): the per-run summary describes what actually
happened (a zero-tick run writes a zero-tick report, CORE-008).
A pre-run shutdown abandons it with
profiler/report_aborted. - Accessors —
profileStats()is the run's cached snapshot (the world is released in shutdown, so the CLI reads the cache);profileReportStatus()/profileReportActive()are the report's sticky outcome / lifecycle state. - Cost — enabled: two
steady_clockreads + one O(1) ring write per frame and per completed tick, no allocation; disabled (profiler()->setEnabled(false)): one branch each. The measured enabled cost is bounded at 1% of a 10k-entity tick — baselines/m1-profiler-cost.md (CORE-001, DBG-004). - Structured events (subsystem
profiler):report_started,report_written(Info),report_write_failed(Error),report_aborted,report_already_started,report_path_invalid(Warn).
The engine records replays opt-in (see api/replay.md for the full format and recorder contract):
Status Engine::startReplayRecording(std::string_view path,
std::uint64_t maxBytes) noexcept;
bool Engine::replayRecordingActive() const noexcept;
std::uint64_t Engine::replayBytesWritten() const noexcept;- When — once, after all component/system registration, before
run_headless: the replay identity (seed, tick rate, component schema hash, math backend id, config hash — ADR 0002) is captured from the live world + config at call time. - What — one zero-length frame per completed tick (M1: no
input system yet; the frame bytes are the future input blob,
M3-INPUT-03), written to
path + ".tmp"and published atpathonly on a successful bounded run (atomic temp+rename). - Failures — a recording failure mid-run stops the run:
run_headlessreturns the recorder'sStatus(no partial log at the final path); the ordered shutdown still runs. A cap below header+trailer (56) or a frame over 1 MiB is anInvalidArgumentat the call/write; a total-size breach isBudgetExhausted. - Debug builds only —
NDEBUGmakes the call anInvalidArgumentwith areplay/record_disabledwarn. - Cost — disabled: one null check per tick; enabled: one bounded stdio write per completed tick (the explicit, opt-in cost — PERF-002/003).
- Structured events (subsystem
replay):record_started,record_finished,record_failed,record_aborted,record_already_started,record_start_failed,record_disabled.
laige-run --headless CONFIG.json [--ticks N] [--replay LOG]
[--prof-out REPORT] [--budget-report [N]]
[--budgets PATH] [--fail-on-budget]
--headless CONFIG— required: the JSON config file (bounded read, 1 MiB max; over-bound →MalformedInput; read error →IoError).--ticks N— the bounded run target (decimal digits only; default 0 = the server form). A bounded run completes EXACTLY N ticks: the CLI drives it with frame budget 1, so a late frame drops its extra due tick (counted indropped_ticks) instead of overshooting the target by up to budget - 1 (therun_headlesscontract). The completed tick count is therefore platform-stable — what the--replaylog contract (api/replay.md) relies on.--replay LOG— records the run (M1-DET-02; it was the M1-HEAD-01 stub): opt-in, debug builds only (release builds reject it withInvalidArgument+ areplay/record_disabledwarn), default size cap 128 MiB, atomic publish atLOGon a clean run. A start or mid-run recording failure exits2(start) or1(mid-run — thestatus=line carries the error name) with no partial log atLOG.--prof-out REPORT— writes the run's profile report (M1-PROF-01, FR-11.1 file export): the version-1 JSON schema (api/profiler.md) written atREPORTat the end of the run. Every build (diagnostics, not replay state — no debug-only gate). A start failure exits2(the run did not happen); a write failure at run end does not fail the run — the run completes (exit0), the report error is printed on stderr, and the exit code becomes2.--budget-report [N]— prints the last N frames' budget report (M1-PROF-02, FR-11.2 — api/frame_budget.md) to stdout at the end of the run, in the AGENTS §12 field format (every declared budget measured vs declared with a pass/flag, plus the over-budget systems list).N: 1..kFrameBudgetWindow(32); omitted = all retained frames. Every build (diagnostics, not replay state). The report needs abudgets.json(see--budgets); a load failure exits2(the run did not happen). The report is printed even on a failed run (the run failure dominates the exit code).--budgets PATH— thebudgets.jsonfile (schema v1 — api/budget_harness.md). Resolution order: this argument, then theLAIGE_BUDGETS_PATHenv var, thenbudgets.jsonin the working directory (thelaige-benchresolution order).--fail-on-budget— exit3when the run completed but the budget report isoverall=FAIL(the CI gate — PRD §8.1 budget policy). A failed run exits1regardless (the gate never masks a run failure).--help/-h— usage, exit 0.
Exit codes: 0 = the run completed (and the profile report, if
requested, was written); 1 = the engine run failed (the Status's
error name is printed on stderr); 2 = usage, file, config,
profile-report-write, or budget-report-start error; 3 = budget
failure (--fail-on-budget: the run completed, the report is
overall=FAIL). On completion the run prints one
machine-greppable summary line on stdout — byte-stable, CI greps
it (laige_run_smoke's PASS_REGULAR_EXPRESSION "status=ok"):
laige-run headless ticks=1000 dropped_ticks=0 dropped_frames=0 status=ok
followed by the profiler's one-line summary (M1-PROF-01, FR-11.1 "exposed in the CLI" — always printed, from the engine's cached per-run snapshot):
laige-run profile: ticks=1000 frames=1001 tick_ms: n=1000 min=... frame_ms: n=1001 ... entities_alive=... sim_allocs=... draw_calls=0 texture_binds=0 net_bytes=0
and, when --budget-report is given, the budget report itself
(M1-PROF-02 — the machine-greppable field format; a sample is
committed at tests/laige-sim/fixtures/budget_report_sample.txt):
laige-budget-report version=1
laige-budget-report context: workload=headless build= machine= warmup=0
laige-budget-report frames: n=4 total=31
laige-budget-report frame=27 ticks=1 tick_after=27 frame_ms=... sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS
...
laige-budget-report over_budget: none
laige-budget-report overall=PASS
The CLI then calls engine.shutdown() a second time — the
double-shutdown idempotency the step verifies — and exits.
- Per frame (the headless run loop): one clock read, one bounded
GameLoop::frame()(itself one clock read + integer ops + up toframeBudgetTickssystem dispatches — PERF-002 bounded), one snapshotonRenderFrame(a few integer ops), one sleep. No allocation and no logging on the healthy path (PERF-003, LOG-003). - Setup, once per run: exactly three one-shot allocations — the
GameLoopobject, thePresentationSnapshotobject, and the presentation slot record table (24 B/entity slot, sized by the scene budget — the presentation.h storage contract). Verified per-frame-zero byctest -R engine(HeadlessFramePathAllocatesNothing: the allocation count is identical for 1, 2, 3, and 10 ticks). The M1-ALLOC-01 pool accounting will supersede the probe once it exists. - Profiler (M1-PROF-01, always-on by default): the ENABLED frame
path adds two
steady_clockreads (theTimeItaround the frame's sim work + presentation refresh) and one O(1) ring write (Profiler::recordFrame); the ENABLED tick path adds two clock reads + one ring write per completed tick (theGameLoop'srunOneTick). No allocation, no logging. DISABLED (profiler()->setEnabled(false)): one branch each. The measured enabled cost is bounded at 1% of a 10k-entity tick — baselines/m1-profiler-cost.md (CORE-001, DBG-004). The profiler's own setup (one object + two fixed window storages) happens inEngine::create, not in the run's setup path — the "exactly three one-shot allocations per run" claim above stays true. - Frame graph / budget report (M1-PROF-02): the per-frame
accumulation is always on — two O(1) reads (the loop's tick
count, the pool reservations), two O(systemCount) passes over the
per-system G-R5 counters, and one O(1) ring write per completed
frame. No allocation, no logging (PERF-003, LOG-003). The ring is
fixed storage created in
Engine::create(the engine's setup, not the run's) — the "exactly three one-shot allocations per run" claim stays true. The report build + cache at the run's end is cold (one format pass, once per run — api/frame_budget.md). - Complexity —
run_headlessis O(maxTicks × per-tick system work), bounded per frame byframeBudgetTicks. The drop path is cold: one rate-limited warn per overload frame (M1-LOOP-01). - Traps — registering systems after the run started does not update the schedule (see below); running at 120 Hz on a 60 Hz display doubles the tick rate (validate your frame budget against your systems' declared budgets, M1-SYS-03).
- Register game components/systems on
world()BEFORErun_headless— the schedule is computed once at the start of the run. A registration after the schedule makes the schedule stale; the next frame failssystem/schedule_stale(the M1-SYS-02 contract) and the run returns that status. - One run per engine — a second
run_headlesson a finished engine returnsInvalidArgument(no log). Create a new engine (the config is cheap; the world's pools are sized at create). - Do not call
world()after shutdown for writes — it reads backnullptrby contract (queries are safe: they return null, they do not dereference). maxTicks == 0never returns — the server form runs until the process ends. Use the bounded form for tests and CI.- The config is validated at
create, not at run — a hand-builtEngineConfigbypassing the JSON path is still validated (same codes), so there is no unvalidated path. - Start replay recording after all registration, before the
run, and only in debug builds — the identity is captured at call
time (a later registration makes the recorded schema hash stale),
a second start fails
InvalidArgument, andNDEBUGbuilds reject the call by contract (see the Replay recording section above and api/replay.md). - Start the profile report once, after registration, before the
run — a second
startProfileReportfailsInvalidArgument(profiler/report_already_started); a report write failure does NOT fail the run — it is sticky inprofileReportStatus()(thelaige-runCLI maps it to exit 2). See the The profiler section above and api/profiler.md. - Start the budget report once, after registration, before the
run — a second
startBudgetReportfailsInvalidArgument(budget/report_already_started); abudgets.jsonload failure fails the START (the run did not happen — thelaige-runCLI maps it to exit 2). A budget FAIL at the end of the run does NOT fail the run —lastBudgetReport().passedis the gate (the CLI's--fail-on-budgetmapsoverall=FAILto exit 3). See api/frame_budget.md.
ctest -R engine— 20 tests: create/config validation, the JSON parse surface, the bounded run + loop accounting, the zero-frame budget rejection, the stopped-state second run, the double-shutdown idempotency, the world release, and the zero-allocation frame-path probe (non-sanitizer trees).ctest -R laige_run_smoke—laige-run --headless tests/laige-sim/fixtures/headless_smoke.json --ticks 1000(60 Hz, 10 000 slots): must exit 0 and printstatus=okon every P0 OS job; TIMEOUT 300 s (≈16.7 s nominal); the TSan job setsTSAN_OPTIONS=halt_on_error=1.ctest -R laige_run_budget— the M1-PROF-02 CLI smoke:laige-run --headless tests/laige-sim/fixtures/headless_smoke.json --ticks 30 --budget-report 4 --budgets <repo>/budgets.json --fail-on-budget; passes iff the report printsoverall=PASSand the run exits 0 (a FAIL report exits 3, which ctest fails). The budgets path is passed explicitly (the ctest CWD is the build tree — the working-directory fallback would not resolve there). See api/frame_budget.md.ctest -R budget_report— the M1-PROF-02 unit suite (the recorder ring, the synthetic over-budget system's correct numbers, the NO_ENTRY/NO_SAMPLES semantics, the cached per-run report, the record-path zero-allocation). See api/frame_budget.md.ctest -R replay_record— the M1-DET-02 replay suite (the format round trip, the malformed-input table, the recorder contract, the identity hashes, the engine's per-tick recording + failure stop);ctest -R fuzz_replay_parsecovers the parser's fuzz surface.ctest -R profiler— the M1-PROF-01 profiler suite (the counter model's exact percentiles, rollover, and no-op-when-disabled, the cold world-pulled snapshot, theGameLoop's per-completed-tick timing, the engine's per-run cache + opt-in JSON report, the record path's zero-allocation, and the enabled-cost check bounded at 1% of a 10k-entity tick — the machine-greppableprofiler-zeroalloc/profiler-costlines land in the ctest output).- The include-graph lint (
tools/laige-include-lint) guarantees the headless path carries no GPU/window symbols (ARCH-003):laige-runlinks onlylaige-sim→laige-core.