From f17368200553e882365b3ffdcdd866df38979d4f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 13:45:58 +0200 Subject: [PATCH 1/2] [M1-ECS-01] Entity handle: 32-bit laige::Entity + World entity storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First module beyond laige-core (M1 milestone rules): the laige-sim module with the ECS foundation per roadmap/M1-heartbeat.md M1-ECS-01. - laige::Entity: 32-bit handle (16-bit id + 16-bit generation; generation 0 reserved; 65536 slots; documented 2^16 wrap limit) - laige::World: pool-backed entity storage (create/destroy/check/isValid/clear/stats; setup-only allocation; O(1) no-allocation ops; LIFO deterministic slot assignment) - Stale-handle contract: debug assert / release Status(InvalidArgument) + warn-once via the logging facade (ecs/stale_entity_access, ecs/stale_entity_destroy) — never silent (FR-12.3, CORE-008) - Capacity = declared scene budget (G-R3): BudgetExhausted on overflow; InvalidArgument for a >65536 budget at construction (API-008) - entity CTest entry (19 cases incl. the forked-SIGABRT stale destroy and the pinned 2^16 generation wrap); docs/api/entity.md; laige-api.json regenerated Verify: ctest -R entity + full suite green on fresh g++ Debug, ASan, TSan, shared, and Release trees, zero warnings; tools/laige-include-lint OK; api-real-tree green. --- CMakeLists.txt | 6 +- docs/README.md | 18 +- docs/api/entity.md | 171 +++++++ laige-api.json | 36 +- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-sim/CMakeLists.txt | 38 ++ src/laige-sim/README.md | 10 +- src/laige-sim/entity.cpp | 170 +++++++ src/laige-sim/include/laige/sim/entity.h | 256 ++++++++++ tests/CMakeLists.txt | 4 + tests/laige-sim/CMakeLists.txt | 39 ++ tests/laige-sim/entity_tests.cpp | 615 +++++++++++++++++++++++ 13 files changed, 1355 insertions(+), 15 deletions(-) create mode 100644 docs/api/entity.md create mode 100644 src/laige-sim/CMakeLists.txt create mode 100644 src/laige-sim/entity.cpp create mode 100644 src/laige-sim/include/laige/sim/entity.h create mode 100644 tests/laige-sim/CMakeLists.txt create mode 100644 tests/laige-sim/entity_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 319e801..1f11a3c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -213,9 +213,11 @@ endfunction() # --------------------------------------------------------------------------- # Modules (PRD §10.1 module map) # --------------------------------------------------------------------------- -# One directory per module. Only laige-core is populated (M0-BUILD-01); -# later milestones add their targets in place. +# One directory per module. laige-core is the M0 foundation +# (M0-BUILD-01); laige-sim is the first module beyond it (M1-ECS-01). +# Later milestones add their targets in place. add_subdirectory(src/laige-core) +add_subdirectory(src/laige-sim) # --------------------------------------------------------------------------- # Tests (gated by LAIGE_BUILD_TESTS; conventions in tests/README.md) diff --git a/docs/README.md b/docs/README.md index d2be645..7b0cdc6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,11 @@ # Laige documentation -Documentation index and navigation (DOC-001). The engine is at **M0** -(foundations): `laige-core` is the only populated module. Every section -of the AGENTS §13 `docs/` tree exists; each entry below links what is -written and the "not yet written" section marks what is still to land. +Documentation index and navigation (DOC-001). The engine is at **M1** +(heartbeat): `laige-core` holds the M0 foundations, and `laige-sim` +has started (M1-ECS-01: the entity handle and world entity storage). +Every section of the AGENTS §13 `docs/` tree exists; each entry below +links what is written and the "not yet written" section marks what is +still to land. ## Getting started @@ -24,6 +26,9 @@ written and the "not yet written" section marks what is still to land. ## API contracts (per public header) +- [Entity handle and world entity storage](api/entity.md) — + `laige::Entity` (32-bit id+generation handle) and `laige::World` + entity storage (M1-ECS-01; `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, @@ -104,9 +109,10 @@ written and the "not yet written" section marks what is still to land. with M0-EXIT-01. - `compatibility/` — no persistent data formats or migration guides yet (they land with M1 replay and M6/M7 networking). -- Per-module API docs for the M1+ modules (`laige-sim`, `laige-render`, +- Per-module API docs for the remaining M1+ modules (`laige-render`, `laige-assets`, `laige-net`, `laige-server`, `laige-script`, - `laige-editor`) — they land with their modules. + `laige-editor`) — they land with their modules. (`laige-sim` has its + first doc: [entity.md](api/entity.md).) ## Related diff --git a/docs/api/entity.md b/docs/api/entity.md new file mode 100644 index 0000000..38dac72 --- /dev/null +++ b/docs/api/entity.md @@ -0,0 +1,171 @@ +# Entity handle and world entity storage (`laige::Entity`, `laige::World`) + +The ECS foundation (M1-ECS-01; PRD §9.1 S-1, FR-1.2, AGENTS CPP-007, +PERF-002/003, G-R3). Public header: +`src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/entity.cpp`. Unit suite: `ctest -R entity` +(`tests/laige-sim/entity_tests.cpp`). + +The safe API uses **32-bit entity handles, never raw pointers** (S-1): +use-after-free is detectable through the handle scheme, and — from +M1-ECS-03 on — structurally detectable through the generation check. + +## The 32-bit handle (FR-1.2, CPP-007) + +```text +Entity (exactly 32 bits, static-asserted): + [ id: 16 bits | generation: 16 bits ] +``` + +- **id** — the slot index, `0 .. 65535` (`Entity::kMaxEntityId`). +- **generation** — `1 .. 65535`. Generation `0` is reserved: the + default `Entity{}` is never valid. +- A handle is valid in the world that produced it while the slot is + live **and** the slot's current generation equals the handle's. + `destroy()`/`clear()` bump the slot's generation, so every stale + handle to that slot fails `isValid()` and can never pass again. + +**Generation wrap (documented limit).** After `2^16` (= 65536) +releases of one slot, the 16-bit generation wraps through the +reserved 0 and re-enters at generation 1 — colliding with the slot's +first incarnation, whose stale handle can no longer be distinguished. +That is the one case the scheme does not rule out: it requires a +single slot to be recycled 65536 times in a process's lifetime, +which is beyond the PRD §12.1 zone capacities (200–2000 entities) and +the M1 workloads (10k entities, 10k frames of churn). The M0-CORE-05 +`Pool` precedent documents the identical case at its 2^32 bound. +The wrap is defined unsigned wraparound (CPP-004), so handle +sequences stay bit-identical across platforms (ARCH-010). + +**Cross-world caveat.** A handle is meaningful only in the world that +produced it: an id+generation pair live at the same generation in two +worlds passes `isValid()` in both (the same cross-pool caveat as +`PoolHandle`, M0-CORE-05). + +## The `World` API + +| Operation | Behavior | Complexity / allocation | +|---|---|---| +| `World::create(Options)` (static) | Construction (setup path): the storage's only backing allocations. `capacity > Entity::kMaxEntities` → `InvalidArgument` (the world is not created) | setup; allocates 3 arrays of `capacity` slots | +| `create()` | Create one entity. LIFO slot recycling (deterministic). Beyond the budget → `BudgetExhausted` | O(1), no allocation | +| `destroy(e)` | Destroy one live entity; bumps the slot generation; returns the slot to the free list. Stale/invalid → debug: **assert** (S-9); release: `InvalidArgument` + warn-once | O(1), no allocation | +| `check(e)` | Access validation — the check every entity access performs (M1-ECS-03's component access builds on it). Stale/invalid → `InvalidArgument` + warn-once in **every build**; live → ok | O(1), no allocation | +| `isValid(e)` | Generation-checked liveness; no side effects | O(1) | +| `capacity()` / `entityCount()` | Declared budget / live count (the G-R3 numerator) | O(1) | +| `stats()` | `EntityStats` accounting snapshot (G-R3 and M1-PROF-01 feed) | O(1), no allocation | +| `clear()` | Destroy every live entity (shutdown path, CONC-006); every handle goes stale; capacity unchanged; world immediately reusable | O(capacity) scan, no allocation, idempotent | + +Move-only (O(1) pointer swap); a moved-from world is a valid empty +world (capacity 0: every `create()` fails, every handle invalid). +Not copyable. + +## Errors (FR-12.1, CORE-008) + +| Operation | Failure | Code | +|---|---|---| +| `World::create(Options)` | `capacity > Entity::kMaxEntities` (16-bit id space) | `ErrorCode::InvalidArgument` (2) | +| `create()` | declared scene budget exhausted | `ErrorCode::BudgetExhausted` (4) | +| `destroy(e)` / `check(e)` | stale, cleared, or out-of-range handle | `ErrorCode::InvalidArgument` (2) | + +Failures are `Result`/`Status` values — never exceptions, never +silent. The world logs its stale-handle degradations through the +logging facade under the stable subsystem name `ecs` (events +`stale_entity_access` / `stale_entity_destroy`); the facade's +per-(subsystem, event, severity) rate limit implements the +"warn-once" semantics (LOG-004: the first event emits, repeats are +counted and summarized). + +## Stale-handle behavior matrix (FR-12.3, S-9) + +| Operation | Debug build | Release build | +|---|---|---| +| `isValid(e)` | `false` | `false` | +| `check(e)` | ok / `InvalidArgument` + warn-once (the query degrades safely) | ok / `InvalidArgument` + warn-once | +| `destroy(e)` on stale | **assert** (SIGABRT — a loud use-after-free crash) | `InvalidArgument` + warn-once | + +Component access (M1-ECS-03) inherits the `destroy` row: uses of a +stale handle assert in debug and degrade in release. + +## Performance (DOC-004) + +- **Hot path:** `create()`/`destroy()`/`check()`/`isValid()` are O(1) + integer bookkeeping — one LIFO stack pop/push plus generation and + alive-flag reads. **No allocation** on any operation after + construction (PERF-003); the free list is a pre-allocated + `uint16` stack (PERF-004: contiguous, compact, no pointers). +- **Memory per slot:** 5 B bookkeeping (2 B generation + 1 B alive + flag + 2 B free-list entry). `EntityStats` reports + `capacity × 5` / `inUse × 5` bytes (M1-ECS-03 adds the per-entity + record to this number). +- **Zero-alloc enforcement:** the standing assertion lands with + M1-ALLOC-01; until then the step is verified by ASan + the + `stats()` accounting (M1 milestone rules). +- **Traps:** + - Holding a handle past `destroy()`/`clear()` and using it is + use-after-free — the debug build crashes on it (S-9); in release + read the `Status` and surface it. + - `BudgetExhausted` is a declared budget being exceeded — log it + and refuse the spawn (S-2); never grow the world at runtime + (G-R1). + - `clear()` is O(capacity) — a shutdown-phase operation, not a + per-tick one. + +## Threading and determinism + +- **Single owner thread** (CONC-001); `World` is not thread-safe + (PRD §10.2: simulation is single-threaded in M1). +- **Determinism (ARCH-010):** slot assignment is pure integer + bookkeeping — no floating point, no randomness, no platform + intrinsics. The same create/destroy sequence produces + bit-identical `Entity` handle sequences on every platform, so + handles are replay state from M1 on. + +## Usage (performant pattern) + +```cpp +// Setup (once, at world construction — the only allocation). +auto w = laige::World::create(laige::World::Options{2000}); // scene budget (G-R3) +if (w.isError()) { /* capacity above the 16-bit id space: configuration bug */ } +laige::World& world = std::move(w).takeValue(); + +// Hot path (per tick): no allocation. +auto r = world.create(); +if (r.isError()) { + // BudgetExhausted: the declared budget is full — log under your + // subsystem name and refuse the spawn (S-2). Never grow the world. +} else { + spawnThing(r.value()); // keep the Entity handle (32 bits) +} + +// Later, when the entity dies: +world.destroy(handle); // every other copy of `handle` is now stale + +// Before accessing stored state (M1-ECS-03 and on): +if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } +``` + +## Misuse warnings + +- A handle held past `destroy()`/`clear()` is use-after-free: check + `isValid()` or read the `Status` — never assume. +- A handle from one world used in another is meaningless (cross-world + caveat above). +- `BudgetExhausted` is not a transient error to retry-loop on: it is + the declared budget being exceeded — surface it and degrade + (G-R1/G-R3). +- `World` is move-only with one owner thread; copying is deleted and + sharing across threads requires an explicit engine boundary + (CONC-002), which M1 defines per subsystem. + +## Roadmap context + +- **M1-ECS-01 (this step):** the handle + entity storage above. +- **M1-ECS-02:** the component registry (`ComponentTypeId`) — + components get a stable compile-time id. +- **M1-ECS-03:** archetype SoA component storage on top of the same + slot table; `world.get(e)` is built on `World::check(e)` and + inherits the stale-handle contract. +- **M1-ECS-05:** deterministic iteration (archetype order, entity id + order — PRD §10.3). +- **M1-ECS-06:** the G-R3 warn thresholds (25%/50%/100% of the + declared budget) pull `stats()`. diff --git a/laige-api.json b/laige-api.json index dc41f8e..a870276 100644 --- a/laige-api.json +++ b/laige-api.json @@ -11,7 +11,8 @@ "src/laige-core/include/laige/pools.h", "src/laige-core/include/laige/prng.h", "src/laige-core/include/laige/result.h", - "src/laige-core/include/laige/sim_math.h" + "src/laige-core/include/laige/sim_math.h", + "src/laige-sim/include/laige/sim/entity.h" ], "symbols": [ {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, @@ -389,6 +390,37 @@ {"name": "laige::sim::SimMath::equals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 393, "signature": "static bool equals(Vec3 a, Vec3 b) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::sim::SimMath::notEquals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 396, "signature": "static bool notEquals(Vec3 a, Vec3 b) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::sim::SimMathFpx16", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 405, "signature": "using SimMathFpx16 = SimMath", "summary": "The `fpx16_16` SimMath (this step; `determinism.math` config id \"fixed_point_16_16\"). The DEFAULT backend (ADR 0002); required for lockstep (AC-10.3) and authoritative MMO. The `determinism.math` config plumbing lands with the config step (FR-1.5); until then, deterministic/lockstep engine code targets SimMathFpx16 and opt-in IEEE-float zones target SimMathFp32.", "budget": null, "experimental": false}, - {"name": "laige::sim::SimMathFp32", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 410, "signature": "using SimMathFp32 = SimMath", "summary": "The `fp32_pinned` SimMath (M0-CORE-03; `determinism.math` config id \"float_pinned_32\"). Opt-in for single-player / non-lockstep zones that want IEEE float semantics (ADR 0002).", "budget": null, "experimental": false} + {"name": "laige::sim::SimMathFp32", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 410, "signature": "using SimMathFp32 = SimMath", "summary": "The `fp32_pinned` SimMath (M0-CORE-03; `determinism.math` config id \"float_pinned_32\"). Opt-in for single-player / non-lockstep zones that want IEEE float semantics (ADR 0002).", "budget": null, "experimental": false}, + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 125, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, + {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 126, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 127, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 129, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 130, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 138, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 141, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 155, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, + {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 156, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 157, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 158, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 159, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 160, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 161, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 168, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, + {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 175, "signature": "struct Options", "summary": "The declared scene budget (G-R3), fixed at construction (API-006). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 176, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 182, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 187, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, + {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 194, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1), no allocation. The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, + {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 201, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, + {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 204, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 207, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, + {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 211, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, + {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 215, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 222, "signature": "void clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity) scan, no allocation, idempotent. (No per-slot element data exists yet; M1-ECS-03 adds the per-entity record that clear() will then destroy.)", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 226, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 227, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 228, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 229, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 233, "signature": "~World() noexcept", "summary": "Destroys nothing per element yet (no per-slot element data); releases the backing storage. Idempotent with clear().", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index c1f8e93..54dbd20 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -16,7 +16,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A ## ECS -- [ ] **M1-ECS-01 · Entity handle** +- [x] **M1-ECS-01 · Entity handle** - **Refs:** FR-1.2 (32-bit handle); AGENTS CPP-007 - **Depends:** M0-CORE-05 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index ab7cdb2..4073354 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 0 | ⬜ not started | +| M1 | 25 | 1 | 🚧 in progress (M1-ECS-01) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **22** | | +| **Total** | **193** | **23** | | --- @@ -195,6 +195,7 @@ One line per completed (or split/renumbered) step. | 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 | — | `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 | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt new file mode 100644 index 0000000..b4f5bac --- /dev/null +++ b/src/laige-sim/CMakeLists.txt @@ -0,0 +1,38 @@ +# laige-sim — game simulation module (PRD §10.1). +# +# Dependency rule (PRD §10.1, NFR-8.11): laige-sim sits above +# laige-core in the module stack and may include only laige-core's +# PUBLIC headers — enforced by tools/laige-include-lint (R2) and kept +# visible here through the single PUBLIC link below. Consumers of +# laige-sim see exactly its PUBLIC include directory plus laige-core's +# (required: laige-sim's public headers include laige-core headers, +# CPP-010). +# +# M1-ECS-01 (roadmap/M1-heartbeat.md): laige-sim is the first module +# beyond laige-core. This step lands the entity handle and the World +# entity storage (include/laige/sim/entity.h, entity.cpp); the game +# loop, systems, and the rest of the ECS land in the remaining M1-ECS / +# M1-SYS / M1-LOOP steps. + +if(LAIGE_BUILD_SHARED) + add_library(laige-sim SHARED entity.cpp) +else() + add_library(laige-sim STATIC entity.cpp) +endif() + +laige_apply_engine_policy(laige-sim) + +# M1: every sim module carries the SimMath pinned-math policy +# (ADR 0002, PRD §10.3; see laige_apply_simmath_policy in the root +# CMakeLists.txt — "from M1 on, every sim module joins the list"). +laige_apply_simmath_policy(laige-sim) + +# laige-core is laige-sim's only internal dependency (PRD §10.1: +# arrows only downward). PUBLIC: the public headers include +# laige-core headers. +target_link_libraries(laige-sim PUBLIC laige-core) + +# Public header root. Only this directory is exposed to consumers +# (CPP-010: no transitive leakage). +target_include_directories(laige-sim PUBLIC + $) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 4fc2794..23d1cbe 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -3,5 +3,11 @@ Module per PRD §10.1: game loop, physics, input state, animation state, pathing/steering, AI hooks. -Status: not started. Planned for M1 (game loop, ECS, systems) and M3 -(physics, input, animation). +Status: M1 in progress. M1-ECS-01 landed the ECS foundation — the +32-bit `laige::Entity` handle and the `laige::World` entity storage +(`include/laige/sim/entity.h`, `entity.cpp`; API contract in +[docs/api/entity.md](../docs/api/entity.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `entity`). The +component registry, archetype storage, iteration, and the game loop +land in the remaining M1-ECS / M1-SYS / M1-LOOP steps; physics, input, +and animation in M3. diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp new file mode 100644 index 0000000..d3c2da8 --- /dev/null +++ b/src/laige-sim/entity.cpp @@ -0,0 +1,170 @@ +// laige-sim World entity storage (M1-ECS-01). +// +// Implementation of the World declared in include/laige/sim/entity.h +// — see that header for the full contract (handle layout, +// stale-handle behavior, budget/allocation, ownership, threading, +// determinism). + +#include "laige/sim/entity.h" + +#include +#include +#include +#include + +#include "laige/logging.h" + +namespace laige { + +namespace { + +// The stable subsystem name for ECS events (LOG-001). +inline constexpr const char* kEcsSubsystem = "ecs"; + +// One slot's bookkeeping footprint: generation (2 B) + alive flag +// (1 B) + free-list entry (2 B). M1-ECS-03 adds the per-entity record +// to this number. +inline constexpr std::size_t kBytesPerSlot = 5; + +} // namespace + +World::World() = default; + +World::~World() noexcept { clear(); } + +World::World(World&& other) noexcept + : capacity_(other.capacity_), + generations_(std::move(other.generations_)), + alive_(std::move(other.alive_)), + freeStack_(std::move(other.freeStack_)), + freeCount_(other.freeCount_), inUse_(other.inUse_), + peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_) { + other.capacity_ = 0; + other.freeCount_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; +} + +World& World::operator=(World&& other) noexcept { + if (this == &other) return *this; + clear(); // release what this currently owns + capacity_ = other.capacity_; + generations_ = std::move(other.generations_); + alive_ = std::move(other.alive_); + freeStack_ = std::move(other.freeStack_); + freeCount_ = other.freeCount_; + inUse_ = other.inUse_; + peakInUse_ = other.peakInUse_; + totalCreated_ = other.totalCreated_; + other.capacity_ = 0; + other.freeCount_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; + return *this; +} + +Result World::create(Options options) noexcept { + // The 16-bit id space addresses 65536 slots; a larger declared + // budget is a configuration error (API-008: fail loudly at setup). + if (options.capacity > Entity::kMaxEntities) { + return ErrorCode::InvalidArgument; + } + World w; + w.capacity_ = options.capacity; + if (w.capacity_ > 0) { + // Backing allocations for the whole storage (setup path, + // PERF-002): the per-slot generation table, the per-slot alive + // flag, and the LIFO free-list stack (pre-filled 0..capacity-1). + w.generations_ = std::make_unique(w.capacity_); + w.alive_ = std::make_unique(w.capacity_); + w.freeStack_ = std::make_unique(w.capacity_); + for (std::uint32_t i = 0; i < w.capacity_; ++i) { + w.generations_[i] = 1; // generation 0 is reserved + w.freeStack_[i] = static_cast(i); + ++w.freeCount_; + } + } + return std::move(w); +} + +Result World::create() noexcept { + if (freeCount_ == 0) return ErrorCode::BudgetExhausted; + const std::uint16_t slot = freeStack_[--freeCount_]; + alive_[slot] = 1; + ++inUse_; + if (inUse_ > peakInUse_) peakInUse_ = inUse_; + ++totalCreated_; + return Entity{slot, generations_[slot]}; +} + +bool World::isValid(Entity entity) const noexcept { + return entity.id < capacity_ && entity.generation != 0 && + alive_[entity.id] != 0 && + generations_[entity.id] == entity.generation; +} + +Status World::check(Entity entity) const noexcept { + if (!isValid(entity)) { + LAIGE_LOG_WARN(kEcsSubsystem, "stale_entity_access", + "Stale or out-of-range entity handle used for access", + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation)); + return ErrorCode::InvalidArgument; + } + return Status{}; +} + +Status World::destroy(Entity entity) noexcept { + // A stale/invalid handle here is a caller bug (S-9): debug builds + // fail loudly; release builds degrade to a Status + warn-once + // (FR-12.3, CORE-008: never silent). + assert(isValid(entity) && + "World::destroy: stale or out-of-range entity handle " + "(destroyed, cleared, or never created) — " + "see docs/api/entity.md"); + if (!isValid(entity)) { + LAIGE_LOG_WARN(kEcsSubsystem, "stale_entity_destroy", + "Stale or out-of-range entity handle used for destroy", + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation)); + return ErrorCode::InvalidArgument; + } + alive_[entity.id] = 0; + bumpGeneration(entity.id); + freeStack_[freeCount_++] = entity.id; + --inUse_; + return Status{}; +} + +void World::clear() noexcept { + // No per-slot element data yet (M1-ECS-03 adds the per-entity + // record): clear is slot bookkeeping only. + for (std::uint32_t i = 0; i < capacity_; ++i) { + if (alive_[i] != 0) { + alive_[i] = 0; + bumpGeneration(static_cast(i)); + freeStack_[freeCount_++] = static_cast(i); + } + } + inUse_ = 0; +} + +std::uint32_t World::capacity() const noexcept { return capacity_; } + +std::uint32_t World::entityCount() const noexcept { return inUse_; } + +EntityStats World::stats() const noexcept { + return EntityStats{ + capacity_, inUse_, peakInUse_, totalCreated_, + static_cast(capacity_) * kBytesPerSlot, + static_cast(inUse_) * kBytesPerSlot}; +} + +void World::bumpGeneration(std::uint16_t slot) noexcept { + ++generations_[slot]; // defined unsigned wrap (CPP-004) + if (generations_[slot] == 0) ++generations_[slot]; // skip reserved 0 +} + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h new file mode 100644 index 0000000..bbd3a67 --- /dev/null +++ b/src/laige-sim/include/laige/sim/entity.h @@ -0,0 +1,256 @@ +// laige-sim entity handle and world entity storage (M1-ECS-01). +// +// PRD §9.1 S-1: the safe API uses 32-bit entity handles, never raw +// pointers to engine-owned data. This header ships the two ECS +// foundation types: +// +// Entity The 32-bit handle: a 16-bit entity id plus a 16-bit +// generation (FR-1.2, CPP-007). A handle is valid while the +// slot is live and the slot's current generation equals the +// handle's; a generation bump on release invalidates every +// stale handle to that slot. +// +// World The entity storage a handle indexes into: create/destroy +// over a budgeted, accounted, pool-backed slot table with +// no per-operation heap (PERF-002/003, S-2; M0-CORE-05 +// Pool precedent). M1-ECS-03 adds the per-entity component +// storage on top of this slot table. +// +// --------------------------------------------------------------------------- +// The handle contract (FR-1.2, CPP-007) +// --------------------------------------------------------------------------- +// +// Layout (32 bits total, static-asserted below): +// [ id: 16 bits | generation: 16 bits ] +// +// id slot index, 0 .. kMaxEntityId (65535). +// generation 1 .. 65535. Generation 0 is reserved: the default +// Entity{} is never valid. +// +// A handle is valid in the world that produced it while: +// +// id < world.capacity() && generation != 0 && +// the slot is live && slot generation == handle generation. +// +// destroy()/clear() bump the slot's generation, so every stale handle +// to that slot fails isValid() and can never pass again — except after +// 2^16 releases of that one slot (defined unsigned wrap; the documented +// one case the 16-bit scheme does not rule out, and effectively +// unreachable at the PRD §12.1 zone capacities — the M0-CORE-05 2^32 +// case at the 16-bit bound). +// +// A handle is meaningful only in the world that produced it: +// id+generation pairs from different worlds are not comparable (an id +// live at the same generation in both worlds passes isValid() in both +// — the same cross-pool caveat as PoolHandle, M0-CORE-05). +// +// --------------------------------------------------------------------------- +// Stale-handle behavior (FR-12.3, S-9, CORE-008: never silent) +// --------------------------------------------------------------------------- +// +// Queries degrade safely in every build; USES of a stale handle fail +// loudly in debug and degrade in release: +// +// isValid(e) bool, every build, no side effects. +// check(e) Status: ok when live; stale/out-of-range -> +// ErrorCode::InvalidArgument + one rate-limited +// structured warn (LOG-004), every build. check() is +// the access validation that M1-ECS-03's component +// access builds on. +// destroy(e) stale/out-of-range -> debug: assert (a loud +// use-after-free crash, S-9); release: +// ErrorCode::InvalidArgument + warn-once (FR-12.3). +// +// --------------------------------------------------------------------------- +// Budget and allocation (G-R3, PERF-002/003, S-2) +// --------------------------------------------------------------------------- +// +// The entity capacity is the declared scene budget (G-R3), fixed at +// construction via World::Options (API-006): raising it is a typed +// configuration change, never a runtime behavior. 0 is legal (every +// create() fails). create() beyond the budget returns +// ErrorCode::BudgetExhausted — the world never grows silently (S-2, +// G-R1). The G-R3 warn thresholds (25%/50%/100%) land with M1-ECS-06. +// +// World::create(Options) performs the storage's only backing +// allocations (a setup path, never a hot path). Every create()/ +// destroy()/check()/isValid()/clear() is O(1) and allocates nothing +// (the free list is a pre-allocated LIFO stack). The standing +// zero-allocation check lands with M1-ALLOC-01; until then ASan + the +// stats() accounting is the check (M1 milestone rules). +// +// --------------------------------------------------------------------------- +// Ownership, threading, determinism +// --------------------------------------------------------------------------- +// +// A World is move-only (O(1) pointer swap); a moved-from world is a +// valid empty world (capacity 0: every create() fails, every handle +// invalid). It has exactly one owner thread and is NOT thread-safe +// (CONC-001; PRD §10.2: simulation is single-threaded). +// +// Determinism (ARCH-010): slot assignment is pure integer bookkeeping +// over the LIFO free list — no floating point, no randomness, no +// platform intrinsics. The same create/destroy sequence produces +// bit-identical handle sequences on every platform, so handles are +// safe to replicate and are replay state from M1 on. (The one +// generation-wrap case above is defined unsigned wrap, hence +// bit-identical too.) +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - A handle held past destroy()/clear() is use-after-free. Check +// isValid() (or read the Status from check()) — never assume. +// - A handle from one world used in another is meaningless (above). +// - BudgetExhausted from create() is a declared budget being +// exceeded — surface it (log + refuse the spawn, S-2), never "fix" +// it by growing the world at runtime. +// - World is move-only: keep one owner; a moved-from world is empty. + +#pragma once + +#include +#include +#include + +#include "laige/errors.h" +#include "laige/result.h" + +namespace laige { + +// The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit +// generation (CPP-007). See the header preamble for the full handle +// contract. +struct Entity { + std::uint16_t id{}; // slot index (0 .. kMaxEntityId) + std::uint16_t generation{}; // 0 is reserved: never a live handle + + static constexpr std::uint32_t kMaxEntityId = 0xFFFFu; // 65535 + static constexpr std::uint32_t kMaxEntities = 0x10000u; // 65536 slots +}; + +// The handle is exactly 32 bits (FR-1.2). +static_assert(sizeof(Entity) == 4, + "Entity must be a 32-bit handle (FR-1.2)"); + +// Handle comparison compares the (id, generation) pair. +inline bool operator==(Entity a, Entity b) noexcept { + return a.id == b.id && a.generation == b.generation; +} +inline bool operator!=(Entity a, Entity b) noexcept { + return !(a == b); +} + +// One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; +// mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 +// profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull: +// +// capacity the declared scene budget (World::Options::capacity) +// inUse live entities right now +// peakInUse high-water mark of inUse since construction +// totalCreated successful create() calls since construction (churn) +// bytesCapacity backing-store bytes (per-slot bookkeeping) +// bytesInUse bookkeeping bytes occupied by the live slots +struct EntityStats { + std::uint32_t capacity{}; + std::uint32_t inUse{}; + std::uint32_t peakInUse{}; + std::uint64_t totalCreated{}; + std::size_t bytesCapacity{}; + std::size_t bytesInUse{}; +}; + +// The entity storage behind laige::Entity handles (M1-ECS-01). +// +// See the header preamble for the handle, stale-handle, budget, +// allocation, ownership, threading, and determinism contracts. +class World { + public: + // The declared scene budget (G-R3), fixed at construction (API-006). + // 0 is legal: every create() fails. Values above + // Entity::kMaxEntities are rejected at construction — the 16-bit id + // space cannot address them (API-008: the invalid state stays + // unrepresentable). + struct Options { + std::uint32_t capacity{}; + }; + + // Construction (setup path: the storage's only backing allocations). + // capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a + // handle-space configuration error; the world is not created). + [[nodiscard]] static Result create(Options options) noexcept; + + // Create one entity. O(1), no allocation. Beyond the budget: + // ErrorCode::BudgetExhausted (the world never grows silently, S-2). + // Slot assignment is LIFO recycling — deterministic (see preamble). + [[nodiscard]] Result create() noexcept; + + // Destroy one live entity and return its slot to the free list. + // O(1), no allocation. The slot's generation is bumped, so every + // stale handle to it fails isValid() (CPP-007). Stale/invalid + // handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + // + one rate-limited warn (FR-12.3: never silent). + [[nodiscard]] Status destroy(Entity entity) noexcept; + + // Access validation — the check every entity access performs + // (M1-ECS-03's component access builds on this). O(1), no + // allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one + // rate-limited warn in every build (queries degrade safely, never + // silent); live: an ok Status. + [[nodiscard]] Status check(Entity entity) const noexcept; + + // Generation-checked liveness (CPP-007). O(1), no side effects. + [[nodiscard]] bool isValid(Entity entity) const noexcept; + + // The declared scene budget (World::Options::capacity). + [[nodiscard]] std::uint32_t capacity() const noexcept; + + // The live entity count right now (the G-R3 numerator; M1-ECS-06 + // turns the inUse/capacity ratio into the 25%/50%/100% warns). + [[nodiscard]] std::uint32_t entityCount() const noexcept; + + // Entity accounting snapshot for the profiler (M1-PROF-01) and the + // G-R3 guardrail (M1-ECS-06). O(1), no allocation. + [[nodiscard]] EntityStats stats() const noexcept; + + // Destroy every live entity (shutdown path, CONC-006). Every handle + // becomes stale; the capacity is unchanged and the world is + // immediately reusable. O(capacity) scan, no allocation, idempotent. + // (No per-slot element data exists yet; M1-ECS-03 adds the + // per-entity record that clear() will then destroy.) + void clear() noexcept; + + // Move is an O(1) pointer swap; the source becomes a valid empty + // world (capacity 0: every create() fails, every handle invalid). + World(World&& other) noexcept; + World& operator=(World&& other) noexcept; + World(const World&) = delete; + World& operator=(const World&) = delete; + + // Destroys nothing per element yet (no per-slot element data); + // releases the backing storage. Idempotent with clear(). + ~World() noexcept; + + private: + // The factory (World::create) is the only constructor path: a World + // with an out-of-range capacity cannot be built (API-008). + World(); + + // Bump one slot's generation on release (defined unsigned wrap, + // CPP-004). Generation 0 is reserved, so a wrap through 0 is + // skipped: the slot re-enters at generation 1 — the documented one + // case the 16-bit scheme does not rule out (see the preamble). + void bumpGeneration(std::uint16_t slot) noexcept; + + std::uint32_t capacity_{0}; + std::unique_ptr generations_; + std::unique_ptr alive_; + std::unique_ptr freeStack_; + std::uint32_t freeCount_{0}; + std::uint32_t inUse_{0}; + std::uint32_t peakInUse_{0}; + std::uint64_t totalCreated_{0}; +}; + +} // namespace laige diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 626381e..b0309cd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -64,6 +64,10 @@ endforeach() add_subdirectory(laige-core) +# laige-sim tests (M1-ECS-01): the entity-handle and World suites +# (tests/laige-sim; CTest entry `entity`). +add_subdirectory(laige-sim) + # Test-infrastructure checks (M0-TEST-01): the seed-handling convention # for randomized tests (tests/support/laige_test_seed.h) is exercised by # tests/testing (suite `SeededRandom`, CTest entry `test_infra`). diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt new file mode 100644 index 0000000..d2086d4 --- /dev/null +++ b/tests/laige-sim/CMakeLists.txt @@ -0,0 +1,39 @@ +# laige-sim tests (M1-ECS-01): entity handle + World entity storage. +# +# One executable per module (tests/README.md; docs/testing.md is the +# source of truth): laige-sim_tests links the module under test plus +# gtest_main. The unfiltered entry runs the whole module; the +# `entity` entry is M1-ECS-01's Verify command (`ctest -R entity`), +# selecting exactly the suites below from the shared executable. + +set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp) + +add_executable(laige-sim_tests ${LAIGE_SIM_TEST_SOURCES}) +laige_apply_engine_policy(laige-sim_tests) +# M1: the sim test executable carries the same pinned SimMath flag set +# as laige-sim (ADR 0002, PRD §10.3 — the sim test TUs join the policy +# list with the module itself). +laige_apply_simmath_policy(laige-sim_tests) + +# Links the module under test plus the dev-only test framework; +# gtest_main provides main(). +target_link_libraries(laige-sim_tests PRIVATE gtest_main laige-sim) + +add_test(NAME laige-sim_tests COMMAND laige-sim_tests) + +# M1-ECS-01: entity handle + World entity storage. The step's Verify +# command is `ctest -R entity`; this entry selects exactly the entity +# suites from the shared laige-sim_tests executable (the unfiltered +# entry above still runs them as part of the full module suite). +# The gtest filter is one UNQUOTED argument (see +# tests/laige-core/CMakeLists.txt for the rationale). +add_test(NAME entity + COMMAND laige-sim_tests + --gtest_filter=EntityHandle.*:WorldBasics.*:WorldCapacity.*:WorldLogging.*:WorldStale.*:WorldStats.*) + +if(LAIGE_TSAN) + # Make the first data race report fatal to the test process (NFR-8.2), + # so ctest fails loudly on any TSan report. + set_tests_properties(laige-sim_tests entity + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") +endif() diff --git a/tests/laige-sim/entity_tests.cpp b/tests/laige-sim/entity_tests.cpp new file mode 100644 index 0000000..0623f63 --- /dev/null +++ b/tests/laige-sim/entity_tests.cpp @@ -0,0 +1,615 @@ +// laige-sim entity handle + World entity storage suite (M1-ECS-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - reuse after destroy bumps generation (CPP-007) +// - stale access is detected: queries degrade safely in every build +// (isValid false, check -> InvalidArgument + warn-once); using a +// stale handle asserts in debug (forked SIGABRT child) and returns +// Status in release (FR-12.3, S-9) +// - capacity limit honored: create() beyond the declared scene +// budget (G-R3) returns BudgetExhausted; an over-capacity world +// construction returns InvalidArgument (API-008) +// - the documented 16-bit generation wrap is pinned, not assumed +// - accounting (stats()) feeds the G-R3 guardrail / profiler +// +// Runs as CTest `entity` (the step's Verify command: `ctest -R entity`, +// green under ASan per the step's Verify clause): a filtered view of +// the shared laige-sim_tests executable, selecting exactly the suites +// below. + +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim/entity.h" + +#if defined(__unix__) +#include +#include +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "entity_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "entity_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "entity_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define ENTITY_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define ENTITY_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if ENTITY_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "entity_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +namespace { + +// One world, taken out of its Result so the test holds a mutable +// lvalue (Result::value() is const; takeValue() && moves the storage +// out — the documented ownership-transfer path). +laige::World makeWorld(std::uint32_t capacity) { + auto w = laige::World::create(laige::World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// The 32-bit handle (FR-1.2, CPP-007) +// --------------------------------------------------------------------------- + +TEST(EntityHandle, HandleIs32Bits) { + static_assert(sizeof(laige::Entity) == 4, + "Entity must be a 32-bit handle (FR-1.2)"); + const laige::Entity e{}; + EXPECT_EQ(e.id, 0u); + EXPECT_EQ(e.generation, 0u); +} + +TEST(EntityHandle, EqualityComparesThePair) { + const laige::Entity a{1, 2}; + const laige::Entity b{1, 2}; + const laige::Entity c{1, 3}; + const laige::Entity d{2, 2}; + EXPECT_EQ(a, b); + EXPECT_NE(a, c); + EXPECT_NE(a, d); + EXPECT_NE(laige::Entity{}, a); +} + +// --------------------------------------------------------------------------- +// World entity storage: create/destroy, LIFO recycling, clear, move +// --------------------------------------------------------------------------- + +TEST(WorldBasics, CreateAssignsLifoSlotsAndCounts) { + // LIFO free-list recycling: the free stack is pre-filled + // 0..capacity-1, so slots are handed out from the top (capacity-1 + // down) — deterministic for a given operation sequence (ARCH-010; + // see the header). + laige::World world = makeWorld(6); + laige::Entity a, b, c; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + a = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + b = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + c = r.value(); + } + EXPECT_EQ(a.id, 5u); + EXPECT_EQ(b.id, 4u); + EXPECT_EQ(c.id, 3u); + EXPECT_EQ(a.generation, 1u); + EXPECT_EQ(b.generation, 1u); + EXPECT_EQ(c.generation, 1u); + EXPECT_EQ(world.entityCount(), 3u); + EXPECT_TRUE(world.isValid(a)); + EXPECT_TRUE(world.isValid(b)); + EXPECT_TRUE(world.isValid(c)); +} + +TEST(WorldBasics, GenerationBumpsOnReuseAfterDestroy) { + // The roadmap's named property: reusing a destroyed entity's slot + // bumps its generation (CPP-007), so the stale handle can never pass + // isValid() again (short of the documented 2^16 wrap — see + // GenerationWrapDocumentedCollision). + laige::World world = makeWorld(1); + auto r1 = world.create(); + ASSERT_TRUE(r1.ok()); + const laige::Entity stale = r1.value(); + ASSERT_TRUE(world.destroy(stale).ok()); + auto r2 = world.create(); + ASSERT_TRUE(r2.ok()); + const laige::Entity live = r2.value(); + EXPECT_EQ(live.id, stale.id); + EXPECT_EQ(live.generation, stale.generation + 1); + EXPECT_FALSE(world.isValid(stale)); + EXPECT_TRUE(world.isValid(live)); +} + +TEST(WorldBasics, LifoRecyclingOrder) { + // The most recently freed slot is recycled first (LIFO); untouched + // entities keep their slots and generations. + laige::World world = makeWorld(6); + laige::Entity a, b, c; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + a = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + b = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + c = r.value(); + } + ASSERT_TRUE(world.destroy(b).ok()); + auto r4 = world.create(); + ASSERT_TRUE(r4.ok()); + const laige::Entity d = r4.value(); + EXPECT_EQ(d.id, b.id); + EXPECT_EQ(d.generation, b.generation + 1); + EXPECT_TRUE(world.isValid(a)); + EXPECT_TRUE(world.isValid(c)); +} + +TEST(WorldBasics, ClearInvalidatesAllAndIsReusable) { + laige::World world = makeWorld(3); + laige::Entity e0, e1, e2; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e0 = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e1 = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e2 = r.value(); + } + world.clear(); + EXPECT_EQ(world.entityCount(), 0u); + EXPECT_FALSE(world.isValid(e0)); + EXPECT_FALSE(world.isValid(e1)); + EXPECT_FALSE(world.isValid(e2)); + // The capacity is unchanged and the world is immediately reusable; + // every cleared slot's generation was bumped. + EXPECT_EQ(world.capacity(), 3u); + auto r = world.create(); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(r.value().id, 2u); // LIFO: the top of the cleared stack + EXPECT_EQ(r.value().generation, 2u); + world.clear(); // idempotent + EXPECT_EQ(world.entityCount(), 0u); +} + +TEST(WorldBasics, MoveTransfersStorageAndEmptiesSource) { + laige::World worldA = makeWorld(4); + laige::Entity e1, e2; + { + auto r = worldA.create(); + ASSERT_TRUE(r.ok()); + e1 = r.value(); + } + { + auto r = worldA.create(); + ASSERT_TRUE(r.ok()); + e2 = r.value(); + } + laige::World worldB = std::move(worldA); + EXPECT_EQ(worldB.capacity(), 4u); + EXPECT_EQ(worldB.entityCount(), 2u); + EXPECT_TRUE(worldB.isValid(e1)); + EXPECT_TRUE(worldB.isValid(e2)); + EXPECT_TRUE(worldB.create().ok()); + // The moved-from world is a valid empty world (capacity 0). + EXPECT_EQ(worldA.capacity(), 0u); + EXPECT_EQ(worldA.entityCount(), 0u); + EXPECT_FALSE(worldA.isValid(e1)); + EXPECT_FALSE(worldA.isValid(e2)); + auto r = worldA.create(); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); +} + +TEST(WorldBasics, MoveAssignmentTakesOverAndEmptiesSource) { + laige::World worldA = makeWorld(2); + laige::World worldB = makeWorld(3); + ASSERT_TRUE(worldA.create().ok()); // one live entity in A + laige::Entity moved; + { + auto r = worldB.create(); + ASSERT_TRUE(r.ok()); + moved = r.value(); + } + worldA = std::move(worldB); + EXPECT_EQ(worldA.capacity(), 3u); + EXPECT_EQ(worldA.entityCount(), 1u); + EXPECT_TRUE(worldA.isValid(moved)); + EXPECT_EQ(worldB.capacity(), 0u); + EXPECT_EQ(worldB.entityCount(), 0u); + EXPECT_FALSE(worldB.isValid(moved)); +} + +// --------------------------------------------------------------------------- +// Capacity: the declared scene budget (G-R3) +// --------------------------------------------------------------------------- + +TEST(WorldCapacity, CapacityLimitHonored) { + // The roadmap's named property: the declared scene budget is the + // hard ceiling — create() beyond it returns BudgetExhausted (the + // world never grows silently, S-2/G-R1). + laige::World world = makeWorld(3); + laige::Entity e0, e1, e2; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e0 = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e1 = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e2 = r.value(); + } + EXPECT_EQ(world.entityCount(), 3u); + auto full = world.create(); + EXPECT_FALSE(full.ok()); + EXPECT_EQ(full.error(), laige::ErrorCode::BudgetExhausted); + // Reclaiming a slot keeps the world within the budget. + ASSERT_TRUE(world.destroy(e1).ok()); + auto recycled = world.create(); + EXPECT_TRUE(recycled.ok()); + EXPECT_EQ(world.entityCount(), 3u); +} + +TEST(WorldCapacity, ZeroCapacityRefusesEveryCreate) { + // A budget of 0 is legal (every create() fails) — the pool + // precedent (M0-CORE-05). + laige::World world = makeWorld(0); + EXPECT_EQ(world.capacity(), 0u); + auto r = world.create(); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_FALSE(world.isValid(laige::Entity{})); + world.clear(); // idempotent on an empty world + EXPECT_EQ(world.entityCount(), 0u); +} + +TEST(WorldCapacity, OverCapacityIsInvalidArgument) { + // The 16-bit id space addresses 65536 slots; a declared budget above + // that is a configuration error, rejected at construction (API-008: + // the invalid state stays unrepresentable). + auto over = + laige::World::create(laige::World::Options{laige::Entity::kMaxEntities + 1}); + EXPECT_FALSE(over.ok()); + EXPECT_EQ(over.error(), laige::ErrorCode::InvalidArgument); + // The boundary itself is legal. + auto max = + laige::World::create(laige::World::Options{laige::Entity::kMaxEntities}); + EXPECT_TRUE(max.ok()); + EXPECT_EQ(max.value().capacity(), laige::Entity::kMaxEntities); +} + +// --------------------------------------------------------------------------- +// Stale-handle behavior (FR-12.3, S-9, CORE-008) +// --------------------------------------------------------------------------- + +TEST(WorldStale, IsFalseForStaleClearedOutOrRangeAndDefault) { + laige::World world = makeWorld(2); + laige::Entity e0, e1; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e0 = r.value(); + } + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e1 = r.value(); + } + // Destroyed (stale): + ASSERT_TRUE(world.destroy(e1).ok()); + EXPECT_FALSE(world.isValid(e1)); + // Out-of-range id (beyond the declared budget): + EXPECT_FALSE(world.isValid(laige::Entity{ + static_cast(world.capacity()), 1})); + // Generation 0 is reserved: + EXPECT_FALSE(world.isValid(laige::Entity{0, 0})); + // The default handle is never valid: + EXPECT_FALSE(world.isValid(laige::Entity{})); + // Cleared: + world.clear(); + EXPECT_FALSE(world.isValid(e0)); +} + +TEST(WorldStale, CheckStaleReturnsInvalidArgument) { + // check() is the safe access validation (M1-ECS-03's component + // access builds on it): in every build — not only release — a stale + // handle yields Status InvalidArgument + a rate-limited warn, never + // silent (FR-12.3, CORE-008). + laige::World world = makeWorld(2); + auto r = world.create(); + ASSERT_TRUE(r.ok()); + const laige::Entity e = r.value(); + EXPECT_TRUE(world.check(e).ok()); + ASSERT_TRUE(world.destroy(e).ok()); + auto s = world.check(e); + EXPECT_FALSE(s.ok()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + // An out-of-range handle fails the same way. + auto s2 = world.check(laige::Entity{ + static_cast(world.capacity()), 1}); + EXPECT_FALSE(s2.ok()); + EXPECT_EQ(s2.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(WorldStale, DestroyStaleReturnsInvalidArgumentInRelease) { + // The release degradation path of destroy() (FR-12.3). Debug builds + // assert instead (DestroyStaleAbortsInDebug below); the same + // validation is exercised in every build via check() above. +#ifdef NDEBUG + laige::World world = makeWorld(1); + auto r = world.create(); + ASSERT_TRUE(r.ok()); + const laige::Entity e = r.value(); + ASSERT_TRUE(world.destroy(e).ok()); + auto s = world.destroy(e); + EXPECT_FALSE(s.ok()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); +#else + GTEST_SKIP() << "destroy() on a stale handle asserts in debug builds " + "(S-9); the release Status path is covered by " + "CheckStaleReturnsInvalidArgument in every build."; +#endif +} + +TEST(WorldStale, DestroyStaleAbortsInDebug) { + // The step's Verify clause "debug stale-handle test asserts" (S-9): + // using a stale handle through destroy() must fail loudly in debug + // builds. Exercised in a forked child so the test process survives: + // the child must die on SIGABRT. POSIX only (fork); the Windows jobs + // skip with a reason (the pools_tests.cpp pattern). +#if defined(__unix__) +# if defined(NDEBUG) + GTEST_SKIP() << "assert-based stale detection is a debug-build property"; +# else + const pid_t pid = fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + auto w = laige::World::create(laige::World::Options{1}); + if (!w.ok()) _exit(117); + laige::World world = std::move(w).takeValue(); + auto r = world.create(); + if (!r.ok() || !world.destroy(r.value()).ok()) _exit(117); + // Stale: the debug assert inside destroy() must fire before this + // statement completes. + (void)world.destroy(r.value()); + _exit(1); // unreachable: the parent fails below without the assert + } + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT) + << "expected the stale-handle assert to abort the child (SIGABRT)"; +# endif +#else + GTEST_SKIP() << "fork() is not available on Windows; the stale-handle " + "assert is exercised on the POSIX jobs."; +#endif +} + +TEST(WorldStale, GenerationWrapDocumentedCollision) { + // The documented 16-bit generation bound: after 2^16 releases of one + // slot the generation wraps through the reserved 0 and re-enters at + // generation 1 — colliding with the slot's first incarnation, whose + // stale handle can no longer be distinguished. This is the one case + // the scheme does not rule out (see the header; the M0-CORE-05 + // precedent documents the same case at the 2^32 bound). The test + // pins the wrap behavior instead of assuming it. + laige::World world = makeWorld(1); + auto first = world.create(); + ASSERT_TRUE(first.ok()); + const laige::Entity firstHandle = first.value(); + EXPECT_EQ(firstHandle.generation, 1u); + // The first release takes the generation 1 -> 2; the remaining + // 2^16 - 2 releases walk 2 -> 0xFFFF -> (wrap through the reserved + // 0) -> 1. + ASSERT_TRUE(world.destroy(firstHandle).ok()); + for (std::uint32_t i = 0; i + 2 < laige::Entity::kMaxEntities; ++i) { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + ASSERT_TRUE(world.destroy(r.value()).ok()); + } + auto last = world.create(); + ASSERT_TRUE(last.ok()); + const laige::Entity lastHandle = last.value(); + EXPECT_EQ(lastHandle.generation, 1u); + EXPECT_TRUE(world.isValid(lastHandle)); + // The documented collision: the first incarnation's stale handle + // matches the wrapped generation again. + EXPECT_TRUE(world.isValid(firstHandle)); +} + +// --------------------------------------------------------------------------- +// Accounting (FR-11.1/FR-11.4; G-R3 and M1-PROF-01 feed) +// --------------------------------------------------------------------------- + +TEST(WorldStats, StatsTrackCountsPeakAndChurn) { + laige::World world = makeWorld(4); + laige::Entity e[4]; + for (int i = 0; i < 4; ++i) { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e[i] = r.value(); + } + auto s = world.stats(); + EXPECT_EQ(s.capacity, 4u); + EXPECT_EQ(s.inUse, 4u); + EXPECT_EQ(s.peakInUse, 4u); + EXPECT_EQ(s.totalCreated, 4u); + // A create immediately destroyed still counts as churn (G-R4). + ASSERT_TRUE(world.destroy(e[2]).ok()); + auto r = world.create(); + ASSERT_TRUE(r.ok()); + s = world.stats(); + EXPECT_EQ(s.inUse, 4u); + EXPECT_EQ(s.peakInUse, 4u); + EXPECT_EQ(s.totalCreated, 5u); + ASSERT_TRUE(world.destroy(e[0]).ok()); + ASSERT_TRUE(world.destroy(e[1]).ok()); + ASSERT_TRUE(world.destroy(e[3]).ok()); + s = world.stats(); + EXPECT_EQ(s.inUse, 1u); + EXPECT_EQ(s.peakInUse, 4u); + EXPECT_EQ(s.totalCreated, 5u); +} + +TEST(WorldStats, StatsTrackBytes) { + laige::World world = makeWorld(4); + auto r = world.create(); + ASSERT_TRUE(r.ok()); + const auto s = world.stats(); + // Per-slot footprint: 5 B bookkeeping (2 B generation + 1 B alive + // flag + 2 B free-list entry) — see the header and entity.md. + EXPECT_EQ(s.bytesCapacity, 4u * 5u); + EXPECT_EQ(s.bytesInUse, 1u * 5u); +} + +// --------------------------------------------------------------------------- +// Stale-access logging: warn-once (LOG-004) through the facade +// --------------------------------------------------------------------------- + +namespace { + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; this test owns its window and +// restores the default console sink at the end). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +} // namespace + +TEST(WorldLogging, StaleAccessWarnsOnce) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); // the burst stays in-window + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(1); + auto created = world.create(); + ASSERT_TRUE(created.ok()); + ASSERT_TRUE(world.destroy(created.value()).ok()); + const laige::Entity stale = created.value(); + + // Three stale accesses within the window: the first emits the warn, + // the other two are suppressed and counted (LOG-004: the + // rate_limited summary carries the count). + for (int i = 0; i < 3; ++i) { + const auto s = world.check(stale); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + } + + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(sinkPtr->entries[0].subsystem, "ecs"); + EXPECT_EQ(sinkPtr->entries[0].event, "stale_entity_access"); + + // Controlled shutdown drains the pending rate-limit summary + // (CONC-006/LOG-007/LOG-004). + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(sinkPtr->entries.size(), 2u); + EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + bool foundEventField = false; + bool foundCountField = false; + for (const auto& [key, value] : sinkPtr->entries[1].fields) { + if (key == "event" && value == "stale_entity_access") foundEventField = true; + if (key == "suppressed" && value == "2") foundCountField = true; + } + EXPECT_TRUE(foundEventField); + EXPECT_TRUE(foundCountField); + + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +} From 6e0f4f7d344bfaa1286301bca4108b85d4830884 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 13:46:11 +0200 Subject: [PATCH 2/2] [M1-ECS-01] Fill in change-log commit hash (f173682) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 4073354..8ad206e 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -195,7 +195,7 @@ One line per completed (or split/renumbered) step. | 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 | — | `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-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 | ---