This folder is the working implementation plan for the Laige engine
(Legendary AI Game Engine), derived from
../PRD.md and governed by ../AGENTS.md.
It is a checklist designed so that one AI agent (or one human) can safely pick up exactly one step at a time, implement it in a small patch, verify it, and move on — without ever facing "thousands of lines at once". Every step is small on purpose.
Before picking work, an implementing agent MUST:
- Read
../AGENTS.md(the engineering contract) and the relevantPRD.mdsections. - Pick the lowest-ID unchecked step whose
Dependsare all checked. Milestones are sequential (M0 before M1, etc.); within a milestone, follow file order unlessDependssays otherwise. - Implement exactly the Scope bullets of that step. Nothing else.
- No refactoring unrelated code.
- No features from later steps.
- No new dependencies, new public API, or new modules unless the step says so.
- Size limit: a step should land in ≤ ~400 lines of code including tests.
If it clearly won't, stop and split the step (create
Mx-XXX-04a,-04b, …, record the split in the Change Log below) instead of delivering a giant patch. - Verify before checking the box:
- Run the step's
Verifycommand(s). - Complete the applicable items of the AGENTS.md §16 acceptance checklist (tests, formatting, no new warnings, docs updated in the same change, logging/diagnostics where the step creates runtime behavior).
- Run the step's
- Check the box in the same PR/commit that implements the step. PR title MUST
start with the step ID:
[M2-ISO-01] Isometric depth key computation. - Update the Progress Board counts and add one line to the Change Log.
- If a step's scope conflicts with AGENTS.md or the PRD, stop and surface the conflict (AGENTS.md §1). Do not silently relax a rule.
- Decision steps (
Mx-DEC-…) produce an ADR underdocs/decisions/. No dependent step may start before its ADR exists.
M<milestone>-<SUBSYSTEM>-<sequence> e.g. M2-ISO-03
| Tag | Subsystem | Tag | Subsystem |
|---|---|---|---|
DEC |
Decisions / ADRs | INPUT |
Input |
REPO |
Repository layout | AUDIO |
Audio |
BUILD |
Build system | ANIM |
Animation |
CI |
CI pipelines | ASSET |
Assets & import |
DEP |
Dependencies / vendoring | SCRIPT |
Scripting |
CORE |
Core (Result, log, math, pools, PRNG, config) | TRAIT |
Component traits |
TOOL |
Tooling (manifest, lints, checks) | UNSAFE |
Unsafe API |
TEST |
Test infrastructure | PASS |
Custom render passes |
DOC |
Documentation | LIGHT |
2D lighting |
ECS |
Entity-component system | ED |
Editor |
SYS |
System framework | NET |
Networking |
LOOP |
Game loop | LOAD |
Load harness |
DET |
Determinism / replay | SHARD |
Sharding / zones |
CFG |
Configuration | PERSIST |
Persistence seam |
PROF |
Profiling / observability | SIM |
Simulation scale-out |
ALLOC |
Allocation guardrails | SEC |
Security / anti-cheat |
GL |
GL context / render infra | OPS |
Server ops |
CAM |
Camera | SOAK |
Soak testing |
PROJ |
Projection modes | REL |
Releases |
ISO |
Isometric (depth keys, picking, presets) | TAG |
Tagging / changelog |
SORT |
Depth sorting | BENCH |
Benchmarks / budgets |
SPRITE |
Sprite batcher | PERF |
Performance acceptance |
SCENE |
Reference scene / scene data | AC |
Acceptance-criteria suites |
TILE |
Tilemaps | SAMPLE |
Templates / sample games |
PAR |
Parallax layers | WEBGL / VULKAN / MOBILE / MESH / TOUCH / SKELE |
M9 stretch |
TEXT |
Fonts / text | EXIT |
Milestone gate |
UI |
UI widgets | ||
GOLD |
Golden/image tests |
Each step is one top-level checklist item:
- [ ] **Mx-XXX-nn · Title**
- **Refs:** <PRD FR/AC/NFR ids, AGENTS.md rule ids>
- **Depends:** <step ids, or —>
- **Scope:** <exact deliverable, 1–4 bullets>
- **Verify:** <command(s) + expected result>
- **Size:** <rough LOC including tests — a sanity ceiling, not a target>- [ ] = open, - [x] = done. A step stays open until its Verify command is green
and AGENTS.md §16 is satisfied for the change.
| Purpose | Command |
|---|---|
| Configure | cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug |
| Build | cmake --build build -j |
| Test | ctest --test-dir build --output-on-failure |
| ASan/UBSan build | cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug -DLAIGE_ASAN=ON |
| TSan build | cmake -S . -B build-tsan -DCMAKE_BUILD_TYPE=Debug -DLAIGE_TSAN=ON |
| Fuzz (bounded) | ./build/bin/laige-fuzz <target> --runs=1000 |
| Benchmarks | ./build/bin/laige-bench --suite=<name> |
| Determinism check | ./build/bin/laige-detcheck --scenario=<name> |
| API manifest | cmake --build build --target laige-api |
| Include-graph lint + dependency count | python3 tools/laige-include-lint |
Exact flags are fixed by M0-BUILD-01; later steps must use the documented form.
| File | Milestone (PRD §15) | Exit criteria (PRD) | Steps |
|---|---|---|---|
| M0-foundations.md | M0 — Foundations (2–3 wks) | CI green on 3 OSes; core unit-tested; budget harness wired | 22 |
| M1-heartbeat.md | M1 — Heartbeat (3–4 wks) | 10k entities @ 60 Hz ≤ 3 ms; replay bit-exact; zero-alloc assertion passes | 25 |
| M2-rendering-2.5d.md | M2 — 2.5D Rendering (4–6 wks) | 50k sprites ≤ 30 draw calls (worst-case iso); isometric is the default template; all AC-4.x pass | 33 |
| M3-game-feel.md | M3 — Game Feel (4–6 wks) | Physics budgets + determinism AC; playable isometric sample in-engine | 36 |
| M4-scripting-customization.md | M4 — Scripting & Customization (3–4 wks) | Script budget watchdog works; a custom-render-pass sample runs | 12 |
| M5-editor-mvp.md | M5 — Editor MVP (6–8 wks) | A dev can build a small isometric game entirely in the editor | 21 |
| M6-networking.md | M6 — Networking (6–8 wks) | Lockstep bit-exact; 100-player zone at 20 Hz; bandwidth budgets met | 16 |
| M7-mmo-scale.md | M7 — MMO Scale (8–12 wks) | AC-10.1/10.2/10.3 met; nightly load green 2 weeks straight | 15 |
| M8-release-1.0.md | M8 — Release 1.0 (4–6 wks) | All P0 FRs closed; budgets green; 3 reference games shipped | 8 |
| M9-stretch.md | M9+ — Stretch | Per-feature proposals (each gated by its own ADR) | 6 |
P1 items (PRD §7: "M6–M7") that are rendering/editor/tooling in nature are placed in the milestone that owns their subsystem (noted per step); the PRD's P1/M9 overlap is flagged where it occurs rather than silently re-scoped.
Steps tagged DEC close these. ADRs live in docs/decisions/.
| ID | Question (PRD §18) | Blocking step(s) | Default if unresolved | Decision |
|---|---|---|---|---|
| D-NAME | Engine name & license (MIT proposed) | M0-REPO-01 | keep "Laige", MIT | Decided 2026-09-10: keep "Laige" (Legendary AI Game Engine); MIT (ADR 0001) |
| D-MATH | Fixed-point default for all deterministic paths, or float-pinned + FP only for lockstep | M1-DET-01, M3-PHYS-11 | Q16.16 default for deterministic mode (PRD §10.3 recommends it for lockstep/MMO) | Decided 2026-09-10: SimMath — config-selectable; fpx16_16 default + fp32_pinned opt-in (ADR 0002) |
| D-JSON | Config JSON: tiny in-engine parser vs vendored library | M0-CORE-07 | in-engine bounded parser (no new dep) | Decided 2026-09-10: in-engine bounded parser, no new dep (ADR 0003) |
| D-ISO | 2:1 dimetric vs true iso as template default | M2-CAM-02 | 2:1 dimetric (PRD v0.2: pixel-art default) | Decided 2026-09-25: 2:1 dimetric as template default (ADR 0005); both presets selectable per scene |
| D-UI | Confirm minimal retained UI widget set for M2 | M2-UI-01 | the FR-2.8 P0 list (panel/button/text/image/list/slider/input) | Decided 2026-09-25: the seven FR-2.8 widgets confirmed as-is (ADR 0006); extras deferred and named in the ADR |
| D-EDITOR | Editor embedded vs standalone | M5-ED-01 | standalone binary (FR-8.7) | — open |
| D-LUA | Confirm Lua 5.4 (vs no scripting in 1.0, vs WASM) | M4-SCRIPT-01 | Lua 5.4, optional module, off by default | — open |
| D-NAT | M6 NAT traversal scope: STUN-only vs STUN+TURN | M6-NET-11 | STUN-style + relay endpoint, no TURN server in-engine | — open |
| D-PERSIST | Persistence seam: external store only vs built-in SQLite | M7-PERSIST-01 | external store only (PRD §12.5) | — open |
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 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) |
| M2 | 33 | 20 | ⬜ in progress |
| M3 | 36 | 0 | ⬜ not started |
| M4 | 12 | 0 | ⬜ not started |
| M5 | 21 | 0 | ⬜ not started |
| M6 | 16 | 0 | ⬜ not started |
| M7 | 15 | 0 | ⬜ not started |
| M8 | 8 | 0 | ⬜ not started |
| M9 | 6 | 0 | ⬜ proposals only |
| Total | 194 | 66 |
One line per completed (or split/renumbered) step.
| Date | Step | Commit | Note |
|---|---|---|---|
| 2026-09-10 | M0-DEC-01, M0-DEC-02, M0-DEC-03 | — | Decisions recorded by project owner; ADRs 0001–0003 written in docs/decisions/; docs only, no code |
| 2026-09-10 | M0-REPO-01 | — | Repo skeleton: top-level README.md/LICENSE (MIT, ADR 0001)/.gitignore; root CMakeLists.txt (CMake ≥ 3.22, C++20, -Wall -Werror, no exceptions/RTTI via laige_apply_engine_policy, LAIGE_BUILD_SHARED placeholder); PRD §10.1 module dirs with only laige-core populated; empty-target configure+build verified |
| 2026-09-10 | M0-BUILD-01 | — | laige-core CMake target (static default, shared via LAIGE_BUILD_SHARED; no transitive leakage — policy flags PRIVATE, CPP-010); options LAIGE_ASAN/LAIGE_TSAN (mutually exclusive, whole-tree instrumentation, fatal UBSan, TSan halt_on_error=1), LAIGE_SCRIPT reserved, LAIGE_BUILD_TESTS default ON; canonical commands fixed in docs/getting-started/building.md (source of truth); link smoke test tests/laige-core with NFR-8.10 static_assert policy self-checks; static+shared+ASan+TSan+Clang builds verified warning-free; fixed latent invalid target_compile_features call in M0-REPO-01 policy function |
| 2026-09-10 | M0-DEP-01 | — | deps.lock (repo root) + configure-time verification in cmake/laige-deps-lock.cmake (deterministic tree SHA-256; fails loudly on mismatch, missing tree, unlisted deps/ dir, or malformed lock); vendored GoogleTest v1.18.0 (deps/googletest, 252 files, commit 063de7e9…, BSD-3-Clause) wired into tests only (gtest_main, BUILD_GMOCK/INSTALL_GTEST off, no-exceptions/no-rtti flags match engine test TUs); laige-core_tests converted to the first GTest suite; ADR 0004 written; fresh+shared+ASan+Clang trees verified warning-free with ctest 1/1, tampered vendored file fails the configure |
| 2026-09-10 | M0-CI-01 | 7ec98b9 |
CI matrix .github/workflows/ci.yml (all 5 P0 jobs on push to master + workflow_dispatch: linux-gcc, linux-clang, windows-msvc, macos-arm64, macos-intel) and ci-pull.yml (one P0 OS per PR, selected by ci:linux/ci:windows/ci:macos labels, default Linux — PRD §14 cadence); each job = canonical configure→build→ctest with timeout-minutes: 10; fixes landed in the same step: .gitattributes (deps/** -text) for byte-exact LF vendored checkouts (Windows CRLF broke the tree-hash lock), _MSVC_LANG for the C++20 self-check on MSVC, MSVC /EH conflict resolution (strip platform-default /EHsc; gtest rewritten to its documented no-exception set /EHs-c- -D_HAS_EXCEPTIONS=0), and _HAS_EXCEPTIONS=0 in the engine policy for the MS STL (C4530 under /WX); verified: full matrix green after pushing a90191c..7ec98b9 |
| 2026-09-10 | M0-CI-02 | 6573a64 |
Sanitizer CI lanes linux-asan (LAIGE_ASAN=ON: ASan+UBSan, reports fatal via -fno-sanitize-recover=all + ASAN_OPTIONS abort/halt) and linux-tsan (LAIGE_TSAN=ON, per-test TSAN_OPTIONS=halt_on_error=1) in ci.yml (merge, all 7 jobs) and ci-pull.yml (PRs under the default-Linux P0 condition); sanitizer reports archived as artifacts (linux-asan-reports/linux-tsan-reports) on every run, green or red; the first CI run rejected the workflow file because step-level permissions: is not a valid schema — fixed in f75ed0d by moving actions: write to job scope (with continue-on-error: true uploads in ci-pull.yml for fork-PR read-only tokens); Verify cycle on CI: scratch OOB read (6331a40) failed linux-asan with the UBSan "index 16 out of bounds for type int[4]" report (archived) while linux-tsan and the other five jobs stayed green; scratch removed in 25b57b9; full 7-job matrix green |
| 2026-09-10 | M0-CI-03 | 785af81 |
tools/laige-include-lint (Python 3 stdlib): parses #include edges of src/**, enforces R1 (laige-core includes nothing internal), R2 (arrows only downward in the PRD §10.1 stack, via the target's public include root — CPP-010), R3 (vendored deps only from their deps.lock owner — new required lock field, validated by cmake/laige-deps-lock.cmake; angle-bracket vendored header paths caught via a vendored-header map), R4 (engine code includes only src/**/deps/**); reports the vendored-dependency list and fails above the PRD §11 budget of 10; include-lint CI job in ci-pull.yml (every PR, label-independent) and ci.yml (every merge, now 8 jobs); CTest coverage in tests/tools (4 fixture trees + real-tree check, expected failures asserted via generated cmake -P scripts because CTest inverts PASS_REGULAR_EXPRESSION under WILL_FAIL); local Verify: illegal laige-core → laige-render stub include fails with R1, all rule directions exercised, dep count prints (1/10), ctest 6/6 on g++/shared/ASan/Clang trees; pushed as 785af81 — CI observed via the GitHub API: ci.yml (8-job) run 34518244428 on 741c163 green, include-lint job log shows the live report (count: 1 (budget: 10, PRD §11), OK); ci-pull.yml job exercised by PR #1 (run 34521473503, green incl. include-lint), squash-merged as a74b65a with the post-merge 8-job run 34521722826 green |
| 2026-09-10 | M0-CORE-01 | 6fa1414 |
laige::Result<T,E>/laige::Status (no exceptions, FR-12.1; inline std::optional storage, SFINAE-guarded implicit constructors + success()/failure() factories, valueIfOk()/errorIfError() null-safe accessors) + central error registry (errors.h/errors.cpp: 4 pinned codes, 0 reserved, unregistered → unknown; pre-rendered NFR-13.3 5-field lines) with the human-readable registry in docs/api/errors.md; result_status CTest entry (16 cases: construction, propagation, copy/move, grammar per code, pinned values) in the shared laige-core_tests executable; local Verify: GCC static/shared/ASan/TSan + fresh Clang trees 7/7 ctest, zero warnings; first push f96ce7d failed 7/8 on windows-msvc (C2535: the Result(T)/Result(E) constructors have identical parameter lists when T == E) — fixed in 6fa1414 by taking the failure value by const E&; CI: ci.yml run 34525402022 on 6fa1414 (8-job matrix) green, Windows job compiles and passes result_status, every job under a minute |
| 2026-09-10 | M0-CORE-02 | 0d3ee28 |
The one structured logging facade (AGENTS §14, FR-12.2): laige::log::Logger Meyers singleton + LAIGE_LOG_* macros (gate before argument evaluation — disabled event = one atomic load + branch, no allocation, LOG-003); per-subsystem level table + atomic global minimum; Field scalars render locale-free via to_chars into a 64-byte stack buffer; rate limiting per (subsystem, event, severity) for Warn/Error/Fatal with rate_limited suppressed-count summaries (first event always emitted, pending counts drained at shutdown); ConsoleSink (non-owning stream) + FileSink (owning, create() → Result, failure = new ErrorCode::IoError 5, LOG-007 console fallback); Fatal = emit + flush + std::abort(); crash handlers (POSIX sigaction SA_RESETHAND / Windows vectored SEH) with raw-write notice + allocation-free try_lock flush; idempotent shutdown() retires the facade; timestamps = system_clock UTC RFC 3339 (in-code Hinnant civil-from-days); additive M0-CORE-01 extensions Result::takeValue() && + IoError; logging CTest entry (27 cases incl. zero-alloc proof via a test-only global operator new counter, excluded from sanitizer trees per the step's fallback: leak-free runs + timing property); API contract in docs/api/logging.md; local Verify: GCC static/shared/ASan/TSan + fresh Clang static/shared trees — ctest 8/8 and ctest -R logging green in every tree, zero warnings (disabled ≈46 ns/event vs ≈1207 ns/event enabled); CI (observed 2026-09-10 via the GitHub API): ci-pull.yml run 34534697621 on 0d3ee28 green — all 5 jobs of the default-Linux lane (linux-gcc g++, linux-clang clang++, linux-asan+UBSan clang++, linux-tsan clang++, include-lint) passed in 50 s, macOS/Windows skipped (label-gated) |
| 2026-09-11 | M0-CORE-03 | 0fd41f3 |
SimMath op interface (sim_math.h; one op interface, template dispatch with no per-call indirection — ADR 0002) + fp32_pinned backend: pinned IEEE float semantics via the new laige_apply_simmath_policy (GCC/Clang: -ffp-contract=off -fno-associative-math; MSVC: /fp:precise) applied to laige-core + test targets; NaN/Inf policy (isNaN/isInf, no signaling); math_float CTest entry (10 SimMath* cases) (board/changelog row reconstructed 2026-09-11 from the step record) |
| 2026-09-11 | M0-CORE-04 | 5c76991 |
laige::fpx16_16 Q16.16 default SimMath backend (ADR 0002): int64 arithmetic, saturating ops (no UB — CPP-004), ties-to-even rounding, defined zero-division (x/0 → ±max, 0/0 → +0), negate(min) = max, no implicit scalar constructors and no arithmetic operators (API-008); length precision bound documented; math_fixed CTest entry (FixedPoint* suite incl. the 4096-tick determinism sequence with FNV-1a known-answer hash 0xF02728762777C581, identical on g++ 16.2.1 and clang++ 22.1.8); CI fix 7538053 (macOS/Windows: missing <compare> include in fpx16_16.h) (board/changelog row reconstructed 2026-09-11 from the step record) |
| 2026-09-11 | M0-CORE-05 | 4a572af |
Header-only pools (pools.h): laige::ArenaPool<T> (contiguous bump arena, per-frame reset(), O(1) create) + laige::Pool<T> (stable handles = index + generation — CPP-007; LIFO free list; stale-handle destroy → InvalidArgument; capacity overrun → BudgetExhausted, no silent growth; PoolStats accounting: capacity/inUse/peakInUse/totalCreated/bytes); setup-only allocation, O(1) hot paths, single owner thread (CONC-001); pools CTest entry (25 cases incl. the stale-handle debug assert proven in a forked SIGABRT child); CI fix ba4ffb2 (Windows C4324: alignas(16) padding in pools_tests fatal under /WX) (board/changelog row reconstructed 2026-09-11 from the step record) |
| 2026-09-11 | M0-CORE-06 | a82de8e |
laige::Prng: xorshift128+ transcribed from and verified against the reference (all-zero state excluded), splitmix64 seeding (bijection), per-substream derivation (documented composition rule), Lemire unbiased next_range, next_float01 = k·2^-24 exact; full period 2^128 − 1 for every nonzero state proven (characteristic polynomial over GF(2) via Berlekamp–Massey + irreducibility/primitivity checks; portable 128-bit arithmetic — no __int128, MSVC-safe); prng CTest entry (18 cases incl. the committed period proof and algorithm-sensitive golden KAT); API contract in docs/api/prng.md (board/changelog row reconstructed 2026-09-11 from the step record) |
| 2026-09-11 | M0-CORE-07 | 41938b0 |
Bounded JSON in laige-core (ADR 0003, no new dependency): laige::JsonValue (deep copy, O(1) move, deep equality) + parseJson (1 MiB / depth-32 bounds, strict UTF-8, duplicate keys rejected, ±inf for overflow tokens — documented) + serializeJson (canonical ASCII; shortest-round-trip numbers; std::to_chars avoided for AppleClang 15 compatibility); all failures → MalformedInput (3); laige-fuzz minimal deterministic runner (Prng-seeded, --runs/--seed, 19-document corpus) + json_parse fuzz target; config_json CTest entry (25 cases) + fuzz_json_parse instrumented entry (ASan tree); API contract in docs/api/json.md (board/changelog row reconstructed 2026-09-11 from the step record) |
| 2026-09-11 | M0-CORE-08 | 810c251 |
Budget harness: laige::Histogram (fixed-capacity rolling window; O(1) allocation-free record(); exact min/mean/p50/p95/p99/max over the stored window via nearest-rank percentiles; allocation-free O(n log n) stats()) + laige::TimeIt (steady_clock ms scope timer) + loadBudgets/budgetCheck (strict budgets.json schema v1 via the bounded JSON parser — ARCH-007; loud NO_SAMPLES failure on empty histograms; target == 0 = hard-zero budget, not "not set"; AGENTS §12 report: stable 4-line text, before/after pair, caller context; formatStatsLine the single source of the stats text) + repo-root budgets.json (all 15 PRD §8.1 entries; measured: 0 = not yet measured) + laige-bench tool (canonical command per building.md: --suite=synthetic deterministic 4096-step LCG+double stand-in workload, --runs/--warmup, --budget=<name> check, exit code 2 on budget failure, --report append); budget_harness CTest entry (22 cases) + laige_bench_smoke CTest entry; bug fix (M0-CORE-07) found by this step's Verify run: json.cpp parseObjectMembers missing skipWhitespace before the member key — object documents with ", " between members (the hand-formatted budgets.json) were rejected; regression test ConfigJsonValid.ObjectMemberWhitespace (fails pre-fix); API contract in docs/api/budget_harness.md; local Verify: ctest 16/16, zero warnings on GCC static/shared/ASan/TSan + Clang trees; MSVC/AppleClang compile proof lands in CI |
| 2026-09-12 | M0-TOOL-01 | 937ac7c |
API manifest (NFR-13.1, PRD §9.4): laige-api target + tools/api/laige-api-scanner (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive // blocks with @budget/@experimental tags; --check FILE byte compare + symbol-level diff via laige::parseJson; exit 0 OK / 1 stale / 2 error) + checked-in laige-api.json (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + tests/api CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree --check) + the api-manifest CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as 937ac7c/PR #10 without updating this board or log) |
| 2026-09-12 | M0-TOOL-02 | fe4460c |
Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): laige-detcheck (tools/detcheck) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in docs/api/detcheck.md): one stdout line per tick <tick> <hash> — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing \r tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: --scenario=synthetic|synthetic-perturbed (built-in 32-body fpx16_16+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and --run-a=<bin> --run-b=<bin> [-- scenario-args...] (the M1-DET-04 mode; -- separator keeps scenario args unambiguous); stable report detcheck scenario=<name> result=OK|DIVERGED [first_diff_tick=<t>] + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (tests/detcheck, ctest -R detcheck): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated cmake -P script asserting exit code + output fragments (tests/api pattern); CI detcheck job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job — but NOT runtime-verified (stale as of 2026-09-13: the mode-2 capture was broken on the Windows CI runner and the mode-2 fragment assertions were dead, so the Windows failures were silent; both were found and fixed inside M0-TEST-01 / PR #13 — see the 2026-09-13 row); CI (observed 2026-09-12 via the GitHub API): ci-pull.yml run 34697638239 on 82c548d green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new detcheck job's real-scenario step correctly reported skipped: no real scenario yet (M1-SAMPLE-01) |
| 2026-09-12 | M0-TEST-01 | a292aef |
Test infrastructure conventions (docs/testing.md, source of truth, linked from docs/README.md, tests/README.md, building.md, README.md): tests/support/laige_test_seed.h (TestSeed()/TestPrng() — fixed default 0x1F055EED, identical to laige-fuzz's kDefaultSeed, one documented default seed repo-wide; LAIGE_TEST_SEED env override, 0x-hex/decimal, read at call time; set-but-unparseable value records a loud test failure and falls back to the default — CORE-008; per-test substream ids so streams never share position) + tests/testing (test_infra_tests, CTest entry test_infra, SeededRandom suite, 6 cases: 65536-draw FNV-1a KATs under the default seed 0x7EA4049545656830 and override seed 0x535D2CA741B61CBF, first-8-draw KAT, default/fuzz seed identity, loud invalid-env path via EXPECT_NONFATAL_FAILURE (gtest-spi.h), seed parser, substream isolation; machine-greppable test-seed-check line per KAT before asserting — the byte-identical-across-two-CI-runs identity check, M0-TEST-01 Verify) + first regress_ test (M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws; suite unchanged so ctest -R config_json still covers it; historical M0-CORE-08 record unchanged) + fuzz lane semantics (no new CI job — bounded --runs=1000 is the existing fuzz_json_parse ctest entry inside every P0 job's ctest, PRD §14 "every commit (bounded)"; nightly long form --runs=1000000 documented in docs/testing.md §3 + canonical command table; scheduled nightly lane lands with the first M1 fuzz target); CI workflow headers note the fuzz lane; local Verify: 32/32 ctest, zero warnings under NFR-8.10, on g++ static/shared, ASan+UBSan, TSan, and Clang trees; seeded KAT line identical on g++ and clang++ locally; cross-CI-run Verify: byte-identical test-seed-check lines in the archived Linux ASan LastTest.log across runs 34713354925/34714039135 (same-commit pair) and again across commits e2abce5→c8b8221 (runs 34728782624/34746755055); Windows CI fix chain landed inside this PR (M0-TOOL-02's Windows path, provenance per the established pattern — see the corrected M0-TOOL-02 row): MSVC portability (NOMINMAX, getenv_s/fopen_s C4996, C2664/C2440/C4457/C2660), WaitForSingleObject before GetExitCodeProcess (STILL_ACTIVE), then the root cause — the CI Windows runner (windows-2022) never delivers handles the process creates itself (pipes or files, INHERIT bit confirmed set, even duplicated) to children through STARTUPINFO; only parent-inherited (kernel-assigned) handles are delivered (measured runs 34728950395/34729337292) — and NULL STARTUPINFO is rejected by its CreateProcess machinery (run 34732057356); fixed by two-stage marker capture on all platforms (phase 1 --run-a/--run-b spawns with plain stdout inheritance + @@DETCHK-RUN-A/B-BEGIN/END@@ markers; phase 2 --compare-combined splits/validates/compares) plus the zeroed-STARTUPINFO spawn; dead fragment assertions fixed in 7928379 (@CHECKS@ variable-name misspelling + CMake single-backslash stripping → double-escaped regexes); final CI run 34746755055 (c8b8221): all 10 jobs green, Windows 32/32 |
| 2026-09-13 | M0-DOC-01 | 972090d |
Docs only: full AGENTS §13 docs/ tree — docs/README.md rewritten as the single index (every section linked + honest "not yet written" list, DOC-001); new section indexes concepts/ (planned docs + interim homes incl. the ARCH-008 coordinates topic), guides/, debugging/ (usable today; AGENTS §15 debug-mode status), compatibility/ (P0 platform/compiler table per PRD §6 + CI, current formats budgets.json/laige-api.json/deps.lock, no migration guides yet); docs/benchmarks/ with methodology.md (normative AGENTS §12 report fields, budgets.json field→report mapping, immutable baseline-file convention, PRD §8.1 regression policy: >10% band, hard-zero budgets, loud NO_SAMPLES) and empty baselines/ (first baseline m0-synthetic.md lands with M0-EXIT-01); decisions/ index updated (stale M0-DOC-01 note replaced; ADRs 0001–0004 indexed); stale references fixed in the same change (root README docs pointer + ADR 0004; old index's non-existent "M0-DOC-02" reference dropped); dead-link check over all 49 repo *.md files (104 internal links, 0 dead — manual per the step's Verify clause; docs-only scope, no CI job added); ctest 32/32 green on the existing build tree; CI run 34749554015: 7/7 executed jobs green (macOS/Windows skipped, label-gated) |
| 2026-09-13 | M0-EXIT-01 | 636257c |
M0 exit gate: all 21 prior M0 steps re-verified against their Scope/Verify clauses on commit 829026f — fresh canonical g++ tree zero-warning, ctest 32/32; build-shared, build-asan (ASan+UBSan), build-tsan, and a fresh Clang 22.1.8 tree all 32/32; laige-fuzz json_parse --runs=1000 clean; laige-detcheck --scenario=synthetic OK; laige-api-scanner --check up to date (376 symbols); tools/laige-include-lint OK (1/10 deps); vendored-tree lock re-proven live (tampered deps/googletest file fails the configure with expected-vs-actual hashes, restored tree passes); gate evidence: CI merge-lane run 34749756361 on 829026f — all 10 jobs success, each under 1.2 min, ctest 32/32 in every P0 OS job (linux-gcc/clang/asan/tsan, windows-msvc, macos-arm64/intel); first baseline docs/benchmarks/baselines/m0-synthetic.md written (synthetic harness workload, full AGENTS §12 metadata, verbatim runs, commit 829026f; no budgets.json measured updated — the stand-in measures no real budget); Progress Board 22/22, milestone marked complete |
| 2026-09-13 | M1-ECS-01 | f173682 |
laige-sim becomes the first module beyond laige-core (M1 milestone rules): 32-bit laige::Entity handle (16-bit id + 16-bit generation, Entity::kMaxEntities = 65536 slots, generation 0 reserved, the documented 2^16 wrap collision pinned by test) + laige::World entity storage (create/destroy/check/isValid/clear/stats over a pool-backed slot table — generation table + alive flags + pre-allocated LIFO free stack; setup-only allocation, O(1) no-allocation operations; G-R3 capacity config: BudgetExhausted on overflow, InvalidArgument for a >65536 budget at construction, API-008; stale-handle contract: debug assert / release Status(InvalidArgument) + warn-once through the logging facade, events ecs/stale_entity_access + ecs/stale_entity_destroy; move-only, single owner thread, ARCH-010 bit-identical handle sequences); public API in src/laige-sim/include/laige/sim/entity.h (+ entity.cpp), new CMake target (static/shared, engine + SimMath policy, single PUBLIC link to laige-core); entity CTest entry (17 cases: LIFO slot assignment, generation bump on reuse, recycling order, clear/move semantics, capacity limit incl. 0 and the 65536 boundary, stale-detection matrix, check() Status path in every build, release destroy Status path, forked-SIGABRT stale destroy in debug, pinned 2^16 generation wrap, stats counts/peak/churn/bytes, warn-once rate-limit summary via a memory sink); API contract in docs/api/entity.md; laige-api.json regenerated (api-real-tree green); local Verify: fresh canonical g++ tree zero-warning, full ctest suite green incl. the new module (34 tests), ctest -R entity and the full suite green on a fresh ASan tree (the step's Verify clause); tools/laige-include-lint OK; api-real-tree green after the manifest regeneration |
| 2026-09-13 | M1-ECS-02 | db13770 |
Component type registry (M1-ECS-02 scope, nothing else): ComponentTypeId (32-bit, dense ids assigned in registration order from 1, 0 reserved as kInvalidComponentTypeId, per-world, operator< = registration order) + LAIGE_COMPONENT(Type) macro (compile-time trait mark, data-only — replication/inspector traits land in M4) + World::registerComponent<T>() recording sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout; user-defined structs register through the same path (S-8 data-carrier case); type identity without RTTI/unordered (per-type inline static marker address, NFR-8.10/PERF-006); engine-level budget kMaxComponentTypes = 256 (BudgetExhausted beyond it); duplicate registration → InvalidArgument + rate-limited warn ecs/component_duplicate; moved-from world → no registry (InvalidArgument); static_assert guards: LAIGE_COMPONENT mark + trivially-copyable (actionable compile errors); setup-phase O(n) no-allocation operation; registry moves with World, clear() leaves it untouched; new public header src/laige-sim/include/laige/sim/component.h; component_registry CTest entry (12 cases: id order, size/alignment incl. 8-byte alignment, duplicate error + unchanged registry, id stability across two worlds with the same registration order, order-determines-ids, 256-type budget boundary, componentInfo validation, move, clear, warn-once rate-limit summary via a memory sink); API contract in docs/api/component_registry.md (+ cross-refs in docs/README, entity.md, sim README); laige-api.json regenerated (421 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full ctest green (35 tests) |
| 2026-09-13 | M1-ECS-03 | cad0594 |
Archetype SoA component storage (M1-ECS-03 scope, nothing else): archetype = an ordered component set stored SoA — one packed T[] column per component, rows in ascending slot-id order (a pure function of the world state; the convergence property M1-ECS-05 iterates), entity→archetype map as two dense per-slot tables (archetypeOf_ 2 B + rowOf_ 4 B — no hash; per-slot bookkeeping 5 → 11 B, entity.md) with get<T>/has<T> O(1) (slot → record → column binary search ≤ 32 → row); addComponent<T> create-or-update (in-place overwrite when present) and removeComponent<T> no-op-ok, both pool-backed moves (tail memmove + rowOf_ re-sync) with zero heap allocation per operation — the reserve policy (initial min(16, capacity) rows, ×2 growth capped at world capacity, one accounted+logged reserve per growth, bounded by log2(capacity/16)+1); budgets kMaxArchetypes = 256 / kMaxArchetypeComponents = 32 (BudgetExhausted + rate-limited warns ecs/archetype_budget, ecs/component_limit), unregistered type → InvalidArgument + ecs/component_unregistered, stale handle → the M1-ECS-01 contract (nullptr + warn-once), archetypes never destroyed (empty sets persist, accounted in bytesReserved); type→id via splitmix64 open addressing (512 slots, lookup-only — never iterated, no pointer-order portability issue, no 256-scan in the hot path); signature match via FNV-1a 32 short-circuit + lexicographic verify; destroy/clear now detach rows first (documented O(tail × row-stride) cost, still no allocation); 25 cases in the archetype CTest entry (basics, access/stale matrix, layout properties: per-column contiguity + 32 B alignment + slot-ordered addresses + shift semantics, move data preservation, two-world layout convergence, 256-set/32-component/growth-cap-at-18 budget boundaries, warn-once via memory sink, stats/bytes tracking, 10k-entity churn: 20k add/remove ops in seeded random order — zero failures, zero reservation delta, zero process-wide allocations (test-only operator new counter, non-sanitizer trees; sanitizer trees prove it leak-free), p99/p50 ≈ 1.98 (flat), machine-greppable archetype-churn <stats> line on every ctest run — measured baseline g++ 16.2.1: Debug p50 0.123 ms / Release p50 0.0021 ms per op); API contract in docs/api/archetype.md (+ cross-refs in entity.md, component_registry.md, docs/README, sim README); laige-api.json regenerated (443 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full ctest green (36 tests), ctest -R archetype green, sim suites green on build-asan (ASan+UBSan, leak-free churn), build-clang, build-release, build-shared, build-tsan, tools/laige-include-lint OK |
| 2026-09-14 | M1-ECS-04 | f2f57e8 |
Query API + iteration legality (M1-ECS-04 scope, nothing else): World::each<T1, T2, ...>(fn, Read/Write tags...) over the M1-ECS-03 SoA rows — superset match (extra components do not exclude), per-component access declared by tag TYPE (Read → const T&, Write → T&, decided at compile time; count/type checked by static_assert), the access tags follow the callable (a pack of parameters must be last to be deducible — the PRD sketch's (access_flags, fn) order settled here and documented in query.h), each<>(fn) visits all live entities ascending slot order, an unregistered listed type matches nothing (ok Status, zero visits); the iteration-legality guard — two stack-scoped membership-only detail::IdSet256 sets (the matched-archetype set, complete before the first callback, plus the Read-declared component set) — enforces the query.h legality table: in-place write of a Read-declared queried component, structural add/remove/destroy/clear touching a matched source/target archetype, and a nested each() all assert in debug (six forked SIGABRT children) and in release return ErrorCode::InvalidArgument (no new codes — the registry stays additive-only) + one rate-limited warn + skip-without-applying while the iteration continues over the unmutated storage (FR-12.3; events ecs/iteration_write_during_read, ecs/iteration_mutation, ecs/iteration_clear, ecs/iteration_nested); legal paths: create() always, structural moves outside the matched set, writes through Write references, in-place overwrites of Write-declared components; no hidden allocations — iteration state is stack-scoped (ids[N]/cols[N]/matchedIds[256]/two 256-bit sets), the 10k-entity × 2-pass window (a Write pass storing per visit + a Read pass) measured zero process-wide heap allocations (test-only operator new counter, non-sanitizer trees; sanitizer trees leak-free) and zero reservation delta (pool-steady), ≈0.044 µs/visit on the -O0 tree (machine-greppable query-iteration <stats> lines per ctest run — CORE-001, M1-BENCH-01 baseline input); clear() is now Status (guard check first — a clear under a live iteration is rejected whole, never partial; the dtor never sees an active iteration) and all call sites honor [[nodiscard]]; compile-time machinery: the component/access packs ride as single tuple types (std::tuple<Ts...>, std::tuple<Acc...>) because an explicit template argument list cannot partition between consecutive packs, rowRef returns one conditional_t reference type (a decltype(auto) if/constexpr pair of returns forces inconsistent deduction), visitRowRec carries the accumulated references as a Refs&... reference pack (forwarded each level — no component copies); new public header src/laige-sim/include/laige/sim/query.h (Access/Read/Write, detail::IdSet256, the full contract) + src/laige-sim/query.cpp (the four guard helpers); 17 cases in the query CTest entry (exact mixed sets incl. superset, empty-query slot order + legal concurrent destroy, unregistered no-match, access-tag reference kinds, pinned archetype-then-slot visit order, guard release after iteration, the legal-mutation matrix, the release skip matrix (state unchanged + iteration continues + the same op succeeds afterwards), six debug assert cases, warn-once + rate_limited summary via a memory sink, the zero-alloc window); API contract in docs/api/query.md (+ cross-refs in entity.md, archetype.md, component_registry.md, docs/README, sim README); laige-api.json regenerated (451 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full ctest green (37 tests), ctest -R query green (15 passed + 2 release-only skips; 11 passed + 6 debug-only skips on build-release), sim suites green on build-asan (ASan+UBSan, leak-free), build-clang, build-release, build-shared, build-tsan, tools/laige-include-lint OK |
| 2026-09-14 | M1-ECS-05 | 3cf8f91 |
Deterministic iteration order (M1-ECS-05 scope, nothing else): the documented contract over the World::each visit order — archetypes in ascending archetype id (= the order a component set is first seen by a mutation, i.e. creation order), entities within an archetype in ascending slot id, the empty query ascending slot id; the order is a pure function of the world state, never of the operation history; the dense-id-order scheme (rows kept in ascending slot-id order through insert/remove — archetype.h invariants I1–I4) documented as what makes convergent histories visit identically; no unordered containers in the iteration path (only the fixed 256-record archetype table scanned in id order, packed slot columns, per-slot direct-index records, and membership-only 256-bit guard sets — the anticipated entity→archetype "one internal hash structure" is direct indexing, not even a hash, a stricter reading of the allowance; the one hash structure in laige-sim, the component type-key index, is lookup-only and never iterated, and sits on the setup path, not any tick); convergence property test: two worlds whose operation sequences interleave create/destroy differently (sequence A: serial per-entity scripts + end-phase dead destroys; sequence B: the same create phase — LIFO requires it for the identical entity→id assignment — with PRNG-scattered dead-destroys at round boundaries, round-robin component steps over the live entities in per-round PRNG permutations, and PRNG-placed component-less scratch pairs) converge on the identical final state including the entity→id assignment and iterate identically for five queries (each<>, each<A>, each<A,B>, each<B>, each<C>), verified per-visit (slot, generation, component values) against an independent oracle (free-list simulation + first-seen archetype order); component moves are structural throughout (adds of absent / removes of present components — the dense-id scheme pinned, not assumed); the archetype-1/archetype-2 boundary is non-vacuously distinguished from a global slot order (archetype 2 opens at slot 4, below archetype 1's top slot 19); KAT test pins the exact degenerate visit sequences; fixed PRNG seed (TestPrng substreams 1005–1007, LAIGE_TEST_SEED overridable) with machine-greppable iter-order … fnv1a=0x… lines — the visit hash is byte-identical across all four scenario instantiations and across g++/Clang/ASan/TSan/release trees and overridden seeds; new iter_order CTest entry (the step's Verify command, added to the TSan property list) over the shared laige-sim_tests executable; no public API added — laige-api.json unchanged (452 symbols, scanner rerun clean), no include-graph change (comments only; tools/laige-include-lint OK); API contract in docs/api/iteration_order.md (+ cross-refs in query.md, entity.md, archetype.md, component_registry.md, docs/README, sim README); local Verify: ctest -R iter_order green on build (g++), build-asan, build-tsan, build-clang, build-release; full laige-sim_tests suite green on build |
| 2026-09-14 | M1-ECS-06 | 675fb94 |
ECS guardrails (G-R3 entity-count thresholds, G-R4 per-frame component churn; M1-ECS-06 scope, nothing else): create() warns exactly when the live count REACHES 25/50/100% of the declared scene budget (integer thresholds capacity*pct/100; a level whose threshold computes to 0 never fires), at most once per level per frame (events ecs/entity_budget_{25,50,100}); the G-R4 per-frame churn counter (successful addComponent calls, including in-place overwrites, plus row-detaching removeComponent calls) warns when the per-frame total STRICTLY EXCEEDS Options::churnPerFrameBudget (default 256; 0 disables), at most once per frame (event ecs/churn_per_frame); both O(1) integer bookkeeping, no hot-path allocation (the warn paths are cold, LOG-003); NFR-13.3 5-field message grammar, build-stable; debug builds carry the PRD §9.3 advice as a structured advice FIELD (never message text); beginFrame() drives the per-frame windows (M1-LOOP-01 will wire it); guardrailStats() is the M1-PROF-01 feed; new EcsGuardrails suite (ctest -R ecs_guardrails — exact threshold firing, once-per-level-per-frame dedup, degenerate thresholds, churn budget exceed/strictness, per-frame reset, no-op removes uncounted, budget-0 disable, grammar parse, profiler feed, zero-alloc below-threshold window); local Verify: canonical g++ tree zero-warning, full ctest green, ctest -R ecs_guardrails green, sim suites green on build-asan/build-clang/build-release/build-shared/build-tsan, include-lint OK; (board/changelog row retroactively added 2026-09-14 by the M1-ECS-07 PR — the step merged as 675fb94/PR #22 without updating this board or log) |
| 2026-09-14 | M1-ECS-07 | 2995ec7 |
ECS stress + memory accounting test (M1-ECS-07 scope, nothing else): new EcsStress suite — the step's Verify command ctest -R ecs_stress (added to the TSan property list) over the shared laige-sim_tests executable: 10k entities at the 100% scene budget (capacity 10000), 6 registered component types (Pos/Vel/Flag/Quad/Pair/Tag — 4/8/8/16/8/4 B, distinct strides), 10k frames of add/remove churn — each frame beginFrame() (drives G-R3/G-R4) + 64 seeded Tag adds + 64 Tag removes (cyclic Fisher-Yates permutation, TestPrng substream 1008, default-seed deterministic; 128 ops/frame, the default 256 G-R4 budget never exceeded) + one each<Pos,Tag> iteration (Read, Read) with visit count + 64-bit FNV-1a checksum over (slot, generation, tag value); 700-frame warm-up (one full 625-frame cohort period — 8 cycles × 10000/128 picks/cycle — plus margin) brings every archetype's columns to their high water before the window, so the window's zero totalReservations/totalArchetypeGrowth delta IS the "pool high-water stable" claim (measured high water: 9 archetypes — 4 base + 4 Tagged + transient {Pos} — 24592 reserved rows, 549024 bytes); iteration within the documented cost (query.h: bounded archetype scan + one visit per matching entity) via a window-wide ns-per-visit throughput floor (600 ns, ≥8x the slowest measured: 58.7 ns g++ 16.2.1 / 72.3 ns clang++ 22.1.8, -O0 Debug); zero-allocation window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free run + reservation delta); no NEW guardrail warns in the window (churnWarns delta 0; the entity-budget 25/50/100% warns fire exactly once at setup and never re-cross — the entity count never changes); memory accounting (PRD §8.1 base memory, accounted bytes): 110000 entity bookkeeping bytes (11 B/slot × 10k) + 549024 reserved row bytes at the 100%-full scene; machine-greppable ecs-stress window/iteration/memory lines on every ctest run — the window/memory lines are BYTE-IDENTICAL across g++/clang++ (pure integer workload, ARCH-010) and across repeated runs; no-leak Verify: ctest -R ecs_stress green on build-asan (37.7 s, no ASan/UBSan report); second baseline docs/benchmarks/baselines/m1-ecs-stress.md (AGENTS §12 fields, verbatim runs, cross-tree results; not a budgets.json workload — no measured field updated); baselines index + benchmarks README updated; no public API added — laige-api.json unchanged (452 symbols, api-real-tree green in the full-suite runs), no include-graph change (comments only); local Verify: ctest -R ecs_stress green on build (Debug g++, 14.7 s), build-asan (required, leak-free), build-release, build-clang, build-tsan, build-shared; full suite 40/40 on build/build-asan/build-clang, laige-sim_tests + ecs_stress green on the other trees; zero new warnings under NFR-8.10 |
| 2026-09-14 | M1-SYS-01 | 115d28c |
System registry (FR-1.3: plain registered functions with declared time budgets and declared component I/O; M1-SYS-01 scope, nothing else): new public header src/laige-sim/include/laige/sim/system.h — SystemId (32-bit dense id from 1, registration order, per-world, deterministic — component.h id contract), SystemDef (name + SystemFn = void(*)(World&, SystemContext&) + budgetMs in ms as fpx16_16 — exact, ADR 0002, keeps the future sim source scan float-free), the LAIGE_SYSTEM(Name, budget_ms) macro (namespace scope: the plain function declaration + the Name##Def def variable — no class, no inheritance), SystemContext (per-tick world view; each<T1..TN>(fn, Read/Write tags...) delegates to World::each — definition out-of-line in entity.h where World is complete), Io<T, Access> (the per-component declared I/O tag; a component appears at most once per system, any access combination — the I/O is a set, not a multiset, stored as disjoint read/write id sets; ascending-id enumeration order documented), SystemInfo (the def value copy + declaresRead/declaresWrite, the M1-SYS-02/M1-PROF-01 feed), kMaxSystems = 256 (engine-level bound, CORE-005), detail::SystemRecord + the IsIoTag/IsIoComponent/IoComponent traits (class form — the api scanner parses class partial specializations; variable templates are an unsupported scanner construct); World::registerSystem(def, Io<...>...) (header-defined template, entity.h), World::systemCount(), World::system(id) (systems.cpp); the fixed record table is allocated in create() like the component registry, travels with the world on move, and survives clear(); validation (first failure wins; every failure one rate-limited structured warn + Status — FR-12.3/LOG-004): moved-from world → InvalidArgument (no warn, the registerComponent precedent), null/empty name → system/name_invalid, null run → system/run_invalid, budget ≤ 0 → system/budget_invalid (the budget must be explicit and positive), duplicate name → system/duplicate (the roadmap's named property), Io T not a component → compile error (static_assert), Io T unregistered in this world → system/io_unregistered, same component twice (any access) → system/io_duplicate, > kMaxSystems → BudgetExhausted + system/budget_exhausted; the def is value-copied into the record table: no allocation at registration (setup path — PERF-003); new SystemRegistry suite (25 tests, CTest entry system_registry, added to the TSan property list): registration, ids dense from 1, the def value copy, the I/O sets + zero-I/O pack, context delegation (write + read paths through the plain functions), every validation error, the kMaxSystems budget (257 distinct names), system() id validation, id stability across two worlds + registration-order-determines-ids (ARCH-010), move/clear/moved-from-world lifetime, the zero-alloc registration window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free), warn-once + rate_limited sink checks (system/duplicate name/existing_system_id fields, system/budget_invalid budget_raw field); docs: docs/api/system_registry.md (full contract + Performance section) linked from docs/README.md, src/laige-sim/README.md status updated (incl. the M1-ECS-06/07 lines), entity.h preamble + member docs carry the M1-SYS-01 note; laige-api.json regenerated (489 symbols; api-real-tree green); local Verify: ctest -R system_registry green on build (25/25 incl. the zero-alloc window), full suite 41/41 on build/build-asan (leak-free)/build-tsan/build-clang/build-release/build-shared, zero new warnings under NFR-8.10, tools/laige-include-lint OK (27 source files, 1/10 vendored deps) |
| 2026-09-14 | M1-SYS-02 | 84c5c06 |
System scheduler (M1-SYS-02 scope, nothing else): the scheduler turns the M1-SYS-01 registry (registration order + declared depends_on + declared component I/O) into the per-tick execution order and runs the systems in it — SystemSchedule (the systemCount plus the dense SystemId order array), World::scheduleSystems(SystemSchedule&) const (setup phase; pure registry read; the STABLE topological sort of the registration order plus the depends_on edges — Kahn's algorithm with a min-id tie-break: repeatedly place the smallest unrun id whose dependencies are all placed, so a system only moves LATER, behind its dependencies, and no dependencies = exactly the registration order), and World::runSystems(const SystemSchedule&) (one sim tick's system phase: the systems run STRICTLY one at a time in schedule order on the world's single owner thread, a fresh non-owning SystemContext per system — PRD §10.2/API-004); SystemDef gains dependsOn (the raw comma-separated registration-name spec; nullptr/"" = none) and LAIGE_SYSTEM(Name, budget_ms, Dep..., ...) becomes variadic (the optional trailing names stringized verbatim into the spec — LAIGE_SYSTEM(Health, 1, Spawner) = spec "Spawner"); kMaxSystemDependencies = 16 (the direct-dep bound, CORE-005 — a barrier is registration position, not a dependency list); detail::DepSpecParse/DepSpecError/parseDepSpec/depSpecErrorName (system.h; defined in systems.cpp — tokens point into the spec literal, no copy, no allocation); validation (first failure wins; every failure one rate-limited structured warn, subsystem system, + Status — FR-12.3): at REGISTRATION (the def-level form, before the duplicate-name check — def fields first): malformed spec (empty token/trailing comma, duplicate name, > 16 deps) → system/dep_spec_invalid (fields name/error); at SCHEDULING (normative order): unknown dependency name (first in ascending (system id, spec position)) → system/dep_missing (fields system/missing_dep/position; the token logged bounded to 64 chars — LOG-005), dependency cycle → system/dependency_cycle (ONE concrete cycle reported: the deterministic walk from the smallest remaining id following each system's first spec-listed dependency that is still remaining — a remaining system always has one, the Kahn invariant; the cycle field is the walk order, comma-joined, bounded to 256 chars — independents already scheduled are excluded), two systems both declaring Write of the same component in one tick (order-independent: the last write would silently win; first conflict in ascending component-id then writer-id) → system/double_writer (fields component_id/first_writer/second_writer); WARN ONLY (scheduling succeeds): a declared read that the computed order places BEFORE a declared write of the same component (the reader sees the previous tick's value; each (reader, writer, component) triple once, ascending component/reader/writer; fix advice in the message: declare depends_on or register the writer earlier) → system/read_before_write (fields reader/writer/component_id); at RUNNING: schedule.systemCount ≠ the current systemCount (registry changed since scheduling, or another world's schedule) → system/schedule_stale (fields scheduled_systems/current_systems), an order entry that is 0 / above the count / a duplicate id (hand-built schedule) → system/schedule_invalid (fields slot/id), empty schedule → ok and runs nothing; the success paths log nothing (LOG-003); determinism (ARCH-010): pure integer/string bookkeeping — no floating point, no randomness, no addresses in the order or the warning set (two worlds, two runs, two builds → bit-identical schedules + warning sequences); no allocation at scheduling or per tick (PERF-003 — all state fixed-size stack/world arrays); new SystemScheduler suite (26 tests, CTest entry scheduler, added to the TSan property list): registration order = execution order, forward/backward deps, the chain and the diamond (reversed spec list — the dependency set is orderless, the tie-break is the min id), the macro spec stringization (1-dep and 2-dep macro forms + the no-dep "" spec), running in scheduled order with state flow (writer before reader → the reader sees the fresh 0x1234; reader before writer → the stale value 0x9999 is observed), reader-before-writer warns (sink: reader/writer/component_id fields; schedule still succeeds), writer-before-reader is clean (the sink stays EMPTY — the success path logs nothing), double-writer rejected (sink: component_id/first_writer/second_writer + LOG-004 rate_limited summary suppressed=2 on shutdown), missing dependency (sink: system/missing_dep fields), the 2-cycle + the self-dependency (cycle [self]) + the cycle among independent systems (the reported cycle excludes the independents that scheduled first), spec validation (empty token, trailing comma, duplicate name, the 17-dep bound, whitespace trimming is legal — " TrimA , TrimB " resolves), the empty + moved-from world schedules and runs empty, the stale schedule is rejected (recompute → usable; nothing ran), the hand-built malformed schedules (duplicate id, id above the count) are rejected (nothing ran), the order bit-identical across two worlds with the same registrations (ARCH-010 memcmp over the order arrays), the known-answer pin (the fixed 5-system scenario WITH a forward edge: order B,A,C,D,E — machine-greppable scheduler-order systems=5 fnv1a=0xaef3282f393ab332, pinned in the test), and the zero-alloc window (100 ticks × 3 systems over 4 entities: schedule + every runSystems allocate nothing — the test-only operator-new counter, non-sanitizer trees; machine-greppable scheduler-zeroalloc ticks=100 allocs=0; the sanitizer trees prove it leak-free); docs: docs/api/scheduler.md (full contract + Performance section) linked from docs/README.md (the API list + the per-module laige-sim list, which gains the previously missing system_registry.md entry), docs/api/system_registry.md updated (the macro is variadic now, the validation table gains the dep_spec_invalid row, cross-refs to scheduler.md), src/laige-sim/README.md status updated, system.h/entity.h preambles + member docs carry the M1-SYS-02 note; no new source file (the scheduler lands in systems.cpp — the sim CMake comment updated); laige-api.json regenerated (496 symbols, +7: kMaxSystemDependencies, SystemDef::dependsOn, SystemSchedule + systemCount + order, World::scheduleSystems + World::runSystems; api-real-tree green); local Verify: ctest -R scheduler green on build (26/26 incl. the zero-alloc window), full suite 42/42 on build/build-asan (leak-free)/build-tsan/build-clang/build-release/build-shared, zero new warnings under NFR-8.10, tools/laige-include-lint OK (27 source files, 1/10 vendored deps) |
| 2026-09-14 | M1-SYS-03 | a63d6b9 |
Per-system timing + budget enforcement (PRD §9.3 G-R5; FR-11.1/11.2, FR-12.3; M1-SYS-03 scope, nothing else): World::runSystems now times each system's own run (the M0-CORE-08 TimeIt steady_clock scope around the run function — two steady_clock reads per system, the context built outside the window) and hands the sample to World::checkSystemBudget (new src/laige-sim/system_timing.cpp): it records into the system's rolling window — a fixed-capacity Histogram (kSystemTimingWindowSamples = 64 samples ≈ 1.1 s at 60 Hz; O(1) record, no allocation, drops the OLDEST on overflow, totalRecorded() keeps counting; the window rolls across TICKS — beginFrame() does not touch it) — and enforces the declared budget: measured > 1× budget → system/budget_overrun (Warn), measured >= 3× budget (kBudgetCriticalMultiplier, PRD "over 3× → error event") → system/budget_critical (Error); a 3× run fires BOTH in the same tick. Both events: NFR-13.3 5-field grammar (build-stable message text; dynamic values as structured fields system/id/measured_ms/budget_ms/p99_ms/window_samples — never message text), rate-limited per (subsystem, event, severity) with the 1 s window (LOG-004; the rate_limited summary carries the suppressed count), and count in SystemTimingStats (warns/errors) even when suppressed; the p99 comes from one cold O(W log W) stats() pass (no allocation) only while the breach persists. An over-budget system is STILL RUN — observation and reporting, never an execution gate (FR-12.3). New public API (additive): SystemTimingStats (runs/lastMs/warns/errors), kSystemTimingWindowSamples, kBudgetCriticalMultiplier, World::systemTimingStats(SystemId) (Result; O(1) pure query; invalid id or moved-from → InvalidArgument, no warn — the World::system precedent) and World::systemTimingWindow(SystemId) (const Histogram* — the M1-PROF-02 frame graph's budgetCheck feed; nullptr for invalid); detail::SystemTimingRecord (unique_ptr Histogram — the Histogram has no default ctor — + the cheap scalars) in a fixed kMaxSystems table parallel to the registry (allocated in create() even for zero-capacity worlds, travels with the world on move, survives clear()); World::checkSystemBudget (private hook, called per system per tick). Determinism: measured times are DIAGNOSTIC only (ARCH-009) — they never enter authoritative state, hashes, or replays. Hot path: two clock reads + one ring write + two comparisons per system per tick — no allocation, no logging on success (the SchedulingAndTicksAllocateNothing window still proves 0 allocs over 100 ticks). New SystemTiming suite (8 tests) + CTest entry system_timing (the step's Verify command; TSan property list): healthy ticks log nothing + track stats (runs/lastMs/window count/totalRecorded); over-budget synthetic system warns at the documented multiplier (1 warn, measured ≥ 6.5 ms against a 5 ms budget; second tick rate-limited; shutdown rate_limited summary suppressed = 1); critical synthetic system (1 ms budget, 7 ms burn) fires the warn THEN the error in one tick; rolling window drops oldest (5W-sample fast/slow/fast phases with min/max/p99 bounds + machine-greppable system-timing window line); NFR-13.3 grammar check (5-field split on `" |
| 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<Backend> — 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<Backend> (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<T>() 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<Position2DFpx16, Write> wired through the thunk; snap.lastTick() == loop.currentTick() at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + onTick + onRenderFrame + 100 sample_position calls; test-only operator-new counter, machine-greppable presentation-zeroalloc ... allocs=0, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); laige-api.json regenerated (555 symbols — +24 public symbols: Position2D/Position2DFpx16/Position2DFp32, PresentationSnapshot + members, GameLoop::Options::TickFn/onTick/onTickContext, GameLoop::startReferenceNs; api-real-tree green); local Verify: ctest -R presentation green, the canonical g++ tree zero-warning with full ctest 45/45, and zero-warning 45/45 on build-asan, build-tsan, build-clang, build-release, build-shared; tools/laige-include-lint OK; Progress Board 12/25 (total 32/193) |
| 2026-09-15 | M1-HEAD-01 | e80ccc8 / PR #31 |
Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): Engine (new public header src/laige-sim/include/laige/sim/engine.h + src/laige-sim/engine.cpp) — EngineConfig (tickRateHz 20–120 default 60, entityCapacity 0–65536 default 0 = empty scene, churnPerFrameBudget 0–4294967295 default 256) + parseEngineConfig over the bounded JSON (M0-CORE-07): unknown key → one config/unknown_key warn, ignored (forward-compatible); rejections config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid} (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); Engine::create pre-validates the tick rate, creates the World, registers Position2DFpx16 FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks): scheduleSystems → GameLoop (with the engine's per-tick hook — snapshot exists before the hook can fire) → first frame() (0 ticks, establishes the start reference) → PresentationSnapshot anchored on the loop's exact startReferenceNs() (ARCH-009) → wall-clock-paced frames (ONE steady_clock read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run ALWAYS ends in the ordered shutdown (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; world() reads back nullptr; a second run_headless on a stopped engine → InvalidArgument with no log — the moved-out GameLoop precedent); maxTicks == 0 = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair engine/run_started/engine/run_finished (structured fields incl. status; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the GameLoop object, the PresentationSnapshot object, the 24 B/slot record table) and zero per-frame allocations — verified with the test-only operator new counter: the count is identical for 1/2/3/10 ticks (machine-greppable engine-zeroalloc ticks=… allocs=3; the M1-ALLOC-01 pool accounting supersedes the probe); laige-run binary (new tools/run, target laige-run): --headless CONFIG (1 MiB bounded read — over-bound MalformedInput, read error IoError), --ticks N (digits-only strtoull), --replay LOG stub (accepted, warned replay/replay_deferred, ignored — M1-DET-02), --help; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…; the CLI calls shutdown() a second time (the idempotency demo); laige_run_smoke CTest entry (--ticks 1000 against tests/laige-sim/fixtures/headless_smoke.json — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION status=ok, TSan TSAN_OPTIONS=halt_on_error=1 — the step's CI Verify on every P0 OS job); engine CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn loop/catchup_invalid, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new docs/api/engine.md (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in docs/README.md (API list + M1 status line + the laige-sim doc list + the tool command list), docs/getting-started/building.md (the canonical laige-run command row + the tool-row note), tools/README.md; laige-api.json regenerated (555 → 573 symbols; +18: Engine + 9 members, EngineConfig + 3 fields, parseEngineConfig; api-real-tree green). Deviation (surfaced, not silent): declared dependency M1-CFG-01 has NOT landed — the JSON config surface is PROVISIONAL (three unversioned keys; parseEngineConfig documented as provisional in engine.md, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 ctest on all six local trees (build Debug GCC 16.2.1, build-asan ASan+UBSan leak-free, build-tsan, build-clang 22.1.8, build-release, build-shared), ctest -R engine green (20/20), ctest -R laige_run_smoke green (≈16.7 s, status=ok), tools/laige-include-lint OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) |
| 2026-09-15 | M1-DET-01 | 56f2835 / PR #34 |
Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header src/laige-sim/include/laige/sim/determinism.h — SimMathBackend (FixedPoint16_16 default / FloatPinned32), DeterminismConfig {enabled, math}, the G-R8 compile-time trait (detail::IsDeterminismSafe<T>: false by default; true for integers, enums, fpx16_16, float (the fp32_pinned Scalar), the four SimMath<B>::Vec2/Vec3; double intentionally never safe — no backend uses it), detail::areDeterminismSafeMembers<Ts...> (the &&-fold), and LAIGE_DETERMINISM_SAFE(Type, Members...) (declares the member list IS the storage; a non-safe member — e.g. double — is a compile error AT THE MARK SITE, a new static_assert in the specialization, before any system can use the component); the trait is enforced by a third static_assert in World::registerSystem (entity.h) folding detail::IoComponentSafety<Io<T, Access>> over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: World::Options gains seed/deterministic (world state carried through create/move/assign; entity.cpp), registerSystem derives each system's substream Prng::deriveSubstream(seed, systemId) (id 0 = master, never assigned) into detail::SystemRecord.rng (std::optional<Prng>), and runSystems hands the NON-const record's stream to SystemContext.rng (a new Prng* field, NSDMI — advanced in place during draws: the stream state IS the replay state); EngineConfig appends seed (full u64, kDefaultSimulationSeed = 0) + DeterminismConfig determinism (existing 3-member aggregate inits keep compiling); parseEngineConfig gains seed (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the determinism object (enabled bool, math ∈ the two ids; unknown nested key → one config/unknown_key warn, ignored — first failure wins across keys) with new rejection events config/seed_invalid / config/determinism_invalid / config/determinism_enabled_invalid / config/determinism_math_invalid; Engine::create forwards seed + mode to the world and registers the backend-matching built-in FIRST (Position2DFpx16 / Position2DFp32) and builds the presentation snapshot for the same backend (type-erased detail::PresentationHandle — one setup allocation, the fnptr-deleter unique_ptr idiom, zero added allocations: the headless setup path stays exactly 3); engine/run_started gains seed/determinism/math fields (the laige-run CLI summary line is unchanged); tools/laige-determinism-lint (NEW; Python 3 stdlib, the laige-include-lint style) — the sim-source scan over src/laige-sim/**: D1a raw float/double type tokens, D1b float literals, D1c double literals, D2 unordered_{map,set,multimap,multiset}, D3 malformed exception markers; a char scanner strips ////* */ comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: Float/fromFloat/next_float01 do not match); the documented false-positive policy = same-line // LAIGE-DETERM-EXCEPTION: G-R8 <reason> markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own float registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: tests/laige-sim/determinism_tests.cpp (suites DeterminismMode/DeterminismEngine/DeterminismConfigParse; CTest determinism_mode = the step's Verify command) — a trivial moving-entity sim (two entities, Position2DFpx16 + a marked DetVel component, a mover system doing one ctx.rng->next_range(0,5) draw per tick at a fixed position + pos += vel through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable determinism-tick-stream line); a different seed diverges; the system's draws equal an independently constructed Prng::deriveSubstream(seed, id) exactly (golden cross-check) and two systems' streams are independent; deterministic == false → ctx.rng == nullptr; backend selection (fp32 config → Position2DFp32 duplicate-rejected / Position2DFpx16 available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). tests/laige-sim/compile_fail/ (4 fixtures + expect-compile-result.cmake.in, CTest trait_compile_*): the positive fixture compiles (exit 0); the three negatives (a double member, an unmarked user struct, a double in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). tests/tools gains the determinism-lint-* fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: determinism-lint job added to BOTH .github/workflows/ci-pull.yml and ci.yml (ubuntu-24.04, python3 tools/laige-determinism-lint). Docs (DOC-007, same change): NEW docs/concepts/determinism.md (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW docs/api/determinism.md (the trait API contract) + updates to docs/api/engine.md (the seed/determinism keys, backend selection, run_started fields, the determinism scope), docs/api/system_registry.md (the ctx.rng bullet + the G-R8 validation row), docs/api/entity.md (the World::Options seed/deterministic fields), docs/api/sim_math.md (the G-R8 enforcement note), docs/testing.md (the determinism test entries), docs/concepts/README.md, docs/README.md, src/laige-sim/README.md. laige-api.json regenerated (573 → 588 symbols; +15: SimMathBackend + 2, DeterminismConfig + 2, LAIGE_DETERMINISM_SAFE, World::Options + 2, SystemContext::rng, EngineConfig + 2, kDefaultSimulationSeed; api-real-tree green). Verified: ctest -R determinism_mode green (14/14), ctest -R trait_compile green (4/4), ctest -R determinism-lint green (3/3), python3 tools/laige-include-lint OK, python3 tools/laige-determinism-lint OK (17 files, 15 marked exceptions), full ctest 55/55 on build (Debug GCC 16.2.1) and 55/55 on build-asan (ASan+UBSan leak-free); zero new warnings under NFR-8.10. Deviation (surfaced, not silent): declared dependency M1-CFG-01 has NOT landed — the seed/determinism keys sit on the PROVISIONAL parseEngineConfig surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. |
| 2026-09-16 | M1-DET-02 | 817ebe9 |
Replay recorder (FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed — ADR 0002, ARCH-007, SCALE-005; M1-DET-02 scope, nothing else; replay EXECUTION — world.state_hash + the laige-replay runner — is M1-DET-03): the versioned replay LOG FORMAT (v1; src/laige-sim/include/laige/sim/replay.h + replay.cpp): 40-byte header — magic LGRP, formatVersion u16 (= 1, the single version gate — unsupported versions rejected explicitly, ARCH-007), reserved u16, then the ADR 0002 REPLAY IDENTITY: seed u64, tickRateHz u32, componentSchemaHash u64, mathBackendId u32, configHash u64 — + per-tick frame records (tick u64, strictly sequential from 1; byteLength u32 ≤ kMaxReplayFrameBytes = 1 MiB; the payload an OPAQUE byte blob — M1 frames are zero-length, the input data shape lands with M3-INPUT-03) + 16-byte trailer (frameCount u64 + fileHash u64 = canonical byte-stream FNV-1a 64 over every prior byte — truncation and bit rot self-detected); all integers little-endian on every platform (SCALE-005: byte order specified, not assumed); the PARSER is total over malformed input: every structural violation (size < header, null data, bad magic, unsupported version, non-zero reserved, frame length over cap — checked before any overrun read, payload past the body, tick not previous+1, trailer count mismatch, fileHash mismatch, trailing garbage) is a MalformedInput Status — never a crash, never a silent skip (CORE-008/TEST-005); the IDENTITY HASHES are pure integers (ARCH-010: no addresses, no wall clock) — word-stream FNV-1a 64, big-endian byte order per u64 (the house convention: determinism state hashes, Prng golden vectors, laige-detcheck): componentSchemaHash(World) over [componentCount, then per registered type in id order: id, size, alignment] (a function of the REGISTRATION order — same types same order → same hash; stack-only, 769 words max, no allocation), configHash(EngineConfig) over [tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math] (M1-CFG-01 refines the encoding with the schema, under the format's versioning), makeReplayIdentity(World, EngineConfig) assembles the header; the REPLAY RECORDER (ReplayRecorder, move-only: create(identity, path, maxBytes) → writeFrame(tick, data, len) → finish()): writes path + ".tmp" (same filesystem — the final rename is atomic; MSVC MoveFileExA(MOVEFILE_REPLACE_EXISTING)) and publishes path only on a successful finish — an interrupted/failed recorder leaves NO file at the final path (temp removed by the destructor; a rename failure leaves the temp for inspection, documented), SIZE-BOUNDED (maxBytes counts header + frames + trailer together; 0 = kDefaultReplaySizeLimit 128 MiB; a cap below kMinReplaySizeLimit = 56 rejected at create as InvalidArgument; a cap breach mid-run is BudgetExhausted, sticky — every later call returns the same Status; finish() on a cap that cannot fit the trailer is the same error), strict 1,2,3,... tick sequence (InvalidArgument otherwise), frame over 1 MiB InvalidArgument, empty path InvalidArgument, open/write/rename I/O IoError; the READER: parseReplay(const uint8_t*, size) (in-memory, O(size), one output allocation per frame) and loadReplay(path, maxBytes = default) (bounded read — a file larger than maxBytes is a MalformedInput, the ADR 0003 JSON-bound precedent; missing file IoError, empty path MalformedInput); the ENGINE WIRING (engine.{h,cpp}): Engine::startReplayRecording(path, maxBytes) — called ONCE, after all component/system registration, before run_headless (the identity is captured from the live world + config at call time — a later registration makes the recorded schema hash stale, the schedule-stale precedent, M1-SYS-02) — OPT-IN and DEBUG BUILDS ONLY (NDEBUG → InvalidArgument + one replay/record_disabled warn — recording is development tooling; the format and wiring exist in every build, the opt-in does not), disabled cost one null check per tick (PERF-003/LOG-003), enabled cost one bounded stdio write per completed tick (the explicit, opt-in, visible cost — PERF-002); the loop's onTick hook writes one ZERO-LENGTH frame per COMPLETED tick (M1: no input system yet — the frame bytes are the future input blob); a recording failure mid-run STOPS the run: run_headless returns the recorder's Status (≤ 1 frame of extra ticks, the loop's bounded frame contract), the log is NOT published (no partial file at the final path), and the ordered shutdown still runs (CONC-006); a successful bounded run finalizes (trailer + atomic rename) and logs replay/record_finished (Info: path, bytes, frames); a failed run's shutdown logs replay/record_aborted (Warn: path, bytes) and discards the temp; a second start is InvalidArgument + replay/record_already_started (Warn); a stopped engine's start is a no-op failure WITHOUT logging (the stopped-state precedent); accessors replayRecordingActive()/replayBytesWritten(); move ctor/assign transfer the active recorder (move-assign abandons the source's via shutdown); structured events, subsystem replay (NFR-13.3 5-field grammar, rate-limited where repeated): record_started (Info: path, size_limit, seed, math_backend, config_hash, schema_hash), record_finished (Info), record_failed (Error: path, tick, error), record_aborted (Warn), record_already_started (Warn), record_start_failed (Warn), record_disabled (Warn, release builds); the CLI (tools/run/laige-run.cpp): --replay LOG is now REAL (it was the M1-HEAD-01 stub — accepted, warned, ignored): laige-run registers no game components (the built-in registration is complete at create), so the identity capture is at the right phase; on a clean bounded run the log is atomically published at LOG; a start failure exits 2 (laige-run: replay: {error text}, the run did not happen), a mid-run failure exits 1 (the status= summary line carries the error name) with no partial log; usage text updated; the FUZZ TARGET (tools/fuzz/laige-fuzz.cpp): replay_parse (any Status ok — only a crash/sanitizer report fails the run, the runner contract) — the parser's malformed-input surface (TEST-005, NFR-8.7: the SCALE-005 parser fuzz requirement) — 1000 deterministic runs in fuzz_replay_parse (TIMEOUT 120, TSan halt_on_error=1 in the TSan tree); the corpus gains a valid v1 replay log (68 bytes: header + one zero-length frame + a correct FNV-1a trailer, local encoder with the house FNV constants) as a mutate/truncate base (the runner's generate-mutate-truncate modes feed the parser real-shaped input); laige-fuzz now links laige-sim (arrows only downward, PRD §10.1; laige-core's headers come through it — CPP-010); the TEST SUITE (tests/laige-sim/replay_record_tests.cpp, 22 tests, CTest entry replay_record — the step's Verify command; added to the TSan property list; NFR-8.10 static_assert self-checks): ReplayFormat — the round trip (record 8 PRNG-payload frames → raw bytes → parse → every identity field + frame byte identical — the step's "record N ticks → parse back → identical bytes" clause; the replay-roundtrip frames=… bytes=… filehash=… machine-greppable line; also: file form via loadReplay, deterministic re-encoding byte-identical, the zero-frame log, the EXACT 1 MiB frame boundary, and the full malformed table (every truncation cut of a valid log, bad magic, unsupported version, non-zero reserved, a length field of 1 MiB+1 — the overrun-read-preventing branch, out-of-sequence tick, trailer count mismatch, a flipped body byte (fileHash branch), trailing garbage, empty input, null data)); ReplayRecorder — atomic publish (final file + no temp), interruption leaves NO file (temp removed by the destructor), the size limit at the EXACT boundary (cap 56: frame 1 fits 52 ≤ 56, frame 2 breaches 64 > 56 → BudgetExhausted; sticky failure; no partial file; machine-checked), the size limit AT FINISH (cap 67: both frames fit exactly, the 16-byte trailer cannot → BudgetExhausted), the tick sequence (first tick 2, repeated tick 1 → InvalidArgument), write-after-finish and the one-shot finish, create validation (empty path, cap 55 below the minimum, the 0 = default cap + header read-back), move transfers the file (source inert, destination finishes); ReplayIdentity — schema hash stable for the same registration order, different for a different order (replay-identity schema-order h1=… h3=… line), config hash per-field sensitivity (seed/tick rate/capacity/churn/enabled/math), makeReplayIdentity echoes seed/tick rate/math id + both hashes; ReplayEngine — 8-tick run with recording ON → 8 zero-length frames (ticks 1..8) + identity matching the pre-run world+config capture (the replay-engine ticks=… bytes=… schema=… line; 152 bytes = 40 + 8×12 + 16), the mid-run failure stop (cap 56 → run_headless returns BudgetExhausted, no partial log, engine shut down, sink: record_failed = 1 + record_aborted = 1 — sink read BEFORE restoreLogger, the engine_tests pattern), double start (InvalidArgument + record_already_started = 1), stopped-engine start (InvalidArgument, NO log — the stopped-state precedent, zero accessors); CMake (src/laige-sim/CMakeLists.txt, tests/laige-sim/CMakeLists.txt, tools/fuzz/CMakeLists.txt): replay.cpp into LAIGE_SIM_SOURCES (step comment), replay_record_tests.cpp into LAIGE_SIM_TEST_SOURCES + the replay_record entry (unquoted gtest filter Replay*, the M1 pattern) + the TSan property list, the fuzz target + entry; laige-api.json regenerated (630 symbols from 20 headers; api-real-tree green); DOCS in the same change (DOC-007): new docs/api/replay.md (the format spec — layout, the malformed-input table, the identity hashes, the recorder contract, the readers, the engine + CLI integration, the Performance section (disabled = one null check/tick; enabled = one bounded stdio write/tick; the 1 MiB frame + 128 MiB total bounds), misuse warnings, testing/CI) linked from docs/README.md (API list + the per-module list + the M1 progress summary + the compatibility bullet); docs/api/engine.md (new "Replay recording (M1-DET-02)" section, the M1-DET-02/03 scope split fixed, the --replay CLI bullet de-stubbed, the misuse bullet, the Testing+CI entries); docs/concepts/determinism.md ("What it is not (yet)": recording landed, execution = M1-DET-03, PRNG introspection joins M1-DET-03's state_hash, Related link); docs/compatibility/README.md (the replay log is the FIRST persistent engine format — added to the formats table: version 1, magic LGRP, strict validation); docs/testing.md (the replay_parse target + its command-table row); tools/README.md (--replay de-stubbed, the fuzz target list); src/laige-sim/README.md status updated; ROADMAP: M1-DET-02 box checked (M1-heartbeat.md), progress board M1 14→15 of 25, total 34→35 (this line's hash is the first commit of the two-commit change; the progress board update is in that commit). Local Verify: ctest -R replay_record green (22/22, Debug), full ctest 57/57 on build (Debug GCC), build-asan (ASan+UBSan — leak-free, the recorder's interrupted-state cleanup proven), and build-tsan (TSan halt_on_error=1, incl. replay_record + fuzz_replay_parse), laige-fuzz replay_parse --runs=1000 + json_parse --runs=1000 clean (default seed), tools/laige-include-lint OK (36 source files, 1/10 vendored deps), laige-api regenerated with api-real-tree green, zero new warnings under NFR-8.10. Untested paths: the MSVC _fsopen/MoveFileExA and AppleClang branches (CI-only), the release-build record_disabled branch (not exercisable in a Debug tree — the NDEBUG path is a compile-time constant), and a real > 128 MiB run (the size-limit branches are boundary-tested at 56/67 instead). Compat: additive only — the M1-HEAD-01 --replay stub behavior is replaced by the real recording (the flag's documented contract was always this step); no existing symbol or behavior changed. |
| 2026-09-17 | M1-SAMPLE-01 | 75d1df6 |
hello.laige headless template game (NFR-13.5, PRD §9.4/§13, TEST-004, ADR 0002, ARCH-010, CORE-008): the first complete Laige game — samples/hello (hello.cpp: 99 code lines < the PRD §9.4 budget of 100, the ~76 NFR-13.5 comment lines do not count; one component PlayerPos + LAIGE_COMPONENT/LAIGE_DETERMINISM_SAFE at namespace scope, one system MovePlayer (LAIGE_SYSTEM; constant +1/tick per axis, wrapped in the [-16, 16] box), one entity at the box center; the canonical config (60 Hz / 8-entity / 256-churn / 0x1F055EED / fpx16_16) is embedded in the binary with config.json as its declarative record (hello_config_valid CTest runs laige-run --headless against it); the stdout stream is the detcheck scenario contract (tick-0 + one World::stateHash line per completed tick — 301 lines at 300 ticks, docs/api/detcheck.md), stderr = engine logging + the hello {headless |
| 2026-09-17 | M1-DET-04 | — |
Bit-exactness CI (FR-1.4/11.5, NFR-8.3, PRD §14, ARCH-010, TEST-004, ADR 0002): activates laige-detcheck with the real M1-SAMPLE-01 scenario — committed per-tick hash-stream baselines in samples/hello/baselines/ (one per SimMath backend; reference build: canonical Debug g++; 301 lines each), hello --expect BASELINE as the scenario-side equivalent of laige-replay --expect (the scenario binary carries the check — a game log cannot be replayed by laige-replay; same 0/1/2 contract, hello::BaselineCheck in hello-baseline.cpp), and the hello-fp32 build variant (same source, float_pinned_32 via the LAIGE_HELLO_BACKEND macros — one op surface, two backends); CI: every P0 OS job's ctest now asserts per-tick identity against the baselines on both backends (hello_baseline_fpx/hello_baseline_fp32) plus the failure fixtures (hello_baseline_mismatch/_truncated/_malformed/_missing — first-divergence, stream-length, malformed-line, missing-file paths), the merge detcheck job builds four configurations (Debug g++, Debug clang++, Debug+ASan clang++, Release g++) and runs the two-configuration pairs (pair A: g++ vs clang++ Debug; pair B: Debug+ASan vs Release — both backends, --run-a/--run-b + --compare-combined) after a reference-baseline sanity check, and the PR detcheck job runs the both-backend baseline comparison (tooling cadence, label-independent); local Verify: full ctest 82/82 on the canonical tree, all four detcheck pairs OK (ticks=301), byte-identical clang++ vs g++ streams (both backends), perturbation proof — kVelocity 1→2 in MovePlayer makes hello --expect exit 1 with "hash mismatch at tick 1 (first divergence)", the revert restores exit 0; determinism report recorded in docs/benchmarks/determinism-matrix.md (AGENTS §12 metadata, ARCH-010 scope statement, the float_pinned_32 per-platform support list generated from matrix results — desynced pairs declared unsupported, never re-baselined; regeneration policy); hello.cpp stays at 99 code lines (PRD §9.4 — the baseline check and the fp32 variant live in separate TUs); docs updated in the same change (detcheck.md baseline-comparison section + CI status, hello README, building.md canonical rows, concepts/determinism.md cross-target, docs/README.md, tools/detcheck comment); commit lands with the step's PR (#41) |
| 2026-09-20 | M1-DET-05 | 6b63465 / PR #44 |
Replay diff (FR-11.3, M1-DET-05 scope, nothing else): World::componentStateHash (the PRNG-excluded canonical state hash — the replay diff's alignment key) + World::stateDiff (the bounded canonical-order state comparison) + diffReplays (the replay DIFF: identity-checked lock-step tick walk aligned on the component-state hash, first divergent tick + bounded state diff, length divergence, fullStateDivergent flag — the tick-37 integration scenario) + laige-replay --diff <logA> <logB>; replay_diff CTest entry; local Verify: ctest -R replay_diff green, full suite green on the canonical trees (board/changelog row retroactively added 2026-09-21 by the M1-CFG-01 PR — the step merged as 6b63465/PR #44 without updating this board or log) |
| 2026-09-21 | M1-CFG-01 | — |
Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 config.json schema in a new public header src/laige-sim/include/laige/sim/config.h (+ config.cpp) — EngineConfig MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the budgets block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the camera block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and asset_roots (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED version key gates before every other key (missing → config/version_missing, non-integer → config/version_invalid, ≠ 1 → config/version_unsupported — the provisional M1-HEAD-01 documents migrate by adding "version": 1); unknown keys at any level warn config/unknown_key and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + InvalidArgument; loadGameConfig(path) (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); EngineConfigOverride + applyConfigOverride (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, assetRoots replacement); ConfigHotReloader (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/record_disabled pattern: release builds reject with config/hot_reload_disabled): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera., draw/particle budgets, asset_roots) apply in place + config/hot_reload_applied (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism., budgets.system_time_default_ms) refused ATOMICALLY + config/hot_reload_rejected (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (config/hot_reload_read_failed or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's configHash covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW docs/api/config.md (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; Engine::create re-validates the tick rate with the shared kConfigTickRateInvalidMessage); fixtures gain "version": 1 (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW game_config CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — ConfigHotReload.*); engine_tests drops the migrated EngineConfigParse.* (the suites live in game_config_tests.cpp); determinism_tests' config docs gain "version": 1; laige-api.json regenerated (734 symbols); local Verify: ctest -R config green (config_json + game_config + hello_config_valid), full ctest 88/88 on build (Debug g++), build-asan (leak-free), build-release (NDEBUG — the hot_reload_disabled path exercised), build-clang, build-tsan (config suites halt_on_error=1), build-shared; zero new warnings under NFR-8.10; untested: the MSVC _fsopen branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split |
| 2026-09-23 | M1-PROF-02 | 0c7cf5c / PR #49 |
Frame graph / budget report (FR-11.2, PRD §9.1 S-6, §9.3 G-R5; M1-PROF-02 scope, nothing else): the FrameBudgetRecorder fixed 32-frame ring (src/laige-sim/include/laige/sim/frame_budget.h + frame_budget.cpp — FrameBudgetRecord per completed frame: the 0-based frame index, the tick delta, the frame's sim work ms, the pool-reservations sim-alloc delta, the G-R5 budget_overrun/budget_critical event deltas; recordFrame O(1) allocation-free hot path, at(i) oldest-first over the wrapping ring, totalFrames() keeps counting past the window — no silent truncation, CORE-008) and buildFrameBudgetReport (cold format pass, the AGENTS §12 field format): every DECLARED budget measured vs declared with a pass/flag — each system's declared SystemDef::budgetMs (fpx16_16, exact — ADR 0002) vs its M1-SYS-03 rolling window's p99 (the G-R5 sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the warns/errors counters), sim_tick_avg/sim_tick_p99 (budgets.json, M0-CORE-08) vs the Profiler's tick window (mean/p99), sim_heap_allocs (the PRD §8.1 hard-zero budget) vs the per-frame sim-alloc deltas (max); the M0-CORE-08 budgetCheck blocks embedded verbatim; a missing entry is a loud NO_ENTRY, an empty window a loud NO_SAMPLES (a zero-tick run is never silent), the over-budget systems list ascending id, and `overall=PASS |
| 2026-09-21 | M1-PROF-01 | a811297 / PR #47 |
Always-on profiler counters (FR-11.1, DBG-008; M1-PROF-01 scope, nothing else): the Profiler core (src/laige-sim/include/laige/sim/profiler.h + profiler.cpp) — always-on, fixed-storage, no allocation after init: tick/frame time rolling windows (512/256 samples, M0-CORE-08 Histogram; record is O(1) allocation-free, drops the OLDEST, the since-construction counters keep counting), draw calls / texture binds / net bytes counters (0 in headless M1 — the fields exist per FR-11.1, the render/network subsystems feed them in M2/M3), and the cold snapshot() (own counters; snapshot(world) adds the world-pulled fields — entity total/alive/capacity, sim alloc count = World::archetypeStats().totalReservations (the M1-ECS-03 pool accounting, target 0), system count — read COLD, never copied; per-system windows stay in World, pulled only in the report); move-only (moved-from = stopped: records no-op, empty snapshot); setEnabled/enabled (disabled = one branch, no recording); report surface: formatProfileSummaryLine (the CLI one-liner), formatProfileText / formatProfileJson (version-1 schema: counters, tick/frame windows (n==0 → n=0 text / JSON null, never NaN), world fields, per-system entries with the M1-SYS-03 window stats), writeProfile (truncating write, no partial file on failure, Result<u64, ErrorCode> — IoError); wiring: GameLoop::Options::profiler (non-owning) — runOneTick times the tick body (the frame's beginFrame + one runSystems dispatch) with the M0-CORE-08 TimeIt and records on SUCCESS only (a failed tick is neither counted nor recorded), null/disabled = one branch; the engine owns the profiler (Engine::create constructs it — engine setup, not run setup, so the "exactly three one-shot allocations per run" claim stays true; the run loop adds the frame feed — two clock reads + one ring write per frame, excluding the pacing sleep, first frame not recorded, failed frames not recorded; shutdown() releases it in the pools step, abandoning a started-but-unfinalized report with profiler/report_aborted); the per-run report: startProfileReport(path) (EVERY build — diagnostics, not replay state, no NDEBUG gate), written at the END of the run on EVERY path (a zero-tick run writes a zero-tick report, CORE-008), a write failure does NOT fail the run (sticky profileReportStatus(), profiler/report_write_failed Error), profileStats() = the last run's cached snapshot (the world is released in shutdown); laige-run --prof-out <report> (start failure exits 2; a write failure leaves the run status=ok and exits 2) + the always-printed laige-run profile: … one-line summary (the byte-stable status=ok line untouched — the summary is a separate stdout line); structured events (subsystem profiler, NFR-13.3 5-field grammar): report_started (Info), report_written (Info), report_write_failed (Error), report_aborted / report_already_started / report_path_invalid (Warn); G-R8 exception markers on the raw double tokens (wall-clock diagnostic — never enters sim state, hashes, or replays, ARCH-009); 26-test profiler CTest entry (tests/laige-sim/profiler_tests.cpp: exact percentiles 1..100 → p50=50/p95=95/p99=99/mean=50.5, rollover cap, independent windows, zero-capacity drop, adders, disabled no-op + preserved state, moved-from stop, cold snapshot, per-completed-tick timing (failed tick unrecorded), the engine's per-run cache + report (written on every run path, version-1 JSON parseable, double-start/empty-path/stopped-engine rejections, write failure sticky without failing the run, pre-run shutdown abandonment with no file on disk), the greppable text form, the record path's zero-allocation (profiler-zeroalloc ticks=1000 allocs=0, non-sanitizer trees), and the enabled-cost gate ON vs OFF over 10k-entity ticks ≤ 1% (profiler-cost on_p50=0.557288 off_p50=0.555746 overhead_pct=0.277465, best-of-2 per arm, non-sanitizer trees — the LAIGE_ALLOC_COUNTER gate: sanitizer instrumentation inflates the fixed per-tick cost, 1.46% on the ASan tree)); docs in the same change: docs/api/profiler.md (new) + engine.md / game_loop.md updates + the docs/README, sim README, debugging README, tools README indexes; baseline docs/benchmarks/baselines/m1-profiler-cost.md (full AGENTS §12 metadata, verbatim runs — measured +0.28% vs the 1% gate); local Verify: canonical g++ tree zero-warning, full ctest 89/89 (the new profiler entry + api-real-tree + determinism-lint-real-tree green after the manifest regeneration), laige-run --prof-out smoke (the summary line + the valid JSON report on disk); cross-tree builds + tools/laige-include-lint in the PR branch |
| 2026-09-24 | M1-ALLOC-01 | 4564029 / PR #50 |
Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 sim_heap_allocs target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header src/laige-core/include/laige/alloc_watch.h + src/laige-core/alloc_watch.cpp) — a process-wide heap-allocation counter behind strong global operator new/new[] (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (LAIGE_ALLOC_WATCH=1 PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with allocWatchLive() false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (allocWatchArm() = three relaxed stores — first-site, count, armed flag; allocWatchRead() = two relaxed loads → AllocWatchReading{allocs, firstSite}; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — __builtin_return_address(0) on GCC/Clang, _ReturnAddress() on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: laige::detail::LoggingAllocationGuard — the logging facade's emit path (the LAIGE_LOG macro block + Logger::record) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 budget_overrun/budget_critical, a replay record_failed, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local std::vector, engine storage growth) still fails at its call site; the per-tick check (GameLoop::runOneTick, #if !NDEBUG, game_loop.cpp): arm BEFORE the tick body (the frame's beginFrame + one runSystems dispatch + the attached profiler + the onTick hook + the replay recorder), read AFTER a completed tick (status.ok() — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one alloc/sim_tick_allocation Error event (fields tick/allocs/site, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame simAllocs delta (profiler.md) instead, never a crash; tests: the new ZeroAlloc suite + zero_alloc CTest entry (added to the TSan property list) over the shared laige-sim_tests executable — the 10k-entity M1-ECS-07 workload through the GameLoop (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — kTickNs = 16666667 = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable zero-alloc window: line; a scratch system with a deliberate std::vector fails the tick assert — proven in a forked SIGABRT child (POSIX; GTEST_SKIP on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (tests/**/logging_alloc_counter.h wraps the watch; the LAIGE_ALLOC_COUNTER test definition is gated on the same trees as LAIGE_ALLOC_WATCH, so the probes and the engine's assertion always agree); HeadlessFramePathAllocatesNothing (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: docs/api/alloc_watch.md (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + game_loop.md (the zero-allocation section + the Performance cost line) + profiler.md cross-ref + the docs/README index; laige-api.json regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: ctest -R zero_alloc green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK; CI (observed via the GitHub API): the PR ci-pull.yml run 35988075605 on c9587fa green — all 8 jobs passed (linux-gcc/clang/asan+UBSan/tsan each ctest 92/92, the determinism check + source scan, the include-graph lint + dependency count, the public API manifest drift); macOS/Windows jobs skipped (label-gated, default-Linux P0 selection) |
| 2026-09-25 | M1-BENCH-01 | — |
10k-entity simulation-tick benchmark (PRD §8.1 ≤3.0 ms avg / ≤5 ms p99, ADR 0002, CORE-001; M1-BENCH-01 scope, nothing else): the sim-tick suite in laige-bench (tools/bench/laige-bench.cpp + CMake) — the PRD §8.1 "Simulation tick" workload: 10 000 entities at the 100% scene budget (capacity 10000, deterministic, seed 0x1F055EED — the repo-wide test-seed convention), 2 000 dynamic bodies carrying the built-in Position2D<B> plus the workload component BenchVel<B> (a {Vec2 v}, LAIGE_COMPONENT + LAIGE_DETERMINISM_SAFE marks for both backends) and 8 000 bare entities; deterministic index-derived initial state (50×50 grid span — one body per cell of the first 2 000 cells, x = (i/50)%50 − 25, y = i%50; vx ∈ -3..3, vy ∈ -5..5 units/tick — max coordinate magnitude 20 049 units over the 4 000-tick run, inside fpx16_16's ±32 768 Q16.16 range: no saturating-overflow edge in the measured window); two systems in registration order — BenchMove (1 ms declared budget; pos += vel through the active backend's SimMath::add, the ADR 0002 pinned op surface) and BenchHash (2 ms budget; the per-tick World::stateHash(tick−1) — the M1 determinism work made part of the measured tick) — driven at 60 Hz by the GameLoop on an EXACT synthetic clock (one due tick/frame: 16 666 667 ns — the M1-LOOP-01 exact integer due computation); one measured sample = one completed tick (beginFrame + the systems + the loop's bookkeeping; the presentation snapshot is excluded — ARCH-009); canonical shape --runs=3000 --warmup=1000 (n=3000, histogram capacity 3000, no truncation); every measured tick in debug non-sanitizer builds passes the engine's OWN G-R1 per-tick zero-allocation assertion (M1-ALLOC-01 — an allocating tick aborts the run, so a PASS is a zero-alloc-asserted tick); tool changes (smallest complete change): `--math=<fixed_point_16_16 |
| 2026-09-25 | M1-EXIT-01 | — / PR #52 |
M1 exit gate: all 25 M1 steps confirmed against their Scope/Verify clauses — (1) sim-tick budget green on both SimMath backends: baseline report docs/benchmarks/baselines/m1-sim-tick.md (canonical Debug g++ 16.2.1: fpx16_16 mean 0.598405 / p99 0.606001 ms, fp32_pinned mean 0.591438 / p99 0.615179 ms — both PASS, 5.0×/8.1× inside the 3.0/5.0 ms targets) + CI (the CI reference machine, ubuntu-24.04, is the gate): PR-lane run 36152843429 (linux-gcc + linux-clang full ctest 94/94, including the two budget-gate entries laige_bench_sim_tick_fpx16/laige_bench_sim_tick_fp32 — both budgets, both backends, exit 0) and run 36155154836 (macOS arm64 + Intel full ctest, the same two gate entries green on AppleClang); (2) replay bit-exact on CI across all P0 OS jobs and both backends: the determinism matrix docs/benchmarks/determinism-matrix.md (every P0 OS job asserts per-tick identity of the hello baseline on both backends — hello_baseline_fpx/hello_baseline_fp32 — and the always-on detcheck job compares the cross-build pairs on both backends) — all-OS evidence: merge-lane run 36037080146 on master (2026-09-24: all 7 P0 jobs + detcheck green) and this PR's label-gated P0 matrix — Linux run 36152843429, macOS arm64/Intel run 36155154836, windows-msvc run 36156322585 — every job green, including the two hello-baseline tests in each OS job's full ctest and the detcheck job; (3) zero-alloc assertion green on the 10k workload: the zero_alloc ctest entry (M1-ALLOC-01) green in every P0 job's full ctest in the runs above (test logs in each run's job logs; the ASan/TSan lanes additionally archive the sanitizer reports) — and the M1-BENCH-01 measurement itself is zero-alloc-asserted: every tick of both backends (4 000 each — warm-up and measured) passed the engine's G-R1 per-tick allocation assertion in the debug trees (an allocating tick aborts the run). Deferred P1 items: none — every M1 step is P0. Progress Board: M1 25/25 complete (total 47/193); the board's stale Total row (41) corrected to 47 in the gate commit |
| 2026-09-25 | M2-DEC-01 | 948b942 |
D-ISO decided: 2:1 dimetric confirmed as the template default (true 30°/60° iso remains selectable per scene — the decision is presentation-only, no sim/determinism impact); ADR 0005 written in docs/decisions/ (context, decision, alternatives, evidence, consequences, review conditions per DOC-005); ADR indexed in docs/decisions/README.md and docs/README.md; M2-DEC-01 box checked, decision register D-ISO row closed, Progress Board M2 1/33 (total 48/194); docs only, no code (board/changelog row added in the follow-up commit of the same PR) |
| 2026-09-25 | M2-DEC-02 | — |
D-UI decided: the M2 retained UI widget set confirmed as the seven FR-2.8 widgets in canonical order — panel, button, text, image, list, slider, input — with the PRD §18 item 5 question text's omission of slider flagged as a typo (the normative FR-2.8 list and the register's D-UI default agree); deferred widgets named in the ADR (checkbox/toggle, progress bar, tab, dropdown, tooltip, multi-line input, separator) — they land later as additive widget kinds, never breaking the seven-widget M2-UI-01 surface; ADR 0006 written in docs/decisions/ (context, decision, alternatives, evidence, consequences, review conditions per DOC-005); ADR indexed in docs/decisions/README.md and docs/README.md; M2-DEC-02 box checked, decision register D-UI row closed, Progress Board M2 2/33 (total 49/194); docs only, no code — Verify clause "widget list in ADR == widget list implemented in M2-UI-01" binds M2-UI-01 when it lands |
| 2026-09-28 | M2-GL-01 | — |
GL infrastructure (M2-GL-01 scope, nothing else): vendoring — GLFW 3.5.1 (deps/glfw, tag commit d9d6f0f…) + the GLAD 2.0.8-generated GL 3.3 core loader output (deps/glad, generated by gladv2 --api gl:core=3.3 --reproducible c --loader from the glad2 repo commit pinned in deps.lock), both tree-hash-pinned in deps.lock (3 entries; the cmake/laige-deps-lock.cmake parser now accepts the valid-JSON }, entry separator); tampering a vendored file fails configure with expected-vs-actual hash; module — src/laige-render created (root project now LANGUAGES C CXX: the vendored C compiles in its own targets — laige-glad C target + GLFW's subproject — while every engine target stays C++-only under the engine policy; GLFW linked PRIVATE, GLFW_BUILD_{DOCS,EXAMPLES,TESTS,INSTALL}=OFF, no sanitizer instrumentation of vendor C — ADR 0007); GlContext (public header laige/render/gl_context.h, pimpl — no vendor type crosses the boundary, include-lint R3): createWindowed(w, h, title) (GLFW, 3.3 core hints) and createHeadless(w, h) — the exact per-OS mechanism documented in docs/api/gl_context.md: Linux (the P0 CI path): an EGL surfaceless context (libEGL.so.1 dlopen'd at runtime — no build-time EGL dependency — EGL_PLATFORM_SURFACELESS_MESA display, no config/surface, context attrs 3.3 + core-profile bit, eglMakeCurrent(display, NULL, NULL, ctx)) and Windows/macOS: a never-shown GLFW window (GLFW_VISIBLE = false); the render target is an offscreen RGBA8 FBO (completeness-checked; failure → GlUnavailable); checkGlVersion — the 3.3 core gate, no silent fallback (older/compat-profile driver → GlVersionUnsupported); GlCapabilities creation-time snapshot (version, coreProfile, maxTextureSize ≥ 4096 by the 3.3 core guarantee, driver vendor/renderer clamped to 127 chars — LOG-005); makeCurrent (one thread current at a time, CONC-001), clear/readPixel (bind the FBO / window FB; valid-and-current preconditions → InvalidArgument with no GL work and no log; RGBA8 clear-color encoding exact: 1.0/0.25/0.75 → 255/64/191); move-only (the move stops the source, releasing window/context/FBO); errors — GlUnavailable = 6, GlVersionUnsupported = 7 added to the registry (errors.h/errors.cpp, 5-field lines + anchors docs/api/errors.md#gl-unavailable / #gl-version-unsupported; result_status kRegistered/IntegerValuesArePinned extended); logging — gl/context_created (Info: kind/version/size), gl/context_creation_failed (Error: kind + machine-stable reason ∈ glfw_init/window_create/egl_load/egl_surfaceless/egl_init/egl_context/egl_make_current/gl_library_load/version_unsupported/fbo_incomplete + the registry line), gl/context_make_current_failed (Error) — no vendor strings in logs (LOG-005); tests — tests/laige-render (CTest entries laige-render_tests + gl_context): GlContextGate (7: the 3.3 core gate matrix) + GlContextArgs (8: size/title validation, stopped-context operations) — GL-free, pass in every local tree; GlContextSmoke (3: the 32×32 offscreen clear/readback round trip with the exact expected RGBA8 pixel + the machine-greppable gl-smoke: line, move-stops-source, windowed creation's clean GlUnavailable/GlVersionUnsupported outcome on a headless host) — needs a usable OpenGL 3.3 environment: it GTEST_SKIPs with the clean Status reason where absent and is verified on the P0 CI runners (the sandbox forbids local GPU execution, so the smoke's local status is "excluded, CI-verified"); docs — ADR 0007 (full DEP-003 justification: capability/alternatives (home-grown windowing, SDL3, ANGLE/GLES, direct GL headers, other loaders — all rejected)/transitive deps/platforms/license (zlib; WTFPL OR CC0-1.0 AND Apache-2.0)/security/upgrade strategy), docs/api/gl_context.md (full API contract incl. the DOC-004 Performance section and the exact per-OS headless mechanism), docs/api/errors.md sections, deps/README.md table rows, docs/decisions/README.md + docs/README.md indices, src/laige-render/README.md status; laige-api.json regenerated (852 symbols / 26 headers; api-real-tree green); local verification — all six canonical trees build warning-free and ctest green (GL smoke entries excluded locally per the no-GPU rule): 94/94 build (Debug g++ 16.2.1), 94/94 build-clang (clang++ 22.1.8), 94/94 build-shared (Debug + LAIGE_BUILD_SHARED=ON), 83/83 build-release (the 10 recording-based replay entries register only in debug builds — see below), 92/92 build-asan / build-tsan; the GL-free render suites pass 15/15 in the five trees where that binary is executable — the build-tsan render binary segfaults at startup on this machine's clang-22 TSan runtime (a pre-existing environmental toolchain bug in __tsan_init's atexit-interceptor setup — present in the pre-change code, not reproduced by a minimal TSan probe linking the same non-instrumented GLFW objects; the CI TSan lane, the contract's TSan authority, runs the entries and is green); tools/laige-include-lint OK (49 files, 3/10 vendored deps, 2 allowed vendored edges — both in gl_context.cpp), tools/laige-determinism-lint OK, deps tamper-check verified; also fixed three pre-existing test-tree defects surfaced by a fresh full-tree ctest (CI's P0 jobs are all Debug, so CI never observed them; none caused by this change): (1) the ten recording-based tests/replay entries (replay_smoke…replay_identity_mismatch, replay_diff_{identical,length,identity}) now register only for debug builds — they SETUP via laige-run --replay, and replay recording is the engine's #if defined(NDEBUG)-gated startReplayRecording (M1-DET-02), so they can never pass in release trees (the hello_replay precedent in tests/sample); (2) the debug-only ReplayEngine GTest suite and its capture-sink helpers, the eleven ConfigHotReload debug tests (plus their text-surgery helpers; ReleaseBuildsRejectTheFeature remains the release twin — it was the only hot-reload test that ran there), and EngineRun.HeadlessFramePathAllocatesNothing now carry #if !defined(NDEBUG) gates — un-gated, takeValue() on the rejected create() was UB in release (segfault in laige-sim_tests/game_config) and the per-tick window reads of the engine's #if !NDEBUG G-R1 arm could not hold in release; (3) ZeroAlloc.TenKWorkloadThroughTheLoopAllocatesNothing's four window-read branches now gate on LAIGE_ALLOC_COUNTER && !NDEBUG (same per-tick-arm dependency; the reservation delta + sanitizer runs are the documented fallbacks there) — build-release went from 14 failures (incl. 2 segfaults) to 83/83; Progress Board M2 3/33 (total 50/194) — GL smoke + MSVC/AppleClang verification is CI-only (the sandbox forbids local GPU execution; the P0 CI runners carry the GL 3.3 core capable drivers); CI configure fix in the same PR (first CI run failed in every build job: Failed to find wayland-scanner at GLFW configure) — GLFW 3.5 defaults the Wayland backend ON on Linux and it requires wayland-scanner at configure time, which the P0 ubuntu-24.04 runners do not carry; fixed with GLFW_BUILD_WAYLAND=OFF (X11-only: GLFW 3.5 dlopens the X11/GL libraries at runtime, so only X11 headers are a configure-time requirement; Wayland sessions are reached through XWayland) + an apt step installing the six libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxext-dev packages on the six ubuntu-24.04 build jobs of ci.yml/ci-pull.yml (the runner image ships none of them); ADR 0007 + docs/api/gl_context.md record the backend selection and its rationale; second CI follow-up (same PR) — the first fixed run showed the GL smoke skipping with reason=egl_load (the runner carries no runtime GL stack — the job was green but the smoke vacuous) and the ASan lane failing on an upstream GLFW leak in the X11 init-failure path (process-global Xlib/Xrm state allocated before XOpenDisplay and never freed when it fails — fatal under the lane's detect_leaks): fixed by adding the runtime GL stack (libegl1 libgl1 libegl-mesa0 libglx-mesa0 libgl1-mesa-dri — Mesa surfaceless EGL + llvmpipe software GL, dlopen'd at runtime by the engine and GLAD) to the same apt step, and a display-less fast-fail probe in createWindowed (DISPLAY unset → the identical GlUnavailable/glfw_init result with no GLFW state created — the upstream leak is no longer reachable from the engine); third CI follow-up (same PR) — the smoke still skipped (reason=egl_context); a temporary CI diagnostic (a probe step in ci-pull.yml, removed once the smoke runs green) plus analysis of the runner's actual stack (the libglvnd 1.7.0 dispatcher as libEGL.so.1, Mesa 25.2.8) established three facts: (1) the libglvnd dispatcher does not export eglDestroyDisplay (verified against the noble libegl1 symbol table and libglvnd's own egl.symbols) — made optional in the engine's dlsym completeness check (process-termination release; the one-display-per-process design); (2) createHeadless passed 0x0008 to eglBindAPI — that is EGL_OPENGL_BIT (a ClientAPIs mask bit), not EGL_OPENGL_API (0x30A2 per the P0 distro's EGL headers); the GLVND dispatcher rejects unknown API enums with EGL_BAD_PARAMETER (0x300C, mislabeled EGL_NOT_SUPPORTED in the diagnostic readout), and the checked call failed exactly as designed — no silent fallback; fixed by passing 0x30A2 (the engine's context-attribute constants 0x3098/0x30FB/0x30FD were already correct — the probe-only 0x2091/0x2092/0x2096 values were gl.h GL_CONTEXT_* constants, a diagnostic-only mix-up); (3) GLAD's built-in Linux loader is GLX-flavored (it dlopens libGL.so.1 and resolves through glXGetProcAddressARB), which cannot serve an EGL surfaceless context — the headless path now loads GLAD with the engine's own userptr loader (gladLoadGLUserPtr) backed by the context's eglGetProcAddress, required on that path (exported by both the libglvnd dispatcher and Mesa's vendor library, verified against the noble symbol tables); fourth CI follow-up (same PR) — with the fixes in, the smoke ran and passed in 7 of 8 lanes (machine line gl-smoke: kind=headless size=32x32 gl=4.5 core=1 max_texture_size=16384 clear_readback=ok); the ASan lane still failed, but only at process exit: LSan reported the Mesa/libEGL driver state reachable from the headless display as a leak (~1.88 MB / 17,781 allocations; both direct roots were libEGL-internal state allocated during createHeadless). That is vendor state, not engine memory (every engine-owned allocation is freed by RAII before exit), and it cannot be suppressed surgically — LSan's leak: suppression lines abort ASan's own suppression parser at init (mutually exclusive formats in one file, verified against compiler-rt), and the leak roots are not pointers the engine holds — so the two render test entries now run with detect_leaks=0 on the ASan lane (test-level ASAN_OPTIONS mirroring the job-level options, tests/laige-render/CMakeLists.txt); in-run ASan/UBSan error detection stays fully active and the smoke still performs its GL work under the sanitizer; CI run 36505496573 (de5b982) is 8/8 green on the active lanes (Windows/macOS jobs run at merge, as for all PRs), with the gl-smoke: machine line confirmed in the ASan and TSan archived logs |
| 2026-09-29 | M2-GL-02 | — |
Render thread + frame pipeline handoff (M2-GL-02 scope, nothing else): render thread — laige::render::RenderThread (new public header laige/render/frame_pipeline.h, implementation src/laige-render/frame_pipeline.cpp): the constructor spawns the consumer thread IN THE BODY, after every state member is initialized (thread_ is declared LAST — spawning from a member initializer let the consumer read members before their in-class initializers ran: a ThreadSanitizer data race caught in CI; the thread-start edge then publishes the fully-initialized state, [intro.multithread]); a thread-spawn failure terminates the process under -fno-exceptions — a documented platform boundary, CORE-008, never silent); the frame pipeline stages (cull/batch → submit) plug in as plain noexcept function-pointer callbacks (StageFn; RenderThreadOptions{batchStage, submitStage, stageContext, onStart, onStartContext} — onStart runs ONCE on the render thread before the first frame: the GlContext::makeCurrent takeover; stages are bounded and non-blocking, API-005); handoff — a single-slot lock-free handoff, one producer (main/sim thread) / one consumer (render thread): the 32-byte POD FrameDescriptor{frameIndex, simTick, renderTimeNs, frameData} (opaque frameData — the sim state read arrives with the sprite stages, M2-SPRITE-02 — so NO render→sim module edge is added, CORE-004; no new error codes); the seqlock synchronization argument (McIlroy/Dekker: release publish / acquire copy + torn-copy retry; no ABA — the seq only increases, a 2^64 wrap ≈ 292 years at 1000 Hz) is documented in the header preamble; the diagnostic atomics (consumedSeq_/inFlight_/stop_/submitted_/rendered_) carry no correctness — seq_ alone does (CONC-002: partitioned ownership, immutable snapshot, no shared locks); backpressure (PERF-008, never queue unboundedly) — the single slot means the consumer can lag by AT MOST one frame: publishing while a frame is pending (seq_ > consumedSeq_) drops the OLDER frame in place + one rate-limited render_thread/frame_dropped warn (fields dropped_frame/new_frame; LOG-004; one documented ±1 EVENT race — stats() is the authoritative count); a dropped frame runs NO stage (logged, never silent — FR-12.3); exact accounting — framesSubmitted = framesRendered + framesDropped + outstanding (outstanding = pending slot ≤ 1 + in-flight frame ≤ 1) at every instant; rendered is a real counter incremented when a frame's stages complete, NOT a seq difference (a drop overwrites the slot without the consumer ever consuming the older frame — seq/2 would count the last consumed frame's NUMBER, not the rendered count); RenderThreadStats::framesDropped is the source of the Profiler's frame-drop field (M2-SPRITE-04); clock — laige::render::FrameClock (move-only; FrameClock::create(options) → Result<FrameClock, ErrorCode>, rate outside [kMinFrameRateHz, kMaxFrameRateHz] = 1–1000 Hz → InvalidArgument + one rate-limited render_thread/frame_rate_invalid warn): a frame deadline grid deadlineNs(N) = referenceNs + N × periodNs (N 1-based, EXACT integer math, one division at create; clamped to INT64_MAX beyond the 2^63 ns frame-time bound — never UB, CPP-004) on the engine's monotonic steady time base (the M1 clock epoch — NowSharesTheM1TimeBase pins it); waitFrame(N) = one bounded sleep_until (no spin) → returns the frame's ACTUAL presentation time (on time the deadline, late the real clock reading — ARCH-009 wall-clock fact; the M1-LOOP-02 alpha contract clamps late frames) — the windowed path's frame pacer, REPLACING the M1 headless monotonic clock read (M1-LOOP-01) for the presentation/interpolation path (PresentationSnapshot::onRenderFrame); headless: the same grid at a target pace (the CI path); GlContext::refreshRateHz() (no GL call — a GLFW window/monitor query; 0 = headless or monitor/video-mode query unavailable → the caller's target rate stands in) feeds the vsync pace; shutdown (CONC-006) — shutdown() = stop request + join + stopped mark: ordered (the thread never outlives the call), idempotent (a second call is a no-op), safe on a stopped object, one Info render_thread/thread_stopped per actual stop; it does NOT flush a pending frame (the owner calls waitIdle() first — the M1-HEAD-01 ordered-shutdown precedent); the destructor shuts down (CONC-005: no detached threads); the move stops the source (joins a live thread) and the moved-to object is STOPPED (the GlContext precedent); a stopped object rejects submitFrame with InvalidArgument (no log — the stopped-state precedent); tests — tests/laige-render/render_thread_tests.cpp (new CTest entry render_thread = the step's Verify command; unquoted gtest filter; TSan TSAN_OPTIONS=halt_on_error=1 + ASan detect_leaks=0 properties mirroring gl_context; TIMEOUT 300): FrameClock (6, no GL: rate validation + the 2× frame_rate_invalid warn shape (memory sink), boundary rates {1, 60, 1000} with exact periodNs = 10⁹/rate, the exact deadline grid + the INT64_MAX clamp boundary (maxIndex/maxIndex+1), waitFrame pacing (f1 ≥ d1, f2−f1 ≤ 500 ms) + the late producer's ACTUAL time (≥ 1 ms past the deadline, no multi-second stall), nowNs shares the M1 steady_clock epoch (±10 ms) and is monotonic, the stopped clock is a no-op (all ops 0, move stops the source)), RenderThreadHandoff (7, no GL: 200 frames delivered in order with zero drops (per-frame waitIdle; the exact ascending index sequence), the backpressure drop (4 ms stage, 20 back-to-back publishes — dropped ≥ 1, the exact identity rendered + dropped == submitted after waitIdle, strictly ascending rendered sequence, per-event field check new_frame == dropped_frame + 1), the batch→submit stage ORDER (b,s,b,s,...), onStart runs on the render thread (submit + waitIdle before the check — no racy post-spawn assert), ordered + idempotent shutdown + stopped-submit rejection (InvalidArgument), shutdown does NOT flush a pending frame (both outcomes legal — the accounting is exact either way), the move stops source AND target), RenderThreadOffscreen (3, GL required — GTEST_SKIP with the clean Status reason where absent, the GlContextSmoke convention: 30 frames @ 100 Hz with zero drops + the end-to-end pixel readback of the LAST rendered frame after ordered shutdown (the RGBA8 clear encoding exact: 0/64/128/191 by frameIndex % 4), the 3000-frame @ 1000 Hz offscreen NO-DEADLOCK integration run (~3 s) + the exact accounting + the last-frame pixel check + the machine-greppable render-thread: kind=... frames=... rendered=... dropped=... pixel=ok status=ok line, the slowed-submit drop path (4 ms stage, 15 back-to-back publishes — dropped > 0 + the frame_dropped events + the pixel check)); docs (DOC-007, same change): new docs/api/frame_pipeline.md (the full contract: the handoff + the synchronization argument, the backpressure + the documented event race, the exact accounting, the clock's vsync/headless contract, the API table, ownership/lifetime, threading/phase, the failure behavior, the DOC-004 Performance section, the misuse warnings, a performant example), docs/api/gl_context.md (the refreshRateHz row + the egl_error failure fields + the required eglGetError), docs/api/game_loop.md (the clock-source row now names FrameClock and links its doc), docs/README.md (the API index), src/laige-render/README.md (status); EGL diagnostics (LOG-002: state the driver's reason when known) — eglGetError added to the required EGL table (EGL 1.0 core — exported by both the libglvnd dispatcher and Mesa's vendor library, verified against the P0 distro's symbol tables) and its value is now the egl_error field of gl/context_make_current_failed and of gl/context_creation_failed with reason=egl_make_current; local verification — all six canonical trees build warning-free under NFR-8.10 (build Debug g++ 16.2.1, build-clang 22.1.8, build-shared, build-release, build-asan, build-tsan); the no-GL suites (28 tests: GlContextGate 7 + GlContextArgs 8 + FrameClock 6 + RenderThreadHandoff 7) green in build/build-clang/build-shared/build-release/build-asan; the build-tsan render binary still segfaults at STARTUP on this machine's clang-22 TSan runtime (the pre-existing M2-GL-01 environmental limitation in __tsan_init's atexit-interceptor setup — present in the pre-change code; the CI TSan lane is the contract's TSan authority and runs the render_thread entry); tools/laige-include-lint OK (51 source files, 120 internal edges — no render→sim edge added), laige-api.json regenerated (902 symbols / 27 headers — +50: FrameDescriptor + 4 fields, StageFn, RenderThreadOptions + 5 fields (+StartFn), RenderThreadStats + 3 fields, RenderThread + 8 members, the rate constants ×3, FrameClockOptions + 1 field, FrameClock + 8 members, GlContext::refreshRateHz); GL path CI-only — the sandbox forbids local GPU execution, so RenderThreadOffscreen (and the full ctest -R render_thread Verify command, incl. the TSan job) is verified on the P0 CI runners, the same convention as the M2-GL-01 smoke; CI feedback incorporated (first PR run 36630626060): (a) the TSan lane caught the constructor data race described above (consumer read racing the in-class initializers of the members declared after thread_) — fixed by the declaration reorder + body spawn; (b) all three Linux GL lanes showed the render thread's eglMakeCurrent failing with EGL_BAD_ACCESS (egl_error=12290, surfaced by this step's new egl_error field): on the P0 EGL stack (Mesa surfaceless via libglvnd), taking over a context that is STILL CURRENT ON ANOTHER LIVE THREAD fails — the handoff protocol is therefore release-then-bind: new public GlContext::release() (unbinding from the calling thread, gl/context_release_failed on failure), the old owner releases on its own thread, then the render thread's makeCurrent in the onStart hook is a FRESH bind of a context no thread holds (the same shape as the creation-time bind that works); makeCurrent also selects the per-thread client API first (eglBindAPI(EGL_OPENGL_API) — required on the P0 distro's libglvnd dispatcher, no-op success on stacks honoring the EGL spec's per-thread OpenGL default; verified NOT the fix on its own); the offscreen tests now perform the release on main before the takeover; a THIRD CI run then showed the takeover working (the render thread rendered all frames) but the main-thread readback AFTER shutdown failing the same way — a context LAST HELD BY A DEAD THREAD cannot be rebound on the P0 stack (thread exit does not release it), so the handoff is two-way: new RenderThreadOptions::onStop hook (+StopFn/onStopContext) runs ONCE on the render thread AFTER the last frame and BEFORE the thread exits (the GlContext::release hand-back, the mirror of onStart; new no-GL test OnStopHookRunsAfterTheLastFrame pins the order f...f,x), and the offscreen tests wire it as the hand-back; laige-api.json re-regenerated (906 symbols — +4: GlContext::release, StopFn, onStop, onStopContext; + order/summary lines); (c) the fourth CI run (TSan lane only, TSAN_OPTIONS=halt_on_error=1) exposed the handoff's formal data race: the seqlock's PLAIN-MEMORY slot_ — a concurrent plain read/write between the producer's slot_ = frame write and the consumer's d = slot_ copy (the McIlroy/Dekker re-check tolerates the torn copy logically, but the access pair is UB under the C++ memory model — the exact access pair ThreadSanitizer reports, and the primitive CONC-007 says race detection must cover): the descriptor now lives in FOUR 8-byte atomic words (slotFrameIndex_/slotSimTick_/slotRenderTimeNs_/slotFrameData_) — 8 bytes is natively single-copy-atomic on every P0 platform (a single 32-byte std::atomic<FrameDescriptor> was tried first and FAILED on the P0 CI: it emits __atomic_store/__atomic_load references that need the toolchain's libatomic, which is not present on the ubuntu-24.04 g++/clang runners — verified in a fifth CI run), the publication counter drops its dead ×2/odd encoding (1-based seq_; the frame_dropped event's dropped_frame field keeps the same numeric value — the kth publication), and the consumer's seq_ re-check is kept as an ORDERING guard ACROSS the words (a == b ⇒ no publication crossed the [a, b] window ⇒ all four words are from publication a; a crossed copy is discarded — the skipped frame is exactly the one the backpressure dropped in place); the preamble + frame_pipeline.md handoff argument updated to the atomic-slot-word protocol (DOC-007); laige-api.json re-regenerated (906 symbols — same set, line fields shifted); compat — additive for the API (no existing symbol's signature or meaning changed); one documented behavior tightening: a cross-thread makeCurrent takeover now requires the old owner's release() first (the P0 EGL stack rejects live take-overs — previously this misbehaved silently as a GlUnavailable on the render thread); GlContext::release + StopFn/onStop/onStopContext are the new symbols (+4, 906 total); the handoff's INTERNAL mechanism changed to atomic slot words (behavior-identical: same stats, same event values, no public symbol touched); the stats() accounting is the new RenderThreadStats's first definition (no consumer existed); Progress Board M2 4/33 (total 51/194) |
| 2026-09-30 | M2-ISO-01 | — |
Isometric depth key (M2-ISO-01 scope, nothing else): API — header-only src/laige-render/include/laige/render/iso_depth_key.h (laige::render, public, additive): isoDepthKey(pos, stepHeight, layer) — a deterministic 32-bit sortable depth key computed from SIM coordinates (world space, never screen space — PRD §4 / ARCH-006), templated over the SimMath backends (Fp32Pinned / Fpx16_16); IsoDepthKeyParts + isoDepthKeyParts (lossless unpack of the two fields); isoDepthOrderLess(keyA, idA, keyB, idB) — the (key, entity id) total order: back-to-front, ties broken by entity id (RENDER-003: explicit, stable); isoShearSupported(axes) — the exact-float predicate for the shear class the key is valid for; named constants per CORE-005 (kIsoDepthQuantScale = 16, kIsoDepthFineBits = 22, kIsoDepthLayerBits = 10, biases 2^21/512, masks, domain caps); formula — with v = (x + y) − z (the depth order value: NDC_y = −A·v for every supported shear, A = −dx.y = −dy.y = zUnit > 0, det ≠ 0, exact float equality): d = round(16v) — EXACTLY round(16(x+y)) − 16z (16z is integral, so rounding commutes with the shift — the key's monotonicity proof: same layer ⇒ keyA < keyB ⇒ vA ≤ vB, equal keys ⇒ |
| 2026-09-30 | M2-ISO-02 | — |
Depth key table + incremental updates (M2-ISO-02 scope, nothing else): API — header-only src/laige-render/include/laige/render/iso_depth_table.h (laige::render, public, additive): IsoDepthKeyTable<Backend> (move-only, the PresentationSnapshot template pattern) — create(options) (validates options first-failure-wins: grid extents, chunkTiles power-of-two, maxChunks ≥ 1, layer domain, tile domain, grid-vs-cap; pre-sizes ONE flat storage for the covered — chunk-aligned — region, flat-ground init, one Info iso_depth_table_created + one Debug iso_depth_table_chunk_created per chunk), rebuild(heights) (the from-scratch scene-load path: span must equal coveredCellCount(), every key through the FULL M2-ISO-01 function — independent of the stored qBase, so rebuild(final grid) == any edit sequence reaching the same grid), setTile(gx, gy, h) (the O(1) incremental update: stores the height, recomputes the affected cells only — the edited cell + its documented neighborhood kIsoDepthTableUpdateRadius = 0 for the M2-ISO-01 formula, since a cell's key is a function of the cell's own (x, y, height, layer) — rejected uncovered/out-of-domain edits leave the table unchanged; zero allocation, no logging on success, no GL), ensureChunk(gx, gy) (growth to the chunk-aligned rectangle containing the tile: bounded by maxChunks → BudgetExhausted + one rate-limited Warn iso_depth_table_growth_cap (fields requested_chunk_x/y, chunk_count, max_chunks), one Debug per new chunk, InvalidArgument beyond the tile domain, idempotent no-op when covered), keyAt/tileHeightAt (the render read path — precondition covers(), check-free by contract, the isoDepthKey pattern) + covers + introspection (layer/chunkTiles/origin*/widthTiles/heightTiles/coveredTileMinX/Y/coveredTileMaxX/Y/chunkCount/maxChunks/coveredCellCount); cell model — one CellRecord{key, qBase, height} per covered cell, flat row-major tileX-fastest: qBase = the backend-quantized ground contribution of the cell CENTER (gx+0.5, gy+0.5) — a pure function of position, computed once at creation/growth through the same backend add + quantize isoDepthKey uses; the key is bit-identical to isoDepthKey<Backend>(center, height, layer) (the setTile pack is the exact remaining terms — no clamp in the validated domain: |
| 2026-10-01 | M2-ISO-02 | — |
Windows (MSVC 2022, /W4 /WX) build fix for the merged depth key table — the first Windows compile of iso_depth_table.h + its tests (the PR run's Windows job was P0-only-skipped, so master's post-merge run was the first Windows build of this code; root cause from CI run 36761849487, Windows x64 (MSVC 2022) Build step): (1) detail::chunkCoord — the negative branch negated a uint32_t expression (MSVC C4146 "unary minus operator applied to unsigned type" → C2220 under /WX); the shifted result is now converted to int32_t BEFORE the negation (lossless — the max is 32767 + 2^14 − 1 = 49150 < 2^31 — identical value on every compiler; the exact-site comment carries the C4146 rationale); (2) the test file's kCompilerId MSVC branch — _MSC_FULL_VER is an INTEGER literal, not a string (C2143/C2059 "syntax error: 'constant'"): it is now stringified through the file's existing LAIGE_ISO_TABLE_STR macro (the laige-bench kCompilerId precedent); (3) the setTile radius gate — if (kIsoDepthTableUpdateRadius > 0) is a constant condition (MSVC C4127 → C2220 under /WX, found in the PR re-run 36770947310 after fixes 1–2 landed): it is now if constexpr — the entity.h resolveIoEntry precedent for compile-time-constant conditions (the discarded branch is never emitted, behavior identical); no behavior, API, or budget change — both fixes are MSVC-compile-only (the budget gate is Linux-scoped; the setTile hot path is byte-identical in value); laige-api.json regenerated (979 symbols / 30 headers — line numbers only, +5 from the chunkCoord comment) with api-real-tree green; local verification — all six canonical trees build warning-free and ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan); Progress Board unchanged (M2 7/33, total 54/194) |
| 2026-10-01 | M2-CAM-01 | — |
3D camera core (M2-CAM-01 scope, nothing else): API — new public header src/laige-render/include/laige/render/camera.h + implementation camera.cpp (laige::render, additive, built on the M2-GL-03 builders — no new matrix code): CameraProjection (Ortho/Perspective), CameraBounds (the closed ground-plane rectangle the camera position is clamped into — z unconstrained), CameraOptions (API-006 option structure: position/target/up, projection kind, aspect, ortho half-extents, perspective fovY, depth slab, zoom + zoomMin/zoomMax per scene config, optional rectangle bounds, per-update followLerp ∈ (0,1], per-update shakeDecay ∈ [0,1), maxShakeOffset; named constants kCameraDegreesToRadians, kLookAtNearParallel = 1e-5 per CORE-005), and Camera (copyable value object — no resources, no heap storage, so zero allocation is structural on every operation): create(options) (first-failure-wins validation → InvalidArgument + one rate-limited Warn camera/options_invalid with the stable option name; the initial position is CLAMPED into the rectangle so the invariant holds from creation), state accessors (position/target/up/shakeOffset/effectivePosition/zoom/following/projectionKind), mutation (setPosition/setTarget — clamped to the rectangle, reject non-finite input and look-at-margin violations with one Warn each, state unchanged; setFollowTarget/stopFollowing; setZoom — CLAMPED into [zoomMin, zoomMax], exact at the bounds, clamping documented behavior not an error; applyShake — offset = clamp(offset + impulse, ±maxShakeOffset) per component: bounded), update() (the documented 3-step per-frame order: follow → bounds clamp → shake decay), and the matrix builds (view = lookAt(position + shakeOffset, target, up) — the shake moves the eye; projection = ortho of halfWidth/zoom × halfHeight/zoom or perspective(2·atan(tan(fovY/2)/zoom), aspect, slab); viewProjection = projection * view); the stopped state (failed create / default) follows the RenderThread precedent: valid() false, identity matrices, InvalidArgument mutators, no log (FrameClock failed-create precedent); semantics — zoom is a MAGNIFICATION factor (visible extent ÷ Z; the Z=1 perspective round-trip stays within 1–2 ulp of fovY, documented); the follow is a RIGID TRANSLATION (delta = (followTarget − target)·followLerp, both points move by the same delta): the view direction and eye→look-at distance stay constant within float rounding while the look-at converges to the follow target; the look-at margin (` |
| 2026-10-01 | M2-ISO-02 | — |
Depth-key budget workload fix — the CI Linux x64 (clang++) reference lane failed on the M2-CAM-01 merge (run 36885653175, job 110448173121): IsoDepthTableBudget.TenThousandDirtyCells measured mean 0.20889 ms (fpx16_16) / 0.209044 ms (fp32_pinned) against the 0.2 ms iso_depthkey_rebuild budget (methodology §5: the absolute gate is the CI reference machine — both P0 Linux lanes; the g++ lane passed, the clang lane missed by 4.5%); root cause (disassembly of the CMake-Debug -O0 build) — the workload's measured applyEdit loop computed i / 100, i % 100 and (gx + gy) % 5 per call, and at -O0 that is three real div/idiv instructions per iteration (~20-30 x86 cycles each, on top of the setTile call) — the gate was measuring the harness's division codegen, not the engine's 10 000 setTile calls; the m2-iso-depth-table.md baseline's "34% inside the gate on clang" (0.149184 ms) was recorded on the local machine with Clang 22.1.8, while the CI lane uses the ubuntu-24.04 runner's apt Clang 18.1.3 on a slower shared runner (~1.40× the local number: 0.2089/0.149184 — the first crossing of this gate on that exact CI toolchain/runner), so the margin never existed on the gate platform; fix (test-only, no engine/API/budget change) — the 10 000 (tileX, tileY, height) edit triples are now precomputed once outside every measured window (the warm-up and the measured runs start below the precomputation) and the measured loop iterates the precomputed sequence — a byte-identical setTile call sequence (same order, same arguments) with zero integer division in the measured loop (disassembly-verified: 0 div/idiv; the only per-call calls are setTile and the caller's Status::ok() check) — this removes a harness artifact from the measured window, it does not relax the 0.2 ms PRD target or change the workload's engine work (TEST-010: no regression was hidden — no engine behavior or workload definition changed); before/after (CMake Debug, -O0, both backends, n=3000, warmup=100): canonical g++ 16.2.1 0.087398 → 0.0814067 ms (worse backend), local clang 22.1.8 0.149184 → 0.138931 ms (worse backend); the local deltas (−7%) are smaller than the CI artifact because this machine's out-of-order core overlaps div latency — on the CI runner the divisions were critical-path, which is why only the CI lane failed; budgets — budgets.json measured 0.087398 → 0.0814067 (worse of backends, canonical tree, the M2-ISO-02 recording convention); NEW baseline file docs/benchmarks/baselines/m2-iso-depth-table-workload-fix.md (sixth baseline; supersedes m2-iso-depth-table.md as the latest record, which stays immutable per methodology §4; the old baseline's numbers are the before/after pair) + docs/benchmarks/baselines/README.md index entry; verification — all six canonical trees build warning-free and ctest green (build 102/102, build-clang 102/102, build-shared 102/102, build-release 91/91, build-asan 99/99, build-tsan 99/99 — the sanitizer lanes run the fixed workload ungated: leak-free ASan, race-free TSan); ctest -R iso_depth_table green on the canonical tree (0.53 s); the CI linux-clang lane of this PR's run is the definitive gate-platform check; compat — test-only: no API, no engine code, no budgets.json target, no behavior change; Progress Board unchanged (M2 8/33, total 55/194) |
| 2026-10-01 | M2-PROJ-01 | — |
Projection modes + screen↔world transforms (M2-PROJ-01 scope, nothing else): API — new public header src/laige-render/include/laige/render/projection.h + implementation projection.cpp (laige::render, additive; built on the M2-GL-03 builders and the M2-CAM-01 camera — no new matrix code): ProjectionMode (Iso/SideView/TopDown/FreeCinematic — Iso is the engine default, ADR 0005), Plane {normal, d} (n·p = d; the normal need not be unit), WorldRay {origin, direction} (unit direction), and ProjectionView (a plain value object — the mode, the world→NDC matrix (the output of the mode's documented builder), and planeCenter (the side_view/top_down reference plane for the ray origins)): worldToScreen(p2d, depth) — the 2.5D world point (ground p2d + elevation depth) → NDC, one homogeneous 4×4 multiply (w = 1 exactly for the affine modes; NDC-z = 0 exactly for iso), screenToWorldRay(ndc) — the per-mode preimage: the FULL LINE for the affine modes (iso origin on the ground plane z = 0 via the invertible 2×2 ground solve; side_view/top_down origin on the plane through planeCenter) and a true RAY for free_cinematic (origin on the NDC near plane, direction = the normalized near→far preimage, away from the camera); the direction is normalize(r1 × r2) — the cross of the matrix's screen-x/screen-y rows — in all modes, unit length, screenToWorld(ndc, plane) — preimage ∩ plane → Result<Vec3, ErrorCode> (a failed pick is a recoverable game condition, API-008): InvalidArgument when the plane is numerically parallel to the ray (` |
| 2026-10-02 | M2-CAM-02 | — |
Isometric camera presets + grid-snap mode (M2-CAM-02 scope, nothing else): API — new public header src/laige-render/include/laige/render/iso_camera.h + implementation iso_camera.cpp (laige::render, additive; built on the M2-CAM-01 camera (owned by value) and the M2-GL-03 builders — no new matrix code): IsoPresetKind (Dimetric2To1 — the engine default per ADR 0005 / TrueIso3060 / CustomShear), IsoPreset (the SINGLE preset config value: kind + NDC scale for the built-in presets, IsoAxes for custom shear), GridSnapOptions (enabled + gridSize world units per cell), IsoCameraOptions (camera + preset + snap), named constants kIsoSnapSqrtTwo = 1.4142135623730951f, kIsoSnapMarginPerCell = kIsoSnapSqrtTwo · 0.5f (the snap's worst-case Euclidean movement per cell, g·√2/2), kIsoSnapMinGridSize = 1e-6f (CORE-005), and IsoCamera (copyable value object — no resources, no heap storage, zero allocation structural on every operation): create(options) (first-failure-wins validation → InvalidArgument + one rate-limited Warn iso_camera/options_invalid with the stable option field in the order preset_kind → preset_scale → preset_shear → grid_size → bounds_grid_alignment → snap_margin; the M2-CAM-01 camera options validate FIRST via Camera::create — its own camera/options_invalid warn, its failure short-circuits; the initial position is snapped into the grid in snap mode), state accessors (camera() — const, all mutation goes through the snap-aware mutators; preset(), gridSnapEnabled(), gridSize()), matrix() — the COMBINED world→NDC affine matrix of the current state (the preset's isoMatrix(axes) scaled by 1/zoom, translation = −axes(effective-eye ground)/zoom: at zoom 1 and no shake it equals the preset's isoMatrix() exactly — the −0.0 normalizations pinned; the camera's ground point projects to the NDC origin — the screen center — at every zoom/position; NDC-z is 0 for every world point — depth is engine-owned, PRD §4; the shake's z component does not enter the matrix — the iso projection has no vertical viewpoint), mutation (setPosition — (x,y) clamped into the bounds rectangle then snapped to the grid, the candidate validated against the INFLATED look-at margin before commit — state unchanged on rejection; z never snapped; setTarget — free (the grid locks the position, not the look-at point), validated against the inflated margin; setFollowTarget/stopFollowing — the M2-CAM-01 follow, in snap mode the position is snapped after every follow step; setZoom — snap mode: snapped to the documented dyadic ladder, no snap: the M2-CAM-01 clamp; applyShake — the M2-CAM-01 bounded shake, moves the view center (the matrix's translation)), update() (the M2-CAM-01 update — follow step → bounds clamp → shake decay — + the grid snap of the position, snap mode), and the static pure snap functions snapCoord(v, g) (nearest grid multiple, ties away from zero, FLOAT-ONLY arithmetic — no integer conversion, total for every finite v and g > 0) and snapZoomLevel(z, zoomMin, zoomMax) (clamps into the bounds, nearest level in log2 space, exact float tie to the HIGHER zoom, always an exact ladder member — idempotent); semantics — the DOCUMENTED snap-mode choice (the roadmap leaves the choice to this step): CONTINUOUS snap — on create, on every setPosition, and after every follow step of update() (the standard isometric grid-locked feel: the camera never rests off the grid, tracks moving targets in grid steps even mid-follow; snap-on-release was considered and rejected — between releases the camera slides off-grid, which is not the isometric look); the zoom levels are the documented DYADIC LADDER L = {zoomMin·2ⁿ : 0 ≤ zoomMin·2ⁿ ≤ zoomMax} — power-of-two spacing from zoomMin so the grid-to-screen scale is a power of two times the level-0 scale at every level (halving/doubling the scale preserves pixel alignment; an arbitrary factor cannot) — the final pixel alignment is window-size-dependent (the game's concern, RENDER-006); both invariants are TOTAL inside the documented world domain ( |
| 2026-10-02 | M2-ISO-03 | — |
Isometric picking — screen → ground → grid cell (M2-ISO-03 scope, nothing else): API — header-only src/laige-render/include/laige/render/iso_picking.h (laige::render, public, additive): IsoGridConfig (the pick grid: cellSize world units per cell; the tile map's grid is 1.0), IsoGridPick (the cell indices cellX/cellY + the computed ground point), and screenToGrid(screen, camera, grid) — the O(1) inverse of the M2-CAM-02 camera matrix (a 2×2 solve on the ground rows — no per-pick 4×4 inverse, no GLM, no allocation) plus a ProjectionView overload for hand-stored iso matrices (the M2-PROJ-01 base the step lands on; precondition mode == Iso); boundary rule — cell (gx, gy) is the half-open square [gx·g, (gx+1)·g) × [gy·g, (gy+1)·g) (floor; exact boundary → the forward cell, corner → the upper-right cell), the grid anchored at the world origin (the tile map's tile (gx, gy) at g = 1, center (gx+0.5, gy+0.5) — the grid the M2-CAM-02 grid-snap camera locks to); exact at all supported zoom — the inverse is a fixed float sequence, zoom enters only through the matrix's entries, so a stored screen point resolves to the same cell at every zoom in the camera's supported set (continuous range or snap ladder); precision — ` |
| 2026-10-03 | M2-SORT-01 | — | Deterministic depth sort (M2-SORT-01 scope, nothing else): API — header-only src/laige-render/include/laige/render/depth_sort.h (laige::render, public, additive): DepthSort (move-only; default = the empty capacity-0 stopped state) — create(capacity) one flat 16 B/slot allocation at scene set-up (InvalidArgument for capacity 0 or > kDepthSortMaxCapacity = 0xFFFFFFFF — the index width), sort(span<const uint32_t> keys) the per-frame O(4n + 4·256) pass: zero allocation, no logging, no GL; overflow n > capacity → BudgetExhausted, the sorter UNCHANGED (the previous frame's order intact — the caller handles/logs it, LOG-002); the sorted order read back as parallel spans sortedKeys()/sortedIndices() (the i-th key back-to-front + its original input position — payload-agnostic: the M2-SPRITE-01 batcher maps indices to its sprite pool); algorithm — 4 × 8-bit LSD radix (stable bucket) passes: one stable 256-bucket counting sort per 8-bit digit, LSB first (count → in-place prefix → stable scatter → role swap; Knuth TAOCP Vol. 3 §7.2.1; the even-pass-count static_assert keeps the result in the base buffers without a copy-out; 8-bit digits = the 1 KB counter-table sweet spot); stable tie-break (RENDER-003) — equal keys keep their INPUT order; the batcher's deterministic entity-id insertion order (FR-1.2) makes the output the (key, entity id) total order without the sorter seeing the ids; determinism — pure integer arithmetic: same key sequence → bit-identical sorted order on every platform/build (presentation-only, ARCH-009/010 scope — never in the sim state hash or replay state); budget — NEW budgets.json entry depth_sort_10k (mean, ms, target 1.0 — 6% of the 16.7 ms 60 FPS frame budget of AC-4.3, half of the 2 ms 50k render-CPU budget); the DepthSortBudget suite gates it on the Linux non-instrumented trees (LAIGE_DEPTH_SORT_BUDGET — the iso_depth_table/iso_picking precedent): 100 warm-up + 3 000 measured sorts of 10 000 precomputed keys (deterministic mixKey finalizer carved to the M2-ISO-01 22-bit fine-depth range — ~2.4 equal keys per value, the realistic overlapping-scene load; no RNG/division in the measured path) — the roadmap's 10k-sprites × 3 000-frames stress test; tests — tests/laige-render/depth_sort_tests.cpp (5 tests / 5 suites; CTest entry depth_sort = the step's Verify command, TSan halt_on_error=1, TIMEOUT 120): DepthSortGolden (the hand-computed 6-key order {1,3,3,5,5,9}/indices {3,1,4,0,2,5}, the BudgetExhausted-unchanged overflow, the at-capacity sort, the create validation), DepthSortEdges (n=0, n=1, all-equal 100, ascending, descending, alternating two keys — oracle-checked; the empty-stopped-state behavior), DepthSortStability (the determinism property: same input twice → bit-identical; 64 Fisher-Yates shuffled permutations of a 512-value 4096-key multiset — the sorted-key sequence invariant under shuffling (multiset determinism) + the full (key, index) order == the std::stable_sort oracle of THAT sequence (stability), TestPrng substream), DepthSortProperty (10 000 random 32-bit keys vs the oracle, the non-decreasing pin, the 1 000-sort zero-allocation proof under the process-wide allocation watch — non-sanitizer trees), DepthSortBudget (the gate + the ungated sanitizer/non-reference runs); BudgetHarnessTable.LoadsTheRepoBudgetsFile count updated 15 → 16 (the repo budgets.json entry-count pin — the M2-ISO-02/03 convention); docs (DOC-007, same change) — NEW docs/api/depth_sort.md (the full contract: the API, the tie-break, the algorithm + digit-width rationale, determinism, ownership/threading, Performance, misuse warnings), docs/concepts/coordinates.md §4.6 (the render-order narrative + the conversion table row now shipped) + §6/Related, NEW docs/benchmarks/baselines/m2-depth-sort.md (the SEVENTH baseline: measured 0.225852 ms mean canonical Debug g++ n=3000, 4.4× inside the 1.0 ms gate; cross-compiler clang -O0 0.161389 ms, 6.2× inside — the CI-shape evidence; the 50k scaling watch item for M2-PERF-01) + baselines/README.md index (+ the retroactive m2-iso-picking.md index entry the M2-ISO-03 step omitted — stale-index fix), docs/README.md API index, src/laige-render/README.md status; laige-api.json regenerated (1123 symbols / 35 headers — +17); api-real-tree/api-check-fresh, include-lint (63 files), and determinism-lint green; all six local trees verified (build 107/107, build-clang 107/107, build-release 96/96, build-shared 107/107, build-asan 104/104, build-tsan 104/104 — full ctest green, no new warnings); scope note — the implementation exceeds the roadmap's "~250 lines" sanity note for the same documented-contract reason as M2-CAM-01/02/ISO-03 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 12/33, total 58/194 |
| 2026-10-03 | M2-SPRITE-01 | — | Sprite item + batcher API, "declare, don't draw" (M2-SPRITE-01 scope, nothing else): API — header-only src/laige-render/include/laige/render/sprite_batcher.h (laige::render, public, additive): SpriteItem (the declared sprite — world 2D position, the M2-ISO-01 depthKey, depthOverride flag, SpriteUvRect UV sub-rect, rotation (rad), Vec2 scale, SpriteTint RGBA, atlasId/materialId refs, BlendMode) + SpriteBatch (one (atlas, material, blend) group: atlas/material/blend + the in-group instances span of frame-scoped pool slots) + SpriteBatcher (move-only; default = the empty capacity-0 stopped state) — create(Options{maxSprites}) one allocation per storage structure at scene set-up (the ArenaPool<SpriteItem> pool, the M2-SORT-01 DepthSort, the key scratch, the instance array, the group table, the cursor array, the batch array — ~132 B/capacity slot, 6.6 MB at the 50k stress budget; InvalidArgument for capacity 0 or > kSpriteBatcherMaxCapacity = 0xFFFFFFFF); the frame protocol beginFrame() → add(item) × n → build(); add returns the frame-scoped pool slot and declares the sprite in the engine's deterministic entity-id iteration order (FR-1.2 — the stable tie-break's carrier); grouping (FR-2.1) — build() sorts the frame's depth keys with DepthSort, groups into (atlas, material, blend) batches in DETERMINISTIC order (ascending (atlas, material, blend) — a function of the distinct group keys alone), and scatters each group's instances in GLOBAL back-to-front order (the sorted order RESTRICTED to the group — the (key, entity id) total order per group); one instanced draw call per group at submit (M2-SPRITE-02, RENDER-001); overflow (PERF-008, S-2) — bounded, never grows: a frame beyond the budget drops the OLDEST live declaration (ring-head overwrite) + one rate-limited Warn per drop (sprite_batcher/frame_overflow_dropped), cumulative in droppedTotal(); G-R11 — a manually-set depthKey (depthOverride) is COUNTED per frame (overrideCount()) + CUMULATIVE (overrideTotal()) + WARNED once per frame (sprite_batcher/depth_override_used, "prefer tile height") — the counted/warned escape hatch, not the default path; determinism — pure integer arithmetic: same declaration sequence → bit-identical batches on every platform/build (presentation-only, ARCH-009/010); no per-frame allocation (FR-2.2, PERF-003) — every per-frame path is pre-allocated integer bookkeeping, proven by a 1 000-frame zero-allocation test; no standalone budget entry — the sort cost is the depth_sort_10k budget, the composite 50k render-CPU budget (2 ms, PRD §8.1 sprites_50k_cpu) is measured with the submit stage (M2-PERF-01); ctest -R batcher green (grouping correctness N atlases × materials × blends → exact group count, in-group order vs hand-computed orders, drop-oldest + warn, G-R11 counted + warned, stopped/protocol/slot edges, the 3 000-sprite determinism property vs the stable-sort oracle, the zero-alloc proof) — verified across all 6 local trees (build/build-clang/build-release/build-shared/build-asan/build-tsan). API contract in docs/api/sprite_batcher.md, module README + docs/README.md index + docs/concepts/coordinates.md §4.7 updated in the same change |
| 2026-10-04 | M2-SPRITE-02 | — | GPU instanced draw + sprite shader (M2-SPRITE-02 scope, nothing else): API — SpriteRenderer (public header src/laige-render/include/laige/render/sprite_renderer.h + implementation sprite_renderer.cpp, move-only; default = stopped state) — create(const GlContext&, Options{maxInstances, maxAtlases, primitiveQuery}) the set-up path (validates first-failure-wins → InvalidArgument + one rate-limited Warn sprite_renderer/options_invalid; requires a valid context + makeCurrent; compiles ONE GLSL 3.30 shader pair (vertex + fragment) + links one program; creates the 32 B quad VBO (GL_STATIC_DRAW), the per-frame instance buffer (maxInstances × 52 B, GL_DYNAMIC_DRAW — 13 floats: pos.xy, scale.xy, uv u0v0u1v1, tint rgba, rot), and the VAO (the quad corner at divisor 0; the five instance attributes at divisor 1, pinned layout(location=0..5)); the atlas registry (maxAtlases × 8 B) — any GL failure → GlUnavailable + ONE structured Error (sprite_renderer/program_creation_failed or resource_creation_failed, with the GL error code + the sanitized info log), no partial renderer); bindAtlas(atlasId, w, h, rgba) the set-up/asset path (one call per atlas per scene load; atlasId ∈ [0, maxAtlases), w, h ∈ [1, capabilities().maxTextureSize], rgba.size() == w·h·4; GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps — the UV sub-rects are pixel-exact, the M2-GOLD-01 contract; re-binding an id REPLACES the texture; a GL upload failure → GlUnavailable + one Error atlas_upload_failed); submit(batcher, worldToNdc) the per-frame draw (preconditions, first failure wins — a FAILED submit draws nothing, zeroes the frame counters, leaves the totals unchanged, and leaves no stale sprite-pass state: stopped renderer → InvalidArgument; context valid + current (makeCurrent idempotent — a cross-thread live takeover → GlUnavailable, the P0 EGL contract); the batcher built for the current frame (frameBuilt() — an open window with declared items is never drawn as an empty frame, CORE-008); the frame's instance count ≤ maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity, PERF-008); every group's atlas in the registry AND bound (else InvalidArgument — the stateless pre-state validation, one failure per frame); then: the per-frame state setup (the render-target frame buffer bind — GlContext::frameBuffer(), the offscreen FBO on headless contexts, the surfaceless default frame buffer is not a valid draw target — + the viewport matched to the render-target size, the driver default 0×0 would clip every draw to nothing — + the depth test OFF (the painter's order is the batcher's — the 2.5D depth is engine-owned, FR-2.2/M2-ISO-01, never derived from the projection) + the blend ENABLED + the program + the per-frame uWorldToNdc uniform), the frame's instances packed into the pre-allocated staging (a contiguous verbatim float copy — no arithmetic on the CPU — the GPU owns the math, FR-2.2/PERF-003), ONE glBufferSubData upload, and per group IN THE Batcher's published order (ascending (atlas, material, blend) — RENDER-003) the texture bind (only when the atlas CHANGED → counted in textureBinds), the blend function (only when the mode CHANGED → counted in blendChanges — Alpha: SRC_ALPHA/ONE_MINUS_SRC_ALPHA, Additive: ONE/ONE), and ONE glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, n_group) (counted in drawCalls/instances); after the pass (success or failure): program + VAO restored to 0 — the pass owns only its own program/VAO; the blend function, texture bind, frame buffer, and viewport PERSIST (the last atlas/blend carry across frames — the counters count real changes)); shader (the whole M2 sprite feature, minimal GLSL 3.30) — vertex: world = aPos + aCorner * aScale (the unit quad's corner scaled in WORLD units and translated — the scale applied BEFORE the projection, the SpriteItem.scale contract), projected through uWorldToNdc (2D ground plane, z = 0), the projected offset rotated by the per-instance rotation IN SCREEN SPACE (NDC — the SpriteItem.rotation contract), the per-vertex UV the per-instance UV sub-rect mapped onto the quad ((-0.5,-0.5) → u0/v0, (0.5,0.5) → u1/v1); fragment: texture(uAtlas, vUv) * vTint (the multiplicative RGBA tint); counters (RENDER-001 — the M2-SPRITE-04 profiler feed) — SpriteDrawStats (the per-frame counters of the last successful submit: drawCalls, textureBinds, blendChanges, instances, primitives) + SpriteDrawTotals (since-construction, successful submits only — frames, drawCalls, textureBinds, blendChanges, instances, primitives); GL 3.3 core has NO draw-call query primitive: the dispatch count is the engine's own bookkeeping (drawCalls == the group count), and the GL-side cross-check is the OPT-IN Options::primitiveQuery (a PRIMITIVES_GENERATED query around every submit + a glFinish read — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop, RENDER-005; the 32-bit glGetQueryObjectuiv read caps the count at 2^32−1 primitives — beyond the realistic frame of 2^31 instances (2 per instance); the glad-generated glQueryCounter has a broken 2-argument signature in the vendored 2.0.8 loader, hence the 32-bit read; ponytail: comment at the site); determinism — presentation-only (ARCH-009): the submit path is a verbatim float copy (no arithmetic on the CPU — the GPU owns the math); the rotation's cos/sin is driver-float (bit-exact across runs of the same driver, not across drivers — the golden-image contract M2-GOLD-01 pins the environment); no allocation on the per-frame path (the staging buffer, the instance buffer, and the registry are sized at create — PERF-003); one owner thread (the render thread's submit stage — CONC-001), no locks/atomics; no new budget entry — the composite 50k render-CPU budget (2 ms, PRD §8.1 sprites_50k_cpu) is measured with this stage (M2-PERF-01); tests — tests/laige-render/sprite_draw_tests.cpp (12 tests / 4 suites; CTest entry sprite_draw = the step's Verify command, TIMEOUT 300): SpriteDrawCreate (options validation matrix + the stopped state — no GL), SpriteDrawState (the stopped-state behavior + the frameBuilt gate + the instance budget + the unbound-atlas rejection — no GL), SpriteDrawSmoke (the roadmap's offscreen render: a 1 000-sprite scene — a 32×32 lattice of unit tiles (scale 0.125) in two atlases (a 4×4 checkerboard + a solid) and three groups ((0,0,Alpha) 796 instances, (0,0,Additive) 200, (1,0,Alpha) 4) on a cleared (0,0,255) frame, plus 3 probe sprites) — SpriteRenderer::create + bindAtlas ×2 + one submit: asserts drawCalls == 3 == the group count (the machine-greppable sprite-draw: line: groups=3 draw_calls=3 instances=1000 primitives=2000 texture_binds=2 blend_changes=3 nonempty=16384 reference_mismatches=0), the GL-side cross-check primitives == 2000 == 2 × 1000 (the opt-in query ON), and the whole 128×128 frame against a CPU reference rasterizer that walks the built frame in the exact draw order and accumulates the per-group blend in double (±1 byte per channel — the GPU float32 vs the reference double — plus three rounding-exact probe pixels: an alpha checkerboard texel, an additive-over-clear texel, and the solid atlas-1 texel; the reference is a CPU double-precision reimplementation of the shader's exact pipeline — the 2×2 linear inverse + the screen-space rotation inverse + the UV mapping); SpriteDrawPipeline (the M2-GL-02 integration: a 100-frame offscreen run through RenderThread — the batch stage (clear + beginFrame + 1 000 add + build) + the submit stage (SpriteRenderer::submit) on the render thread, the GlContext handoff (release on the test thread → makeCurrent in onStart → release in onStop), the submit loop PACED to the render thread (waitIdle per frame — a tight loop would outrun the software-GL render and drop 98 of 100); asserts the exact since-construction totals: frames=100, drawCalls=300, instances=100 000, textureBinds=200 (2/frame — the last-atlas carries across frames), blendChanges=201 (3 on frame 1 + 2 on each later frame — the last-blend carries across frames), primitives=0 (the query OFF in this renderer), framesSubmitted=100/framesRendered=100/framesDropped=0 — + the last frame survives in the FBO after ordered shutdown (the P0 probe pixel read back exact)); ctest -R sprite_draw green (the GL suites GTEST_SKIP on an environment failure — the CI path: Mesa software GL on the offscreen FBO, the sandbox's no-GPU rule); docs (DOC-007, same change) — NEW docs/api/sprite_renderer.md (the full API contract: the API, the one-draw-per-group + the state-persistence model, the shader + the pass's GL state model, the counters, the DOC-004 Performance section — O(G×5 + n) per frame, zero allocation, the state-change observability, the render-target/viewport/state-persistence/primitiveQuery traps — ownership/lifetime/threading (the context outlives the renderer), a performant example, the misuse warnings), docs/api/gl_context.md (the NEW frameBuffer() row — the render-target frame buffer handle: the offscreen FBO on headless, 0 on windowed/stopped, no GL call; the per-frame draw path binds it once per frame), docs/api/sprite_batcher.md (the NEW frameBuilt() row + the submit-stage gate + the Related link), docs/README.md API index, docs/concepts/coordinates.md §4.8 (the sprite-draw narrative + the World→screen conversion table row now shipped + §6/Related), src/laige-render/README.md status; laige-api.json regenerated (cmake --build build --target laige-api — 1211 symbols / 37 headers — +36: SpriteDrawStats + 5 fields, SpriteDrawTotals + 6 fields, SpriteRenderer + 8 members + 2 constants, GlContext::frameBuffer, SpriteBatcher::frameBuilt; api-real-tree/api-check-fresh green), include-lint (65 files), and determinism-lint OK; local verification — the canonical tree builds warning-free under NFR-8.10 with full ctest green (incl. sprite_draw 12/12 + the API/lint entries); the remaining five trees re-verified in the follow-up (build-clang/build-release/build-shared/build-asan/build-tsan); compat — additive only (no existing symbol's signature or meaning changed; the new public API is SpriteRenderer/SpriteDrawStats/SpriteDrawTotals + GlContext::frameBuffer + SpriteBatcher::frameBuilt); scope note — the implementation exceeds the roadmap's "~300 lines + tests" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 14/33, total 60/194 |
| 2026-10-04 | M2-ISO-02 | — | Budget revision (CI follow-up to M2-ISO-02) — the iso_depthkey_rebuild gate (PRD §8.1, mean ≤ 0.2 ms, 10k dirty cells after a terrain edit) failed the CI Linux x64 (clang++) reference lane repeatedly on unchanged engine code (the setTile path has not changed since the 2026-10-01 workload fix): the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at 0.194–0.267 ms, straddling the 0.2 ms bar — a zero-margin gate whose pass/fail was decided by runner load, not engine regression (evidence: PR lane run 37192995927 attempts 1–2 — 0.194142 PASS / 0.201495 FAIL, then 0.266709 / 0.267328 FAIL on a slow shared runner; master merge-lane run 37194767648 on commit 70830a7 — 0.202146 / 0.202018 FAIL, the Linux x64 (g++) lane passing the same workload on the same commit). Revised per the NFR-8.1 policy ("unless the budget is revised via a PRD revision"): PRD v0.4 §8.1 ≤ 0.2 ms → ≤ 0.3 ms; budgets.json target 0.2 → 0.3, measured 0.0814067 → 0.202204 (latest recorded CI reference value, worse backend); new baseline docs/benchmarks/baselines/m2-iso-depth-table-budget-rebaseline.md (the eighth baseline, verbatim CI reports); docs — docs/api/iso_depth_table.md, docs/api/iso_depth_key.md, docs/concepts/coordinates.md, docs/README.md, and the M2/M5/M8 roadmap budget references updated to the revised value in the same change (DOC-003), baselines index entry added. No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain). |
| 2026-10-04 | M2-SPRITE-03 | — | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): API — header-only src/laige-render/include/laige/render/sprite_frames.h (laige::render, public, additive): SpriteFrameLayout (the atlas sheet frame layout in texels — frameWidth/frameHeight, columns/rows, frameSpacing (the gap between adjacent frames), sheetBorder (the sheet-edge margin); plain value, no ownership, validated at use time) + kSpriteFrameMaxAtlasTexels (2^24 — the float-exact atlas domain) + spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result<SpriteUvRect> (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); sheet model (documented) — row-major, frame 0 at the top-left (col = i % columns, row = i / columns); frame (c, r) occupies [border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh) texels; the tight sheet is 2·border + cols·fw + (cols−1)·spacing wide/tall (a wider atlas = slack margin, the fit check is authoritative); v-axis — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; failure (CORE-008, API-008, first failure wins) — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all InvalidArgument, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); exactness — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); the M3 hook (data-driven, ARCH-009) — SpriteItem gained frameIndex (the declared animation frame — the caller sets it + sets uv to the frame's rect via spriteFrameUv; the batcher carries the index through untouched — uv is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); batcher delta — SpriteItem +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); tests — tests/laige-render/sprite_frames_tests.cpp (new CTest entry sprite_frames = the step's Verify command; 11 tests / 4 suites, no GL): SpriteFrameUvGolden (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), SpriteFrameErrors (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), SpriteFrameProperty (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), SpriteFrameItemPassThrough (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); docs — NEW docs/api/sprite_frames.md (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), docs/api/sprite_batcher.md (the SpriteItem table row + the 76 B/slot update + Related), docs/concepts/coordinates.md §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, docs/README.md API index, the module README status paragraph; laige-api.json regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); verification — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: ctest -R sprite_frames + the full laige-render_tests); api-real-tree/api-check-fresh, include-lint (38 public headers), and determinism-lint green; budget — no standalone budgets.json entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); compat — additive only (no existing symbol's signature or meaning changed; the new public API is SpriteFrameLayout/spriteFrameUv/kSpriteFrameMaxAtlasTexels + SpriteItem.frameIndex); scope note — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 |
| 2026-10-04 | M2-SPRITE-04 | — | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): API — SpriteDrawStats gained programChanges (the pass's glUseProgram count — 1 per successful non-empty frame), uploadBytes (the frame's instance upload, n × 52 B), renderTargetBytes (the render-target size drawn, w × h × 4), and the G-R2 drawCallCapExceeded flag; SpriteDrawTotals gained the programChanges/uploadBytes/renderTargetBytes sums + capExceededFrames; SpriteRenderer::Options gained the configurable per-pass draw-call cap maxDrawCalls (domain [1, kSpriteRendererMaxInstances] — a frame's draw calls can never exceed its instance count; documented default kSpriteRendererDefaultDrawCalls = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the maxDrawCalls() accessor; NEW textureMemoryBytes() gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at bindAtlas, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); G-R2 semantics — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn draw_call_cap (fields capacity, draw_calls) on the over-cap submit, the frame's drawCallCapExceeded flag (the frame-graph flag M2-PROF-01 will report) + the totals' capExceededFrames; contract — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); tests — NEW tests/laige-render/render_counters_tests.cpp (new CTest entry render_counters = the step's Verify command; 8 tests / 4 suites): RenderCountersCreate (no GL: the maxDrawCalls validation first-failure-wins + one Warn options_invalid, the default reaching the context check → GlUnavailable, the stopped state reads zero), RenderCountersScene (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in PRIMITIVES_GENERATED feed = 20), RenderCountersCap (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), RenderCountersMemory (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); docs — docs/api/sprite_renderer.md (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), docs/README.md index entry, the module README status paragraph, the roadmap box; laige-api.json regenerated (1233 symbols / 38 headers, +12 symbols); verification — all six local trees warning-clean + full ctest green: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 108/108; the first CI TSan run reproduced a DRIVER-INTERNAL teardown data race (both race accesses inside libgallium: the eglDestroyContext teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames), triggered by a query-enabled submit as the FIRST FBO operation of the frame — fixed in the test with the frame-pipeline clear-before-draw pattern (RenderCountersScene.PrimitiveQueryFeed clears the target before the submit — the SpriteDrawSmoke.ThousandSpriteFrame precedent; the clear touches no sprite-pass state, the counters are unaffected; a clear AFTER the submit or a readback quiesce does NOT fix it — the clear must precede the first draw); api-real-tree/api-check-fresh, tools/laige-include-lint, and tools/laige-determinism-lint green; budget — no standalone budgets.json entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); compat — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 SpriteDrawStats fields + the 4 SpriteDrawTotals fields + Options::maxDrawCalls + kSpriteRendererDefaultDrawCalls + maxDrawCalls() + textureMemoryBytes() = 12 symbols); Progress Board M2 16/33, total 62/194 |
| 2026-10-04 | M2-TILE-01 | — / PR (open) | Tilemap data + auto-depth from tile height (FR-2.6; M2-TILE-01 scope, nothing else): API — NEW laige::render::TileMap<Backend> (header-only, templated over the SimMath backends — the presentation.h pattern; TileMap::Options IS the M2-ISO-02 table's Options, one type, no duplicated validation) + TileData (the per-tile value: textureId — the game-assigned atlas ref, height — read from the owned table, animationId — DATA ONLY in M2, M2-TILE-02 cycles frames from it): create(options) (setup path: the table's create validation first-failure-wins + the pre-sized 8 B/tile data array; flat/empty init), setTile(gx, gy, textureId, height, animationId) (the per-tile edit: the height routes into the table's setTile — the AUTO-DEPTH wiring, the table recomputes exactly that cell's key, radius 0 — O(1), zero allocation), rebuild(tiles) (scene load: the requested grid row-major tileX fastest → the heights mapped into the table's covered rectangle → the table's from-scratch rebuild; property rebuild(final grid) == any setTile sequence reaching the same grid pinned; one setup-path temporary), declareTo(batcher, options) (the render path / S-5: one SpriteItem per tile of the requested grid — the tile's CENTER pos (gx+0.5, gy+0.5), scale (1,1) (the unit quad spans the tile cell), rotation 0, the fixed-frame UV (DeclareOptions::uv, default the full texture), the table's key (auto-depth — depthOverride stays false, G-R11), the tile's texture as atlasId, the options' materialId/blend — in the grid's row-major order (RENDER-003: the tile's grid position is its stable identity, the FR-1.2 analog); preconditions: the batcher window open (a built frame's window is closed → InvalidArgument, nothing declared; a stopped batcher → BudgetExhausted, nothing declared); the WHOLE requested grid is declared — visible-rect culling lands with M2-PERF-01, the composite 50k budget's worst case is the full grid), tileAt/depthKeyAt/tileHeightAt/covers (the O(1) read path; covers = the requested grid — the table's superset margin cells are table cells, not tiles), introspection originTileX/Y(), widthTiles(), heightTiles(), layer(), chunkTiles(), tileCount(); bounded draw calls (FR-2.1, RENDER-001) — the batcher's (atlas, material, blend) grouping renders one tilemap in one draw call per DISTINCT (textureId, material, blend) combination: tiles of one chunk sharing one texture and blend form ONE group (one draw call per chunk group); semantics — the tile's height lives in the table ALONE (one source of truth; tileAt combines the three fields); rejected operations (tile outside the requested grid, height outside |h| ≤ 2047, wrong span size, any out-of-domain height in the span) leave the tile data AND the table unchanged (validated before any write; the Status is the failure channel — LOG-002); the happy update/declare paths log nothing (LOG-002/003 — pinned by a no-log test); ARCH-009: headless-buildable, presentation-only, sim-phase writes / render-phase reads; tests — NEW tests/laige-render/tilemap_tests.cpp (new CTest entry tilemap = the step's Verify command; 17 tests / 6 suites, no GL — runs in every tree): TileMapCreate (the grid options + the flat/empty contract, the non-zero origin, the rejected options — first failure wins, the create-time grid-over-cap → InvalidArgument (a misconfiguration; the runtime growth cap is the BudgetExhausted of ensureChunk)), TileMapData (the per-tile read/write, rejected edits leave no state (incl. the superset-margin cells — table cells, not tiles), the scene load against the hand-computed goldens, the rebuild validation (wrong size / out-of-domain height — whole-span validation), the rebuild-from-scratch == incremental property (8×8, both backends)), TileMapAutoDepth (the roadmap's "height change → depth table increment": a single tile-height edit changes ONLY the edited cell's key (radius 0), the hand-computed golden + the independent oracle, last-write-wins, the cross-backend key agreement on the dyadic grid-locked centers), TileMapDeclareGolden (the roadmap's "tile quad positions/depth for a 4×4 chunk": all 16 declared quads pinned field-by-field against the hand-computed position + depth goldens (the 16 literal keys — four screen rows with the exact tie structure), the fixed-frame fields, the (texture, material, blend) grouping — 2 texture ids → exactly 2 groups, ascending atlas order, the hand-computed in-group instance sequences (the (key, declaration-position) stable sort restricted to the group) — + the cross-frame determinism (bit-identical second frame) + the tilemap-golden: machine line), TileMapDeclare (the frame protocol — a built frame's window is closed, InvalidArgument, nothing declared; a stopped batcher → BudgetExhausted; the custom DeclareOptions (material/blend/uv) carried on every item; the no-log happy path), TileMapZeroAlloc (the 1000-frame × 256-tile beginFrame/declareTo/build loop allocates NOTHING under the allocation watch — FR-2.2); docs — NEW docs/api/tilemap.md (the full contract: the API table, the tile model + auto-depth, the quad model + bounded draw calls, the declaration-order determinism, ownership/lifetime/threading, the DOC-004 Performance table, the misuse warnings, a performant example), docs/README.md index entry, the module README status paragraph, docs/concepts/coordinates.md (NEW §4.9 + the §5 conversion row "Tile grid → static tile quads" + the Related link); API surface — laige-api.json regenerated (1233 → 1265 symbols, 38 → 39 headers: TileData + 3 fields, TileMap + Options alias + DeclareOptions + 3 fields, the 15 public members, the move/copy ops, the private TileSlot + its 2 fields — the CellRecord-style private nested-type convention); budget — no standalone budgets.json entry (the count stays 16 — the BudgetHarnessTable.LoadsTheRepoBudgetsFile pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, sprites_50k_cpu — M2-PERF-01 measures the reference scene with tile quads included); compat — additive only (no existing symbol's signature or meaning changed); local verification — all six local trees warning-clean + full ctest green: build 112/112, build-clang 112/112, build-release 101/101, build-shared 112/112, build-asan 109/109, build-tsan 109/109; ctest -R tilemap green (17/17, both backends); api-real-tree/api-check-fresh (6/6), tools/laige-include-lint (68 source files), and tools/laige-determinism-lint (28 sim source files, 0 violations) green; Progress Board M2 17/33, total 63/194 |
| 2026-10-07 | M2-PAR-01 | — / PR (open) | Parallax layers (FR-2.3; M2-PAR-01 scope, nothing else): API — NEW laige::render::ParallaxLayers<Backend> (header-only, templated over the SimMath backends — the presentation.h pattern; parallax.h) + ParallaxLayerDef (the named layer value: id < maxLayers, source (Image | Tilemap — the M2-TILE-02 hook), factor in [0, 1] (one source of truth — 0 = fixed in world space, 1 = fixed on screen), center (the reference camera position — where the layer sits at exactly offset; default (0,0)), offset (the world-space offset at p = center; default (0,0)), size/uv (Image only), atlasId, tilemapId (Tilemap only), materialId, blend, depthLayer (the M2-ISO-01 key layer value), scrollMode (Manual | Auto), scrollSpeed (UV units PER FRAME per axis — negative scrolls the other way), enabled) + ParallaxScrollMode/ParallaxSource + the named presets (kParallaxLayerBackground/Midground/Foreground/CustomBase = 0/1/2/3) + the depth-layer values (kParallaxDepthLayerBackground = -2, kParallaxDepthLayerMidground = -1, kIsoDepthGroundLayer = 0 (the ground), kParallaxDepthLayerForeground = +1; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] — more negative = further back); create(options) (maxLayers in [1, kParallaxLayersMaxLayers = 64], default kParallaxLayersDefaultLayers = 8 — the pre-sized slot table; no log), setLayer(def) (validates the def in the documented order — id → factor → center → offset → scroll_speed → size (Image) → uv (Image) → depth_layer — first failure wins; a rejection leaves the slot unchanged + one rate-limited parallax/layer_invalid warn (fields layer, field) — LOG-004; a success RESETS the layer's UV offset to (0, 0)), setUvOffset(id, uv) (any finite value — WRAPPED to [0, 1)²; unknown id → InvalidArgument (no log); non-finite → InvalidArgument + one rate-limited parallax/uv_offset_invalid warn), advanceScrolls() (the AUTO layers only — once per frame, before the declarations; O(layers), no allocation, no logging, no GL), declareTo(batcher, cameraPos) (the render path / S-5: declares every SET, ENABLED Image-source layer's wrap quads — the 2 x 2 split (q00, q10, q01, q11 — a quad whose range is empty — uvOffset 0 on that axis — is skipped); each quad: its world CENTER pos, its extent scale, rotation 0, the full-default tint, the def's atlasId/materialId/blend, and the M2-ISO-01 key of the quad's world center at the def's depthLayer (engine-owned — G-R11, depthOverride stays false); Tilemap-source layers are SKIPPED (the M2-TILE-02 hook — no log); a built frame's window is closed → InvalidArgument (nothing declared); a stopped registry → InvalidArgument (no log); the first failed add fails the call), worldOffsetAt(id, cameraPos) (the EXACT formula factor * (p - center) + offset — O(1), no allocation, no logging, no GL), introspection (valid(), maxLayers(), layerCount(), has(), layerAt(), uvOffsetAt()); the render order (documented — background first): WITHIN a shared (atlas, material, blend) group the key's LAYER field dominates — every background-layer quad sorts before every ground object and every foreground quad after it, whatever the quads' v (the M2-ISO-01 "layer dominates" contract); ACROSS groups the draw order is the batcher's group order (ascending (atlas, material, blend) — RENDER-003), so the scene's SET-UP assigns the parallax layers' atlas ids so the group order matches the depth order: background ids BELOW the world content's, foreground ids ABOVE it (the M2-TILE-01 texture-id convention); the UV scroll (auto or manual): each layer carries a CURRENT UV OFFSET in [0, 1)² — Manual via setUvOffset, Auto via scrollSpeed on advanceScrolls() (frames are the presentation pace — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent); the WRAP is exact at the texture boundary (wrap(x) = x - floor(x): 1.0 → exactly 0.0; -0.25 → exactly 0.75 — dyadic values wrap bit-exactly, pinned); the texture's v axis (v = 0 = first uploaded texel row, the M2-SPRITE-02 contract) maps to the world +y direction; a scrolled layer renders through the 2 x 2 wrap split (a single SpriteItem carries ONE UV rect — no wrap): up to four quads per layer, one draw call per layer group (FR-2.1, RENDER-001); semantics — everything is WORLD space (PRD §4, RENDER-006 — the formula is a world-space translation); ARCH-009: headless-buildable, presentation-only (the layer state reads the camera's presentation position, never sim state — never part of replay state or the simulation state hash); one owner (the sim/scene-owner thread; the M2-GL-02 cull/batch stage owns the render-side declaration), sim-phase writes / render-phase reads (CONC-001); no per-frame allocation (FR-2.2 — the zero-allocation proof: 1000 frames × 3 layers (up to 4 quads each) + 2 sprites allocates NOTHING on the owner thread); the rejected-definition warn is the only logging (LOG-002/004 — the happy paths log nothing); tests — NEW tests/laige-render/parallax_tests.cpp (new CTest entry parallax = the step's Verify command; 12 tests / 6 suites, no GL — runs in every tree): ParallaxCreate (the create-domain edges + the stopped-state matrix), ParallaxLayer (the setLayer validation matrix — 15 rejection cases with the pinned warn fields (first failure wins), the domain edges accepted, the Tilemap-source size/uv NOT validated, state-unchanged-on-rejection, the replace + scroll-reset), ParallaxOffset (the EXACT offset formula — hand-computed dyadic goldens for factors 0/1/0.5/0.25 (the dyadic exactness zone, both backends agree) + factor 0.3f linearity within tolerance; the world-origin reference case (center/offset zeroed)), ParallaxScroll (the auto advance + the EXACT wrap at the 1.0 boundary (→ (0,0)) + the negative axis, the manual wrap (1.5 → 0.5, 1.0 → 0, -1/-2 → 0), the offset persists across frames, the non-finite rejection + the pinned warn, the unknown-id no-log), ParallaxDeclare (the roadmap's golden: the 4-layer + ground scene at camera (10,6) — both backends: the 5 declared quads pinned field-by-field (world centers, extents, the atlas uv mapping incl. the layer uv sub-rect, the hand-computed 32-bit keys — WITH the 2^21 base — against the independent M2-ISO-01 oracle), the batcher's group (draw) order (atlas 0..4 ascending), the layer-dominance orderings (bg < mid < ground < custom < fg, even when the quads' v is out of order), the cross-frame determinism (bit-identical second frame); the scrolled split — the FULL/half/un-scrolled cases (4/2/1 quads — the empty-range quads skipped; the world rects tile the rectangle exactly: the areas sum to the rectangle's area; the q00/q10/q01/q11 order pinned), the atlas SUB-RECT mapping (the layer uv (0.25,0,0.75,0.5) mapped over the quad's base range), the OFFSET layer's position, the frameCounts pin (6/4/3/6); the frame protocol — a built frame → InvalidArgument, a stopped batcher → BudgetExhausted, a stopped registry → InvalidArgument (no log), the disabled + tilemap-hook skips (no log)), ParallaxZeroAlloc (the 1000-frame loop under the allocation watch — 0 owner-thread allocs — FR-2.2); docs — NEW docs/api/parallax.md (the full contract: the API table, the model + the exact offset formula, the render-order section (within-group layer dominance + the across-group atlas-id convention), the UV scroll + the exact wrap + the 2 x 2 split table (world/uv per quad), ownership/lifetime/threading, the DOC-004 Performance table, the performant example + the misuse warnings), docs/README.md index entry, the module README status paragraph, docs/concepts/coordinates.md (NEW §4.10 + the §5 conversion row "Camera position → parallax offset" + the §6 render-order summary + the Related link); the stale "land with M2-PAR-01" references updated in the same change (docs/api/iso_depth_key.md layer field + Related, docs/api/iso_depth_table.md, docs/api/tilemap.md, the tilemap.h comment); API surface — laige-api.json regenerated LAST (1265 → 1315 symbols, 39 → 40 headers: the 2 enums, ParallaxLayerDef + 16 fields + the field-wise operator== (a defaulted == is deleted — SpriteUvRect has no operator==), ParallaxLayers + Options + 13 public members, the 6 kParallax* constants — the TileSlot-style private nested-type convention); budget — no standalone budgets.json entry (the count stays 16 — the BudgetHarnessTable.LoadsTheRepoBudgetsFile pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, sprites_50k_cpu — M2-PERF-01 measures the reference scene with the parallax layers included — the M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite / ≤30-draw-call budget); compat — additive only (no existing symbol's signature or meaning changed); local verification — all six local trees warning-clean + full ctest green: build 113/113, build-clang 113/113, build-release 102/102, build-shared 113/113, build-asan 110/110, build-tsan 110/110 (the prior counts +1 each — the new parallax entry); ctest -R parallax green (12/12, both backends); api-real-tree/api-check-fresh (6/6), tools/laige-include-lint (69 source files), and tools/laige-determinism-lint (28 sim source files, 0 violations) green; Progress Board M2 18/33, total 64/194 |
| 2026-10-07 | M2-TILE-02 | — / PR (open) | Parallax tile layers + tile animation (FR-2.6; M2-TILE-02 scope, nothing else): API — TileMap<Backend> (header-only, tilemap.h) gained: TileAnimationDef (frameCount in [1, kTileAnimMaxFrames = 64], frameTicks (the documented rate — the SIMULATION ticks per frame, ≥ 1), the tile sheet's SpriteFrameLayout (M2-SPRITE-03, texels)) + kTileAnimMaxFrames/kTileMapDefaultAnimations (8)/kTileMapMaxAnimations (256) + Options::maxAnimations (validated FIRST in create, domain [1, 256] — first failure wins, then the table's grid options) + the pre-sized animation slot table (id 0 = the STATIC sentinel; 1..maxAnimations = animation slots — the tile's animationId is now CONSUMED by the batch path, no longer inert data — the existing tilemap test goldens updated to static tiles); setAnimation(id, def) → Status (setup/config path, the parallax setLayer precedent: validates the id domain → frame count → tick rate → sheet extents → frameCount ≤ columns·rows → the tight sheet's float-exact domain (2^24 texels, the adversarial-layout u64 overflow guards, CPP-004) — first failure wins, no log, the slot unchanged; a success RESETS the phase and PRECOMPUTES all frame UVs (one spriteFrameUv per frame — the only per-animation allocation)); advanceAnimations() (the sim phase's per-tick call — ONCE PER SIM TICK, ARCH-002: every set animation's frame steps every frameTicks ticks, wrapping at frameCount — frame(ticks) = (ticks / frameTicks) mod frameCount — O(maxAnimations), zero allocation, no GL; the frame state is presentation state, ARCH-009); hasAnimation(id)/animationAt(id)/animationFrame(id) introspection; the declareTo frame fields: the STATIC tile carries DeclareOptions::uv + frameIndex 0, the ANIMATED tile its animation's CURRENT frame UV (the precomputed rect) + frameIndex (an animated tile whose slot is UNSET fails the declare — first failure wins, nothing declared past it); NEW declareTo(batcher, options, layers, layerId, cameraPos) → Status — the M2-PAR-01 Tilemap-source HOOK implemented: every quad TRANSLATED by the layer's worldOffset(cameraPos) (the M2-PAR-01 formula (1)), its key the M2-ISO-01 key of the TRANSLATED center at the TILEMAP's own Options::layer (the scene-setup convention: the layer's def depthLayer must equal the tilemap's layer — bg/mid/fg tilemaps get -2/-1/+1); the translated keys are computed per tile per frame (the camera-dependent translation is not precomputable — O(tileCount), zero allocation; the qBase + qOffset derivation is the documented upgrade path); protocol (first failure wins, nothing declared, no log): built frame / unset layer id / non-Tilemap-source layer → InvalidArgument, a DISABLED layer declares NOTHING (OK — the layer's documented skip); setTile/rebuild gained the animationId ≤ maxAnimations check (0 always valid; the WHOLE span validated before any write). No GL anywhere (pure data + batcher bookkeeping); no per-frame allocation (FR-2.2 — the frame UVs precomputed at setAnimation, the slot table at create); no standalone budgets.json entry (the per-frame declare + per-tick advance cost is part of the composite 50k render-CPU budget — M2-PERF-01). Tests — NEW ctest entry tilemap_anim (tests/laige-render/tilemap_anim_tests.cpp, 12 tests / 6 suites, both backends, no GL): TileMapAnimSet (the setAnimation validation matrix — the slot domain (0 / > maxAnimations rejected), frameCount [1, 64] (65 rejected, 64 on an 8×8 sheet accepted), frameTicks ≥ 1, the zero-sheet-extent rejections, frameCount beyond the sheet's frame count, the tight sheet beyond 2^24 (the adversarial layout), the rejected-set-leaves-no-state + phase-reset contract, no-log happy path), TileMapAnimCycle (the documented rate: hand-computed frame = ticks / frameTicks mod frameCount over two INDEPENDENT animations — the phase is per animation, not per tile), TileMapAnimDeclare (the hand-computed frame-UV goldens from the M2-SPRITE-03 tight-sheet formula (a 2×2 grid of 16×16 frames → 32×32 sheet), the frameIndex, the (atlas, material, blend) groups, the wrap at tick 8, and the cross-frame determinism — same animation state → bit-identical items), TileMapAnimParallax (the golden-verified offsets at given camera positions: factor 0.25, center (4,4), offset (1,2), camera (10,6) → offset (2.5,2.5); the hand-computed layer-(-2) key goldens (0x7FA00060/0x7FA00070 — the 2^21 bias adds into bit 21) + the independent oracle (isoDepthKey on the translated centers, both backends — the dyadic exactness zone) + the layer-dominance ordering (a ground probe sorts after every bg tile); camera (4,4) → 0x7FA00040, camera (14,8) → 0x7FA00078; the protocol paths (built frame / unset id / Image-source / disabled layer → nothing declared); the animated tile UNDER the translation (frame 1 UV + translated pos, frameIndex 1)), TileMapAnimProtocol (the animationId edit/load domain — out-of-range rejected, no state change; the boundary id accepted; the WHOLE span validated; the unset-slot declare failure + frameCount 0), and TileMapAnimZeroAlloc (1000 frames × (256 tiles + 64 parallax tiles) of advanceAnimations (30 ticks) + beginFrame/declareTo (standalone + parallax)/build under the owner-thread allocation window — 0 allocations, the dladdr site diagnostic). The existing tilemap suites updated to the new animationId semantics (static sentinel 0; the domain 0..maxAnimations). Docs — docs/api/tilemap.md (the tile animation + parallax tile layer sections, the new API rows, the failure/perf/ownership updates, the misuse warnings), docs/api/parallax.md + parallax.h comments (the hook is implemented: the tiles are declared through the tilemap's declareTo overload, not this registry's — the def's depthLayer must equal the tilemap's Options::layer), docs/concepts/coordinates.md (§4.9 the tile animation + parallax tile layer, §4.10 the Tilemap source, §5 the tile-grid → tile-quads row), docs/README.md, src/laige-render/README.md. Verify — all six local trees warning-clean (build 114/114, build-clang 114/114, build-release 103/103, build-shared 114/114, build-asan 111/111, build-tsan 111/111 — the prior counts +1 each, the new tilemap_anim entry); ctest -R tilemap_anim green (12/12, both backends); ctest -R tilemap still green (the unanchored regex also selects the tilemap_anim entry — intended); api-real-tree/api-check-fresh (6/6), tools/laige-include-lint, and tools/laige-determinism-lint green after the final laige-api.json regeneration; Progress Board M2 19/33, total 65/194 |
| 2026-10-07 | M2-PART-01 | — / PR (open) | Particle simulation (CPU, 2D + depth) (FR-2.7 — "CPU-simulated, budgeted, pooled"; M2-PART-01 scope, nothing else): API — NEW laige::ParticleSystem<Backend> (header-only, templated over the SimMath backends — the presentation.h pattern; src/laige-sim/include/laige/sim/particles.h) + ParticleEmitterDef<Backend> (the spawn-domain value: origin (the spawn position — a particle spawns exactly here, no position jitter in M2-PART-01), depth (constant over life — the M2-ISO-01 depth-key input M2-PART-02 feeds the batcher), velMin/velMax (the per-tick velocity box, componentwise), lifeMin/lifeMax (sim ticks), sizeMin/sizeMax (world units), tint[4] (base RGBA u8, default white), fadeTicks (0 = no fade), continuousRate (particles PER SIMULATION TICK; 0 = burst-only — unbounded by design: the pool budget bounds the work, overflow drops)) + the constants kParticlePoolMaxParticles (2^16)/kParticlePoolDefaultMaxParticles (4096)/kParticleEmitterMaxEmitters (256)/kParticleEmitterDefaultMaxEmitters (32)/kParticleMaxLifeTicks (2^20)/kParticleSampleDenominator (2^24 — the Prng's 24-bit tap resolution) + ParticleEmitterId (dense from 1, 0 = the unused sentinel) + ParticleStats (the live/capacity gauges + the u64 spawnedTotal/droppedTotal counters — the DBG-008 feed); create(options) (maxParticles in [1, 2^16], maxEmitters in [1, 256], any u64 seed — validates (no log — the M2-SPRITE-01 create precedent), two pre-allocations: the pool (maxParticles × 40 B) + the emitter table (maxEmitters + 1 slots, index 0 unused) — the setup path, nothing allocates afterward); the public default ctor = the stopped state (valid() false, addEmitter/burst → InvalidArgument (no log), update() a no-op, liveParticles() empty, stats() zero) + move-only (O(1) pointer swap; moved-from = stopped — its Prng copy shares the stream position but never draws again); addEmitter(def) (dense id in registration order; validates in the documented order — origin → depth → vel_min → vel_max → vel_box → life_min → life_box → life_max → size_min → size_max → size_box → fade_ticks — first failure wins; a rejection leaves the registry unchanged + one rate-limited particles/emitter_invalid warn (fields emitter = the would-be id, field) — LOG-004; registry full → BudgetExhausted + particles/emitters_exhausted warn (field capacity); stopped → InvalidArgument (no log)); burst(id, count) (spawns NOW in the sim phase — the game's spawn policy; stopped or unknown id → InvalidArgument (no log — the M2-PAR-01 setUvOffset precedent), count 0 a no-op success; O(count)); update() (EXACTLY ONCE per completed sim tick — ARCH-002, the TileMap::advanceAnimations pattern — in-tick order: advance (live-array order: age += 1, pos += vel (one SimMath vector add — ADR 0002), age >= life kills via swap removal (the swapped-in particle — not yet advanced this tick — re-examined at the same index)) → emit (registration order, continuousRate each) → overflow report (at most ONE rate-limited particles/pool_overflow warn per tick, fields dropped/live/capacity — LOG-004; the logger 1s window is a second layer); O(live + Σrates), zero allocation, no logging on the happy path (LOG-003)); lifetime semantics — a particle emitted during tick T's update is visible for EXACTLY life renders (age 0..life-1, dies during tick T+life's update); a particle burst-spawned before tick T's update is advanced by that same tick (visible for life − 1 renders — the documented per-tick contract); determinism (ARCH-010, ADR 0002) — the state after N updates is a pure function of (seed, emitter definitions, operation sequence): each SUCCESSFUL spawn consumes exactly FOUR Prng draws in a fixed order (vx, vy, life, size) — each uniform scalar lerp(min, max, u / 2^24) (the Prng's 24-bit tap resolution, one documented rounding per backend), life an integer draw; a DROPPED spawn consumes NO draws (the pool check precedes the draws — a drop never perturbs the stream); fpx16_16 bit-identical across all builds/platforms/ISAs, fp32_pinned same-build/same-platform (the detcheck matrix owns cross-target claims); the Prng state is exposed (prngSeed()/prngStatePart1()/prngStatePart2()) for the World stateHash / replay identity (the M1-DET-03 pattern); pool budget (FR-2.7, PERF-003/008) — overflow = DROP (counted in droppedTotal, one warn per tick window) — never grows the pool, never throws, never mutates live state; the stats() feed is the budget's observability (DBG-008); threading (CORE-009, CONC-001) — one owner (the sim thread); the render pass reads liveParticles() (a non-owning std::span<const Particle> of the compact live prefix — spawn order with swap removal) read-only after the sim phase (the M2-GL-02 cull/batch stage, ARCH-009); tests — NEW tests/laige-sim/particles_tests.cpp (new CTest entry particles = the step's Verify command; 17 tests / 10 suites, both backends, no GL — runs in every tree): ParticlesCreate (the create domain edges (maxParticles 0 / 2^16 / 2^16+1, maxEmitters 0 / 256 / 257 — first failure wins, no log on rejection), the stopped-state matrix, move-stops-source (no log)), ParticlesEmitter (dense ids, the validation matrix — every rejection case with the pinned warn fields (first failure wins, incl. the fp32 NaN cases + the fpx box cases), state-unchanged-on-rejection, the registry BudgetExhausted with the pinned capacity field, the no-log happy path), ParticlesBurst (a degenerate def's EXACT particle fields pinned (Q16.16 raws / dyadic floats — origin/depth/vel/life/size/tint/fade), count 0 no-op, unknown-id/stopped InvalidArgument no log, the pool drop with the pinned dropped/live/capacity fields — one warn), ParticlesUpdate (hand-computed per-tick advance goldens both backends (vel (0.5, −0.25) from (1,2): Q16.16 raw x = 65536 + 32768k, y = 131072 − 16384k; float 1.5/2.0/2.5/3.0 & 1.75/1.5/1.25/1.0), death at EXACTLY age == life (5 renders for life 5), the swap-removal live order), ParticlesContinuous (the rate + the first two spawns' (vel, life, size) cross-checked against an independent Prng oracle — the 4-draw contract — + the pool-full one-warn-per-tick pin), ParticlesDeterminism (50 ticks + a burst(2,5) every 10th tick on a capacity-8 pool (drops occur): same seed → bit-identical live set (every field, both backends) + the machine-greppable particles-determinism … fnv1a=0x… state-hash line (FNV-1a 64, the docs/testing.md §4 KAT convention), a different seed diverges, an independent Prng advanced by 4 × spawnedTotal draws lands on the system's stream state (the no-draws-on-drop contract), EXPECT_GT(droppedTotal, 0) pinning the exercised drop path), ParticlesFade (the EXACT integer fade table: life 8/fade 4/tint.a 255 → age 0..7 alpha [255,255,255,255,255,191,127,63], tint.a 200 → 150/100/50 at age 5/6/7, fadeTicks 0 constant — direct fadeAlpha + through the system), ParticlesPoolExhaustion (the EXACT per-tick table — capacity 3, rate 2, life 4: live [2,3,3,3,3,3,3,3,3,3], droppedTotal [0,1,3,5,5,6,8,10,10,11], cumulative warns [0,1,2,3,3,4,5,6,6,7] — one per tick where drops occur), ParticlesStats (the exact ParticleStats feed: {4 live, 4 capacity, 8 spawned, 2 dropped} after 5 ticks), ParticlesZeroAlloc (500 ticks × 6 spawns + a periodic burst under the owner-thread allocation watch — 0 heap allocations + the machine-greppable particles-zeroalloc ticks=500 allocs=0 line; the steady-state counters pinned); docs — NEW docs/api/particles.md (the full contract: the API table, the per-tick contract, the pool budget, the determinism scope, the color fade, the failure table (first failure wins), ownership/lifetime/threading, the DOC-004 Performance section, the performant example + the misuse warnings), docs/README.md (the API index entry + the laige-sim doc list), src/laige-sim/README.md (the module status paragraph), docs/concepts/coordinates.md (NEW §4.11 + the §5 conversion row "Particle state → sprite items" (Planned, M2-PART-02) + the Related link); API surface — laige-api.json regenerated LAST (1342 → 1410 symbols, 40 → 41 headers); budget — no standalone budgets.json entry (the count stays 16 — the BudgetHarnessTable.LoadsTheRepoBudgetsFile pin): the particle→sprite budget is measured in M2-PART-02 against the composite 50k render-CPU budget (PRD §8.1); compat — additive only (no existing symbol's signature or meaning changed); local verification — all six local trees warning-clean + full ctest green: build 115/115, build-clang 115/115, build-release 104/104, build-shared 115/115, build-asan 112/112, build-tsan 112/112 (the prior counts +1 each — the new particles entry); ctest -R particles green (17/17, both backends); api-real-tree/api-check-fresh (2/2), tools/laige-include-lint (70 source files), and tools/laige-determinism-lint (29 sim source files, 0 violations) green after the final laige-api.json regeneration; Progress Board M2 20/33, total 66/194 |
These come from AGENTS.md / PRD and are restated here so no step can be done "cheaper" by forgetting them:
- Determinism scope is always stated (ARCH-010); deterministic-mode code uses engine math ops only (PRD §10.3, S-7).
- Zero steady-state allocation in sim/render hot paths (PRD §8.1, G-R1, PERF-003); enforced by the M1-ALLOC-01 assertion once it exists, then asserted in every later hot-path step's Verify.
- No exceptions / no RTTI / no
dynamic_castin engine core or public API (FR-12.1, NFR-8.10). Errors areResult<T,E>/Statuswith the §9.4 error grammar. - Every new runtime subsystem registers its essential counters and one inspection view before its step is checked (DBG-008 / FR-11.1).
- Docs, tests, benchmarks ship in the same change as the code (CORE-006, DOC-007).
- New third-party code requires a PRD-revision entry in the decision register first (PRD §11). Vendoring an already-listed dependency is fine (DEP steps say so).
- Budgets are measured, never assumed (CORE-001): any step that claims a budget
number must run the harness and record the result in
docs/benchmarks/.