From cad05940ff2229966d97a60845fb828b207a91b2 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 22:47:12 +0200 Subject: [PATCH 1/2] [M1-ECS-03] Archetype SoA storage Archetype = ordered component set stored SoA (one packed T[] column per component, rows in ascending slot-id order); entity->archetype map as two dense per-slot tables (archetypeOf_ + rowOf_, no hash); O(1) get/has; addComponent create-or-update / removeComponent no-op-ok with pool-backed moves (tail memmove + rowOf_ re-sync) and zero heap allocation per operation (bounded reserve policy: min(16,capacity) initial, x2 growth capped at world capacity, accounted + logged). Budgets: kMaxArchetypes=256, kMaxArchetypeComponents=32 (BudgetExhausted + rate-limited warns ecs/archetype_budget, ecs/component_limit). Per-slot bookkeeping 5 -> 11 B; destroy/clear detach rows first (documented cost). 25 test cases in the 'archetype' CTest entry, incl. the 10k-entity churn (20k add/remove ops, seeded random order: zero failures, zero reservation delta, zero process-wide allocations via test-only operator new counter, p99/p50 ~ 1.98, machine-greppable stats line per ctest run). Docs: docs/api/archetype.md (+ cross-refs), laige-api.json regenerated (443 symbols), roadmap checkbox + Progress Board 3/25 + Change Log. Verify: canonical g++ tree zero-warning, full ctest 36/36, ctest -R archetype green, sim suites green on build-asan/build-clang/build-release/build-shared/ build-tsan, tools/laige-include-lint OK. --- docs/README.md | 7 +- docs/api/archetype.md | 222 ++++ docs/api/component_registry.md | 10 +- docs/api/entity.md | 34 +- laige-api.json | 91 +- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-sim/CMakeLists.txt | 10 +- src/laige-sim/README.md | 12 +- src/laige-sim/archetype.cpp | 330 ++++++ src/laige-sim/entity.cpp | 77 +- src/laige-sim/include/laige/sim/archetype.h | 293 +++++ src/laige-sim/include/laige/sim/entity.h | 441 +++++++- tests/laige-sim/CMakeLists.txt | 45 +- tests/laige-sim/archetype_tests.cpp | 1061 +++++++++++++++++++ tests/laige-sim/entity_tests.cpp | 9 +- tests/laige-sim/logging_alloc_counter.cpp | 79 ++ tests/laige-sim/logging_alloc_counter.h | 51 + 18 files changed, 2688 insertions(+), 91 deletions(-) create mode 100644 docs/api/archetype.md create mode 100644 src/laige-sim/archetype.cpp create mode 100644 src/laige-sim/include/laige/sim/archetype.h create mode 100644 tests/laige-sim/archetype_tests.cpp create mode 100644 tests/laige-sim/logging_alloc_counter.cpp create mode 100644 tests/laige-sim/logging_alloc_counter.h diff --git a/docs/README.md b/docs/README.md index b836db0..3fbfc10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,8 @@ 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; -M1-ECS-02: the component type registry). +M1-ECS-02: the component type registry; M1-ECS-03: archetype SoA +component 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. @@ -33,6 +34,10 @@ still to land. - [Component type registry](api/component_registry.md) — `ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent` (M1-ECS-02; `laige-sim`). +- [Archetype SoA component storage](api/archetype.md) — archetypes as + ordered component sets with per-column SoA storage, + `World::get`/`addComponent`/`removeComponent`, the reserve + policy, and the 10k-entity churn baseline (M1-ECS-03; `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, diff --git a/docs/api/archetype.md b/docs/api/archetype.md new file mode 100644 index 0000000..601688a --- /dev/null +++ b/docs/api/archetype.md @@ -0,0 +1,222 @@ +# Archetype SoA component storage (`World::get`, `World::addComponent`, ...) + +Archetype SoA storage (M1-ECS-03; PRD §9.1, AGENTS PERF-003/004, +CORE-001, G-R4). Public header: +`src/laige-sim/include/laige/sim/archetype.h` (constants, `ArchetypeStats`, +layout contract) plus the `World` member templates in +`src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/archetype.cpp` (+ the slot tables in `entity.cpp`). +Unit suite: `ctest -R archetype` (`tests/laige-sim/archetype_tests.cpp`), +including the 10k-entity churn test whose machine-greppable stats line +lands in the ctest output on every run (CORE-001). + +An **archetype** is a *set of component types* an entity can carry, +stored as **Structure-of-Arrays**: one contiguous `T[]` column per +component type, one row per entity in the set. The entity→archetype +map is two dense per-slot tables (`archetypeOf_`, `rowOf_`), so +`get(e)` is **O(1)**: slot → archetype → column index → row, no +hash, no search (the roadmap's "archetype lookup + column index"). + +## Storage layout + +```text +per archetype (256 max, created on first sighting of a component set): + sig the component ids, strictly ascending (dense 1-based) + fingerprint FNV-1a 32-bit over sig (short-circuit only; a collision + is harmless — the lexicographic verify decides) + slotCol[r] the entity slot id of row r — rows are kept in + ascending slot-id order (a pure function of the world + state; M1-ECS-05 pins the convergence property) + columns[c] one byte[] block per component: rowCapacity * size bytes + (over-allocated 31 B), base aligned to 32 bytes + (kArchetypeColumnAlignment — covers every + trivially-copyable alignof on the P0 targets) + +per entity slot (entity.h): archetypeOf_[slot] (0 = no archetype) + +rowOf_[slot] — the dual representation; the two always agree +(invariant I2, maintained inside attachSlot/removeRow). +``` + +Rows are packed (no gaps): the tail is memmoved on every +insert/remove, so contiguity per column is a stored-data property, +not an assumption — the suite pins it (the `ArchetypeLayout` +property tests check element spacing, 32-byte alignment, and the +slot-ordered addresses). + +## The `World` component API + +| Operation | Behavior | Complexity / allocation | +|---|---|---| +| `world.has(e)` | Membership: is `T` in `e`'s set? Stale handle → `false` (pure query, no warn); unregistered `T` → `false` | O(1), no allocation | +| `world.get(e)` | `T*` into the column row, or `nullptr`: stale handle (warn-once via `check`), unregistered `T`, or `e` lacks `T` (silent — the two silent cases are documented, never an error) | O(1), no allocation | +| `world.addComponent(e, v)` | **Create-or-update**: `T` present → overwrite in place (no move); absent → move `e` to the set `current ∪ {T}`, copying the shared components into the new row. Stale handle → `InvalidArgument` + warn-once; unregistered `T` → `InvalidArgument` + warn; set would exceed 32 components → `BudgetExhausted` + warn | O(tail × row-stride) bytes moved (tail = rows at/above the insertion point); **no heap allocation** — growth is a pre-reserved, accounted, logged reserve (below) | +| `world.removeComponent(e)` | Remove `T`: no-op ok if absent; else move `e` to `current ∪ {T} \ {T}`, copying the remaining components into the new row. Same error rows as add | as add | +| `world.archetypeCount()` | Distinct component sets created so far (archetypes are never destroyed; empty sets stay) | O(1) | +| `world.archetypeStats()` | `ArchetypeStats` snapshot: `archetypeCount`, `rowsLive`, `rowsReserved`, `bytesReserved`, `totalAdds`, `totalRemoves`, `totalArchetypeGrowth`, `totalReservations` (the M1-PROF-01 / G-R4 feed) | O(256 × 32) cold pass, no allocation | + +The `World::destroy(e)` / `World::clear()` cost note now includes the +row detach: an entity with components leaves its archetype first — +O(tail × row-stride) bytes moved, still no allocation (entity.md). + +## Reserve policy (no per-op allocation) + +Each column block is reserved, not sized, to the rows it holds: + +- **Initial:** `min(kInitialArchetypeRows = 16, world capacity)` rows, + at archetype creation. +- **Growth:** ×2, capped at the world capacity — one bounded + `unique_ptr` reallocation per column per growth, **accounted** in + `totalReservations` / `totalArchetypeGrowth` and logged + (`ecs/archetype_grow`). Bounded by `log2(capacity / 16) + 1` growth + events per archetype (≤ 10 for a 10k world). +- **Steady state:** an add/remove that does not hit a full archetype + allocates nothing — the churn test proves it: zero reservation + delta over the window **and** zero process-wide allocations + (test-only `operator new` counter, non-sanitizer trees; the + sanitizer trees prove the same property with a leak-free run of the + same loop — M1-ALLOC-01 lands the standing assertion). + +## Budgets + +| Constant | Value | Meaning | Error when exceeded | +|---|---|---|---| +| `kMaxArchetypes` | 256 | distinct component sets per world | `BudgetExhausted` + warn `ecs/archetype_budget` | +| `kMaxArchetypeComponents` | 32 | components on one entity (one row) | `BudgetExhausted` + warn `ecs/component_limit` | +| `kMaxComponentTypes` | 256 | registered types per world (M1-ECS-02) | `BudgetExhausted` | + +The 256/32 bounds are M1 engine-level caps (documented here and in +archetype.h); raising either is an ADR (the zone workloads — PRD +§12.1: 200–2000 entities, small component palettes — are far inside +them). + +## Logging (LOG-001/002/004) + +Stable subsystem `ecs`, stable events: + +| Event | Severity | When | +|---|---|---| +| `archetype_created` | Info | a component set is seen for the first time (`archetype_id`, `components`) | +| `archetype_grow` | Info | a column reserve doubles (`archetype_id`, `rows`, `components`) | +| `archetype_budget` | Warn | 257th distinct set (rate-limited, `archetype_count`) | +| `component_limit` | Warn | 33rd component on one entity (rate-limited) | +| `component_unregistered` | Warn | add/remove of a type not registered in this world (rate-limited) | +| `stale_entity_access` | Warn | component op on a stale handle (rate-limited; inherited from M1-ECS-01) | + +Repeats are rate-limited per (subsystem, event, severity) with the +suppressed-count summary on shutdown (LOG-004) — the suite pins the +warn-once + `rate_limited` drain. + +## Performance (DOC-004) + +- **Hot path:** `has` / `get` are O(1) pointer arithmetic + (slot table → record → column binary search ≤ 32 → row). **No + allocation, no lock, no I/O, no logging** on any success path + (PERF-003; LOG-003). +- **Move cost:** add/remove between archetypes memmove the tail — + O(tail × row-stride) bytes plus the O(tail) `rowOf_` re-sync. + This is the documented cost of the dense packed rows (PERF-004: + contiguous over pointer-per-entity); it is a *spawn/despawn-time* + cost, not a per-tick one (iteration — the per-tick path — lands in + M1-ECS-04/05 over the same columns). +- **Measured baseline (CORE-001; g++ 16.2.1, 2026-09, single-threaded + headless):** 10k entities × 20k add/remove ops (seeded random + order, full cost range), 3-component working set: + + | Build | p50 | p99 | p99/p50 | + |---|---|---|---| + | Debug (`-O0`) | 0.123 ms | 0.243 ms | 1.98 | + | Release (`-O2`) | 0.0021 ms | 0.0040 ms | 1.94 | + + The suite asserts the flatness (`p99 < 3 × p50`), the zero + reservation delta, and the zero-allocation window, and prints the + machine-greppable line (`archetype-churn `) to the ctest + output on every run — the M1 baseline record for the G-R4 feed. + Numbers are machine-dependent; the *shape* (flat, no spike, no + allocation) is the tested property. +- **Memory per entity (live, with components):** one row per archetype + column — `Σ component sizes` bytes (8 B for a pos+vel pair) plus the + 2 B slot column slot; per-slot bookkeeping is 11 B (entity.md). + Reserved (not live) bytes are accounted in `bytesReserved`. +- **Traps:** + - The move cost is proportional to the row *above* the insertion + point: adding a component to a high-slot entity in a large + archetype is the expensive case (the churn window spans exactly + this range). Batch spawn/despawn when possible (API-002); the + per-tick iteration never moves rows. + - Archetypes are never destroyed: a workload that churns through + many component *sets* fills the 256-set budget even though few + entities are live — keep the per-world component-set count + bounded by design (game palettes are small; the warn names it). + - `get` returns a raw pointer valid until the entity's next + add/remove/destroy (the row can move) — never hold it across a + mutating op. + +## Threading and determinism + +- **Single owner thread** (CONC-001); not thread-safe (M1 is + single-threaded simulation — PRD §10.2). +- **Determinism (ARCH-010):** the row order is a pure function of the + world state (ascending slot id); the type→id lookup is a + deterministic splitmix64 hash with linear probing that is *only + ever queried* (never iterated), and the archetype scan is a + lexicographic verify — no platform intrinsics, no floating point. + The same operation sequence produces bit-identical layouts and + address sequences on every platform; two worlds that reach the + same state through different interleavings share the layout + (the suite's convergence test). + +## Usage (performant pattern) + +```cpp +// Setup (once): register every component type the world will carry. +ASSERT(world.registerComponent().ok()); +ASSERT(world.registerComponent().ok()); + +auto e = world.create(); +if (e.isError()) { /* BudgetExhausted: refuse the spawn (S-2) */ } + +// Spawn: create-or-update (an existing component is overwritten, not duplicated). +world.addComponent(e.value(), PlayerPos{1, 2}); +world.addComponent(e.value(), PlayerVel{9}); + +// Read (per tick, via the M1-ECS-04 query API once it lands): +const PlayerPos* p = world.get(e.value()); // O(1), nullptr on absence +if (p != nullptr) { /* use p — valid until the entity's next mutation */ } + +// Despawn: +world.removeComponent(e.value()); // back to {PlayerPos} +world.removeComponent(e.value()); // component-less, still alive +``` + +## Misuse warnings + +- `get` returning `nullptr` is **not always** an error: unregistered + `T` and absent `T` are silent by contract (a stale handle is the + one case that warns). Branch on it; don't log every miss. +- Holding a `get` pointer across `addComponent`/`removeComponent` + on the same entity is dangling (the row moves). Re-fetch. +- `addComponent` is create-or-update: passing a new value for a + present component *replaces* it — no duplication, no error. +- Adding components one-by-one through many intermediate sets walks + through (and leaves alive) intermediate archetypes; for a spawn + with N new components, N−1 moves are the cost. +- The 256-set / 32-component caps are hard engine bounds: a `Warn` + `ecs/archetype_budget` / `ecs/component_limit` means the game's + component design outgrew M1 — fix the design or ADR the bound. + +## Roadmap context + +- **M1-ECS-01 (done):** the entity handle and slot storage — + [entity.md](entity.md). +- **M1-ECS-02 (done):** the component registry that supplies the + ids and sizes this storage consumes — + [component_registry.md](component_registry.md). +- **M1-ECS-03 (this step):** the storage above. +- **M1-ECS-04:** the query/iteration API over these columns (no + iteration-legality state until M1-ECS-05). +- **M1-ECS-05:** deterministic iteration over the stored row order + (the slot-ordered scheme above is what it iterates). +- **M1-ECS-06:** the G-R3 warn thresholds read the same slot tables. +- **M1-PROF-01 / G-R4:** `archetypeStats()` feeds the profiler. +- **M1-ALLOC-01:** the standing zero-allocation assertion over the + churn property this step measured. diff --git a/docs/api/component_registry.md b/docs/api/component_registry.md index 904229f..9e73ebe 100644 --- a/docs/api/component_registry.md +++ b/docs/api/component_registry.md @@ -171,9 +171,9 @@ if (pos.isError()) { // M1-SYS-01 declares system I/O by these ids, and M1-DET-02's replay // header hashes the component schema built from them. -// Later (M1-ECS-03): world.add_component(e, {...}) and +// M1-ECS-03 (done): world.addComponent(e, {...}) and // world.get(e) resolve T -> id -> SoA column through this -// registry; O(1), no allocation. +// registry; O(1), no allocation (docs/api/archetype.md). ``` ## Misuse warnings @@ -194,9 +194,9 @@ if (pos.isError()) { - **M1-ECS-01 (done):** the entity handle and world entity storage this registry hangs off — see [entity.md](entity.md). - **M1-ECS-02 (this step):** the component registry above. -- **M1-ECS-03:** archetype SoA storage consumes the recorded - size/alignment (`world.add_component`/`world.get`, O(1) column - lookup). +- **M1-ECS-03 (done):** archetype SoA storage consumes the recorded + size/alignment (`world.addComponent`/`world.get`, O(1) column + lookup) — see [archetype.md](archetype.md). - **M1-ECS-05:** deterministic iteration orders the component sets by registration order (`operator<`). - **M1-SYS-01:** system I/O declarations reference these ids. diff --git a/docs/api/entity.md b/docs/api/entity.md index 6e687d8..7586bd3 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -48,16 +48,24 @@ worlds passes `isValid()` in both (the same cross-pool caveat as |---|---|---| | `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 | +| `destroy(e)` | Destroy one live entity; if it is in an archetype its row is detached first (M1-ECS-03); bumps the slot generation; returns the slot to the free list. Stale/invalid → debug: **assert** (S-9); release: `InvalidArgument` + warn-once | O(1) for a component-less entity; O(tail × row-stride) when it has components — 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 | +| `clear()` | Destroy every live entity (shutdown path, CONC-006); each live row is detached (M1-ECS-03); every handle goes stale; capacity unchanged; world immediately reusable | O(capacity) scan + detaches, 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. +Move-only (O(1) pointer swap — the archetype tables move with it, so +a moved world keeps its component data); a moved-from world is a +valid empty world (capacity 0: every `create()` fails, every handle +invalid). Not copyable. + +M1-ECS-03 adds the component layer on the same slot tables: +`world.has(e)`, `world.get(e)`, `world.addComponent(e, v)`, +`world.removeComponent(e)`, `world.archetypeCount()`, +`world.archetypeStats()` — see +[archetype.md](archetype.md) (the stale-handle contract above is +inherited by all four component ops). ## Errors (FR-12.1, CORE-008) @@ -93,10 +101,11 @@ stale handle assert in debug and degrade in release. 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). +- **Memory per slot:** 11 B bookkeeping (2 B generation + 1 B alive + flag + 2 B free-list entry + 2 B archetype slot + 4 B row index — + M1-ECS-03). `EntityStats` reports `capacity × 11` / `inUse × 11` + bytes; the archetype column blocks are accounted separately in + `ArchetypeStats` (archetype.md). - **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). @@ -163,9 +172,10 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } - **M1-ECS-02 (done):** the component registry — `ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent`; see [component_registry.md](component_registry.md). -- **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-03 (done):** 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 — see + [archetype.md](archetype.md). - **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 diff --git a/laige-api.json b/laige-api.json index 1f5ea04..18f045c 100644 --- a/laige-api.json +++ b/laige-api.json @@ -12,6 +12,7 @@ "src/laige-core/include/laige/prng.h", "src/laige-core/include/laige/result.h", "src/laige-core/include/laige/sim_math.h", + "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/entity.h" ], @@ -392,6 +393,22 @@ {"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::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, + {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, + {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, + {"name": "laige::kArchetypeColumnAlignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 200, "signature": "inline constexpr std::uint32_t kArchetypeColumnAlignment = 32", "summary": "Column blocks are aligned to this many bytes: the P0 targets' maximum fundamental alignment (x86-64/arm64), so every trivially copyable component type's alignof fits (registerComponent static- asserts alignof(T) <= kArchetypeColumnAlignment).", "budget": null, "experimental": false}, + {"name": "laige::kArchetypeColumnPad", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 202, "signature": "inline constexpr std::size_t kArchetypeColumnPad = kArchetypeColumnAlignment - 1", "summary": "Over-allocation that keeps the aligned base inside the raw block.", "budget": null, "experimental": false}, + {"name": "laige::kInvalidColumnIndex", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 207, "signature": "inline constexpr std::uint32_t kInvalidColumnIndex = 0xFFFFFFFFu", "summary": "The \"column absent\" sentinel for columnIndexOf results (API-008: call sites never spell raw 0xFFFFFFFF).", "budget": null, "experimental": false}, + {"name": "laige::kInvalidRowIndex", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 212, "signature": "inline constexpr std::uint32_t kInvalidRowIndex = 0xFFFFFFFFu", "summary": "The \"row absent\" sentinel for attachSlot failure results. Rows are 0-based and row 0 is a valid row, so 0 cannot be the failure value (API-008: call sites never spell raw 0xFFFFFFFF).", "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 234, "signature": "struct ArchetypeStats", "summary": "One archetype's storage accounting snapshot (PRD §10.4, FR-11.4, G-R4 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the churn/overflow checks pull:", "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::archetypeCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 235, "signature": "std::uint32_t archetypeCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::rowsLive", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 236, "signature": "std::uint32_t rowsLive{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::rowsReserved", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 237, "signature": "std::uint32_t rowsReserved{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::bytesReserved", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 238, "signature": "std::uint64_t bytesReserved{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::totalAdds", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 239, "signature": "std::uint64_t totalAdds{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::totalRemoves", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 240, "signature": "std::uint64_t totalRemoves{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::totalArchetypeGrowth", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 241, "signature": "std::uint64_t totalArchetypeGrowth{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArchetypeStats::totalReservations", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 242, "signature": "std::uint64_t totalReservations{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::ComponentTypeId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/component.h", "line": 119, "signature": "struct ComponentTypeId", "summary": "The stable per-world component type id (FR-1.2). See the header preamble for the assignment and determinism contracts.", "budget": null, "experimental": false}, {"name": "laige::ComponentTypeId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 120, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kInvalidComponentTypeId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 125, "signature": "inline constexpr ComponentTypeId kInvalidComponentTypeId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, @@ -403,39 +420,45 @@ {"name": "laige::ComponentInfo::alignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 144, "signature": "std::uint32_t alignment{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxComponentTypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 149, "signature": "inline constexpr std::uint32_t kMaxComponentTypes = 256", "summary": "The engine-level cap on component types per world (CORE-005). See the preamble for the rationale and the ADR path to raise it.", "budget": null, "experimental": false}, {"name": "LAIGE_COMPONENT", "kind": "macro", "header": "src/laige-sim/include/laige/sim/component.h", "line": 196, "signature": "#define LAIGE_COMPONENT(Type)", "summary": "Mark T as a Laige component (FR-1.2; S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 130, "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": 131, "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": 132, "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": 134, "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": 135, "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": 143, "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": 146, "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": 160, "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": 161, "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": 162, "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": 163, "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": 164, "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": 165, "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": 166, "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": 173, "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": 180, "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": 181, "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": 187, "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": 192, "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": 199, "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": 206, "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": 209, "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": 212, "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": 216, "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": 220, "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::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 241, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 246, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 252, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 259, "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": 263, "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": 264, "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": 265, "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": 266, "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": 270, "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} + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 133, "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": 134, "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": 135, "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": 137, "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": 138, "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": 146, "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": 149, "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": 163, "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": 164, "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": 165, "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": 166, "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": 167, "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": 168, "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": 169, "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": 176, "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": 183, "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": 184, "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": 190, "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": 195, "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": 205, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). 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": 212, "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": 215, "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": 218, "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": 222, "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": 226, "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::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 247, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 252, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 258, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 269, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, + {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 279, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, + {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 296, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, + {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 304, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 310, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 315, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 324, "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 + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive.", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 328, "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": 329, "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": 330, "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": 331, "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": 336, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index cad1c1e..1975744 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -38,7 +38,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R component_registry` green. - **Size:** ~150 lines + tests -- [ ] **M1-ECS-03 · Archetype SoA storage** +- [x] **M1-ECS-03 · Archetype SoA storage** - **Refs:** FR-1.2 (archetype/SoA, pool-backed add/remove); AGENTS PERF-003, PERF-004 - **Depends:** M1-ECS-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 76a5c76..7235d76 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 | 2 | 🚧 in progress (M1-ECS-02) | +| M1 | 25 | 3 | 🚧 in progress (M1-ECS-03) | | 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** | **24** | | +| **Total** | **193** | **25** | | --- @@ -197,6 +197,7 @@ One line per completed (or split/renumbered) step. | 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()` 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 | — | 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`/`has` O(1) (slot → record → column binary search ≤ 32 → row); `addComponent` create-or-update (in-place overwrite when present) and `removeComponent` 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 ` 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 | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index b4f5bac..6008856 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -14,10 +14,16 @@ # loop, systems, and the rest of the ECS land in the remaining M1-ECS / # M1-SYS / M1-LOOP steps. +# M1-ECS-03 (roadmap/M1-heartbeat.md): archetype.cpp adds the archetype +# SoA storage helpers (find/create/grow/attach/detach + the type-key +# index); entity.cpp keeps the handle/slot storage and the World +# lifecycle. +set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp) + if(LAIGE_BUILD_SHARED) - add_library(laige-sim SHARED entity.cpp) + add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) else() - add_library(laige-sim STATIC entity.cpp) + add_library(laige-sim STATIC ${LAIGE_SIM_SOURCES}) endif() laige_apply_engine_policy(laige-sim) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 70dd197..608e603 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -13,6 +13,12 @@ M1-ECS-02 landed the component type registry — `ComponentTypeId`, the (`include/laige/sim/component.h`; API contract in [docs/api/component_registry.md](../docs/api/component_registry.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry -`component_registry`). The archetype storage, iteration, and the game -loop land in the remaining M1-ECS / M1-SYS / M1-LOOP steps; physics, -input, and animation in M3. +`component_registry`). M1-ECS-03 landed the archetype SoA component +storage — archetypes as ordered component sets with per-column SoA +arrays, `World::get`/`addComponent`/`removeComponent`, the +bounded reserve policy, and the 10k-entity zero-alloc churn baseline +(`include/laige/sim/archetype.h`, `archetype.cpp`; API contract in +[docs/api/archetype.md](../docs/api/archetype.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `archetype`). +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/archetype.cpp b/src/laige-sim/archetype.cpp new file mode 100644 index 0000000..1b1941d --- /dev/null +++ b/src/laige-sim/archetype.cpp @@ -0,0 +1,330 @@ +// laige-sim World archetype SoA storage (M1-ECS-03). +// +// Implementation of the archetype helpers declared in +// include/laige/sim/entity.h — see that header and +// include/laige/sim/archetype.h for the full contracts (layout, +// reserve policy, budgets, complexity, determinism, failure table). + +#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"; + +// FNV-1a 32-bit parameters (CORE-005; the standard FNV-1a constants — +// fnv.org). The fingerprint only short-circuits the signature scan; +// correctness always rests on the lexicographic verify, so a +// fingerprint collision is harmless. +inline constexpr std::uint32_t kFnvBasis = 2166136261u; +inline constexpr std::uint32_t kFnvPrime = 16777619u; + +// The FNV-1a 32-bit fingerprint of a sorted signature. +inline std::uint32_t sigFingerprint(const std::uint32_t* sig, + std::uint16_t count) noexcept { + std::uint32_t h = kFnvBasis; + for (std::uint16_t i = 0; i < count; ++i) { + h ^= sig[i]; + h *= kFnvPrime; + } + return h; +} + +// splitmix64 (Seiler, 2018; the canonical 64-bit integer mixer, +// splitmix13.org): a deterministic bijection used to hash the type-key +// address value into the key-index slot. The input value differs per +// process (ASLR) — that is fine: the table is never iterated and +// correctness rests on key equality only (componentIdOfKey contract). +inline std::uint64_t splitmix64(std::uint64_t x) noexcept { + x += 0x9E3779B97F4A7C15ull; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ull; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBull; + return x ^ (x >> 31); +} + +// Align `raw` up to kArchetypeColumnAlignment (the raw block is +// over-allocated by kArchetypeColumnPad, so the aligned address always +// stays inside it). +inline std::byte* alignBlock(std::byte* raw) noexcept { + const std::uintptr_t addr = reinterpret_cast(raw); + const std::uintptr_t mask = kArchetypeColumnAlignment - 1; + const std::uintptr_t aligned = (addr + mask) & ~mask; + return raw + static_cast(aligned - addr); +} + +} // namespace + +std::uint32_t World::componentIdOfKey(const void* key) const noexcept { + // Open-addressing probe: deterministic splitmix64 slot + linear + // probing until an empty slot (absent) or the exact key (present). + // Never iterated — lookup only (PERF-006; the PRD §10.3 "deterministic + // hash + fixed iteration, or banned" allowance, fixed form). + std::uint32_t slot = static_cast( + splitmix64(reinterpret_cast(key)) & + (detail::kComponentKeyIndexSize - 1)); + for (std::uint32_t probe = 0; probe < detail::kComponentKeyIndexSize; + ++probe) { + const detail::ComponentKeySlot& entry = keyIndex_[slot]; + if (entry.key == nullptr) return 0; // empty slot: absent + if (entry.key == key) return entry.id; + slot = (slot + 1) & (detail::kComponentKeyIndexSize - 1); + } + return 0; // unreachable: load factor <= 1/2 +} + +void World::noteComponentKey(const void* key, std::uint32_t id) noexcept { + // Setup path only (registerComponent): insert at the first empty + // slot on the probe path. A duplicate key cannot reach here — + // duplicate registration is an error (M1-ECS-02). + std::uint32_t slot = static_cast( + splitmix64(reinterpret_cast(key)) & + (detail::kComponentKeyIndexSize - 1)); + for (std::uint32_t probe = 0; probe < detail::kComponentKeyIndexSize; + ++probe) { + if (keyIndex_[slot].key == nullptr) { + keyIndex_[slot] = detail::ComponentKeySlot{key, id}; + return; + } + slot = (slot + 1) & (detail::kComponentKeyIndexSize - 1); + } + // Unreachable: load factor <= 1/2. +} + +detail::ArchetypeRecord* World::findArchetype(const std::uint32_t* sig, + std::uint16_t count) noexcept { + const std::uint32_t fp = sigFingerprint(sig, count); + // Bounded scan over the created archetypes (id order — creation + // order; O(kMaxArchetypes), no allocation, PERF-007 documented). + for (std::uint32_t i = 0; i < archetypeCount_; ++i) { + const detail::ArchetypeRecord& rec = archetypes_[i]; + if (rec.fingerprint != fp || rec.sigCount != count) continue; + bool equal = true; + for (std::uint16_t j = 0; j < count; ++j) { + if (rec.sig[j] != sig[j]) { + equal = false; + break; + } + } + if (equal) return const_cast(&rec); + } + return nullptr; +} + +detail::ArchetypeRecord* World::createArchetype(const std::uint32_t* sig, + std::uint16_t count) noexcept { + // The archetype table is pre-allocated (kMaxArchetypes records, + // value-initialized) at World::create — this is the first creation + // of a component set: assign the next id (creation order — + // deterministic for a fixed operation sequence, ARCH-010). + detail::ArchetypeRecord& rec = archetypes_[archetypeCount_]; + for (std::uint16_t i = 0; i < count; ++i) rec.sig[i] = sig[i]; + for (std::uint16_t i = count; i < kMaxArchetypeComponents; ++i) { + rec.sig[i] = 0; // keep the 0-termination explicit + } + rec.sigCount = count; + rec.fingerprint = sigFingerprint(sig, count); + // The reserve policy (archetype.h): start at kInitialArchetypeRows, + // or the world capacity when smaller (a small world never outgrows + // the initial reserve — growth would be a no-op there). + rec.rowCapacity = + capacity_ < kInitialArchetypeRows ? capacity_ : kInitialArchetypeRows; + if (rec.rowCapacity == 0) rec.rowCapacity = 1; // defensive: no live entity + rec.size = 0; + rec.slotCol = std::make_unique(rec.rowCapacity); + rec.columns = std::make_unique(count); + for (std::uint16_t i = 0; i < count; ++i) { + // Column geometry comes from the registry record (M1-ECS-02): + // packed rows of sizeof(T), aligned to alignof(T) — which the + // registerComponent static_assert bounded to <= kArchetypeColumnAlignment. + const detail::ComponentRecord& comp = components_[sig[i] - 1]; + detail::ArchetypeColumn& column = rec.columns[i]; + column.block = std::make_unique( + static_cast(rec.rowCapacity) * comp.size + + kArchetypeColumnPad); + column.base = alignBlock(column.block.get()); + column.size = comp.size; + } + ++archetypeCount_; + totalReservations_ += 1 + count; // slot column + one block per component + LAIGE_LOG_INFO(kEcsSubsystem, "archetype_created", + "New archetype for a component set seen for the first time", + laige::log::field("archetype_id", archetypeCount_), + laige::log::field("components", count)); + return &rec; +} + +std::uint32_t World::columnIndexOf(const detail::ArchetypeRecord& arch, + std::uint32_t componentId) const noexcept { + // Binary search over the sorted signature (component ids are + // registration-order dense, so sorted = registration order within + // the set). O(log kMaxArchetypeComponents); no allocation. + std::uint32_t lo = 0, hi = arch.sigCount; + while (lo < hi) { + const std::uint32_t mid = lo + (hi - lo) / 2; + if (arch.sig[mid] < componentId) { + lo = mid + 1; + } else if (arch.sig[mid] > componentId) { + hi = mid; + } else { + return mid; + } + } + return kInvalidColumnIndex; +} + +bool World::growArchetype(detail::ArchetypeRecord& arch, + std::uint32_t archetypeId) noexcept { + // The reserve policy (archetype.h): double the row capacity, capped + // at the world's entity capacity — one bounded reservation per + // column, then move the live rows. Bounded per archetype by + // log2(worldCapacity / kInitialArchetypeRows) + 1 growth events. + const std::uint32_t newCapacity = + std::min(capacity_, arch.rowCapacity * 2); + if (newCapacity <= arch.rowCapacity) { + // The archetype already spans the whole world: no further row can + // exist (every live entity would have to leave it to free one — + // unreachable for the caller's add). The caller degrades to + // BudgetExhausted. + return false; + } + // All new blocks are reserved before any old block is released, so a + // failure mid-growth cannot corrupt live rows (allocation failure + // itself terminates under the no-exceptions policy, like every other + // engine allocation). + auto newSlotCol = std::make_unique(newCapacity); + for (std::uint16_t i = 0; i < arch.sigCount; ++i) { + detail::ArchetypeColumn& column = arch.columns[i]; + auto block = std::make_unique( + static_cast(newCapacity) * column.size + + kArchetypeColumnPad); + std::byte* base = alignBlock(block.get()); + std::memcpy(base, column.base, + static_cast(arch.size) * column.size); + column.block = std::move(block); // releases the old block + column.base = base; + } + std::memcpy(newSlotCol.get(), arch.slotCol.get(), + static_cast(arch.size) * sizeof(std::uint16_t)); + arch.slotCol = std::move(newSlotCol); + arch.rowCapacity = newCapacity; + ++totalArchetypeGrowth_; + totalReservations_ += 1 + arch.sigCount; + LAIGE_LOG_INFO(kEcsSubsystem, "archetype_grow", + "Archetype row capacity doubled (bounded reservation)", + laige::log::field("archetype_id", archetypeId), + laige::log::field("rows", newCapacity), + laige::log::field("components", arch.sigCount)); + return true; +} + +void World::removeRow(detail::ArchetypeRecord& arch, std::uint32_t row) noexcept { + // Shift the tail left by one row: the slot column (2 B/row) plus one + // pass per live component column (size B/row) — (size - row - 1) rows + // move — then re-sync the rowOf_ records of the shifted rows (the + // dual representation of the slot column — the rowOf_ table must + // agree with slotCol, invariant I2 below). + // O((size - row) * (row-stride + 2 B)) moved + O(size - row) re-sync; + // no allocation. + // Components are trivially copyable, so the vacated row's bytes need + // no destruction. + const std::size_t tailRows = static_cast(arch.size - row - 1); + if (tailRows > 0) { + std::memmove(arch.slotCol.get() + row, arch.slotCol.get() + row + 1, + tailRows * sizeof(std::uint16_t)); + for (std::uint16_t i = 0; i < arch.sigCount; ++i) { + detail::ArchetypeColumn& column = arch.columns[i]; + std::memmove(column.base + static_cast(row) * column.size, + column.base + static_cast(row + 1) * column.size, + tailRows * column.size); + } + } + --arch.size; + for (std::uint32_t r = row; r < arch.size; ++r) { + rowOf_[arch.slotCol[r]] = r; // the shifted rows' records follow + } +} + +std::uint32_t World::attachSlot(std::uint32_t slot, + detail::ArchetypeRecord& arch) noexcept { + const std::uint32_t archId = static_cast( + std::distance(archetypes_.get(), &arch)) + 1; + if (arch.size == arch.rowCapacity && !growArchetype(arch, archId)) { + // The at-world-capacity edge (caller: BudgetExhausted). kInvalidRowIndex + // (not 0): row 0 is a valid row index. + return kInvalidRowIndex; + } + // Slot-ordered insertion position: the first row with a greater slot + // id (the slot is absent from this archetype — a slot lives in + // exactly one archetype, and this one is the destination). + // O(log size) binary search over the strictly ascending slot column. + std::uint32_t lo = 0, hi = arch.size; + while (lo < hi) { + const std::uint32_t mid = lo + (hi - lo) / 2; + if (arch.slotCol[mid] <= slot) { + lo = mid + 1; + } else { + hi = mid; + } + } + const std::uint32_t row = lo; + // Shift the tail RIGHT by one row: the existing rows [row, size-1] + // (size - row of them) move to [row+1, size] — the mirror of + // removeRow's left shift: memmove from the old position (row) to the + // new one (row+1); memmove handles the overlap. 0 when appending at + // the end (row == size). + const std::size_t tailRows = static_cast(arch.size - row); + if (tailRows > 0) { + std::memmove(arch.slotCol.get() + row + 1, arch.slotCol.get() + row, + tailRows * sizeof(std::uint16_t)); + for (std::uint16_t i = 0; i < arch.sigCount; ++i) { + detail::ArchetypeColumn& column = arch.columns[i]; + std::memmove(column.base + static_cast(row + 1) * column.size, + column.base + static_cast(row) * column.size, + tailRows * column.size); + } + } + arch.slotCol[row] = static_cast(slot); + ++arch.size; + for (std::uint32_t r = row; r < arch.size; ++r) { + rowOf_[arch.slotCol[r]] = r; // covers the new row and the tail + } + archetypeOf_[slot] = static_cast(archId); + return row; +} + +std::uint32_t World::archetypeCount() const noexcept { return archetypeCount_; } + +ArchetypeStats World::archetypeStats() const noexcept { + ArchetypeStats s{}; + s.archetypeCount = archetypeCount_; + // Bounded cold pass (O(kMaxArchetypes * kMaxArchetypeComponents)); + // no allocation. bytesReserved counts reserved row bytes: the slot + // column (2 B/row) plus each component column (size B/row) — + // alignment padding is excluded (it is over-allocation, not rows). + for (std::uint32_t i = 0; i < archetypeCount_; ++i) { + const detail::ArchetypeRecord& rec = archetypes_[i]; + s.rowsLive += rec.size; + s.rowsReserved += rec.rowCapacity; + std::uint64_t stride = sizeof(std::uint16_t); + for (std::uint16_t c = 0; c < rec.sigCount; ++c) { + stride += rec.columns[c].size; + } + s.bytesReserved += static_cast(rec.rowCapacity) * stride; + } + s.totalAdds = totalAdds_; + s.totalRemoves = totalRemoves_; + s.totalArchetypeGrowth = totalArchetypeGrowth_; + s.totalReservations = totalReservations_; + return s; +} + +} // namespace laige diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index b042efb..e4d3cb0 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -22,9 +22,11 @@ namespace { 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; +// (1 B) + free-list entry (2 B) + the M1-ECS-03 per-entity record +// (archetypeOf_ 2 B + rowOf_ 4 B). The archetype table and the SoA +// column blocks are not per-slot bytes — they are accounted separately +// in ArchetypeStats (archetype.h). +inline constexpr std::size_t kBytesPerSlot = 11; } // namespace @@ -40,13 +42,26 @@ World::World(World&& other) noexcept freeCount_(other.freeCount_), inUse_(other.inUse_), peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_), components_(std::move(other.components_)), - componentCount_(other.componentCount_) { + componentCount_(other.componentCount_), + archetypeOf_(std::move(other.archetypeOf_)), + rowOf_(std::move(other.rowOf_)), + archetypes_(std::move(other.archetypes_)), + archetypeCount_(other.archetypeCount_), + keyIndex_(std::move(other.keyIndex_)), + totalAdds_(other.totalAdds_), totalRemoves_(other.totalRemoves_), + totalArchetypeGrowth_(other.totalArchetypeGrowth_), + totalReservations_(other.totalReservations_) { other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; other.peakInUse_ = 0; other.totalCreated_ = 0; other.componentCount_ = 0; + other.archetypeCount_ = 0; + other.totalAdds_ = 0; + other.totalRemoves_ = 0; + other.totalArchetypeGrowth_ = 0; + other.totalReservations_ = 0; } World& World::operator=(World&& other) noexcept { @@ -62,12 +77,26 @@ World& World::operator=(World&& other) noexcept { totalCreated_ = other.totalCreated_; components_ = std::move(other.components_); componentCount_ = other.componentCount_; + archetypeOf_ = std::move(other.archetypeOf_); + rowOf_ = std::move(other.rowOf_); + archetypes_ = std::move(other.archetypes_); + archetypeCount_ = other.archetypeCount_; + keyIndex_ = std::move(other.keyIndex_); + totalAdds_ = other.totalAdds_; + totalRemoves_ = other.totalRemoves_; + totalArchetypeGrowth_ = other.totalArchetypeGrowth_; + totalReservations_ = other.totalReservations_; other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; other.peakInUse_ = 0; other.totalCreated_ = 0; other.componentCount_ = 0; + other.archetypeCount_ = 0; + other.totalAdds_ = 0; + other.totalRemoves_ = 0; + other.totalArchetypeGrowth_ = 0; + other.totalReservations_ = 0; return *this; } @@ -83,13 +112,26 @@ Result World::create(Options options) noexcept { // budget (kMaxComponentTypes), a setup-path allocation like the // entity tables below. w.components_ = std::make_unique(kMaxComponentTypes); + // Archetype storage (M1-ECS-03): the fixed archetype table + // (kMaxArchetypes records, value-initialized) and the type-key + // index (kComponentKeyIndexSize slots) — setup-path allocations, + // allocated even for a zero-capacity world so a moved-from / + // zero-capacity world stays a valid empty world with working + // registry behavior. + w.archetypes_ = std::make_unique(kMaxArchetypes); + w.keyIndex_ = + std::make_unique(detail::kComponentKeyIndexSize); 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). + // flag, the LIFO free-list stack (pre-filled 0..capacity-1), and + // the M1-ECS-03 per-slot entity record (archetype membership + + // dense row; value-initialized: archetypeOf_ 0 = no archetype). w.generations_ = std::make_unique(w.capacity_); w.alive_ = std::make_unique(w.capacity_); w.freeStack_ = std::make_unique(w.capacity_); + w.archetypeOf_ = std::make_unique(w.capacity_); + w.rowOf_ = 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); @@ -103,6 +145,11 @@ Result World::create() noexcept { if (freeCount_ == 0) return ErrorCode::BudgetExhausted; const std::uint16_t slot = freeStack_[--freeCount_]; alive_[slot] = 1; + // A new entity carries no components: it is in no archetype. + // (Cleared-slot leftovers are overwritten here — a recycled slot + // always re-enters clean.) + archetypeOf_[slot] = 0; + rowOf_[slot] = 0; ++inUse_; if (inUse_ > peakInUse_) peakInUse_ = inUse_; ++totalCreated_; @@ -141,6 +188,15 @@ Status World::destroy(Entity entity) noexcept { laige::log::field("generation", entity.generation)); return ErrorCode::InvalidArgument; } + // M1-ECS-03: release the entity's component row first — its slot + // leaves the archetype (the row-stride move cost is documented in + // archetype.h; no allocation). + const std::uint32_t archIdx = archetypeOf_[entity.id]; + if (archIdx != 0) { + removeRow(archetypes_[archIdx - 1], rowOf_[entity.id]); + archetypeOf_[entity.id] = 0; + rowOf_[entity.id] = 0; + } alive_[entity.id] = 0; bumpGeneration(entity.id); freeStack_[freeCount_++] = entity.id; @@ -149,10 +205,17 @@ Status World::destroy(Entity entity) noexcept { } void World::clear() noexcept { - // No per-slot element data yet (M1-ECS-03 adds the per-entity - // record): clear is slot bookkeeping only. + // M1-ECS-03: every live entity is detached from its archetype first + // (its component row is released with its slot); the archetypes + // themselves and the component type registry survive (setup state). for (std::uint32_t i = 0; i < capacity_; ++i) { if (alive_[i] != 0) { + const std::uint32_t archIdx = archetypeOf_[i]; + if (archIdx != 0) { + removeRow(archetypes_[archIdx - 1], rowOf_[i]); + } + archetypeOf_[i] = 0; + rowOf_[i] = 0; alive_[i] = 0; bumpGeneration(static_cast(i)); freeStack_[freeCount_++] = static_cast(i); diff --git a/src/laige-sim/include/laige/sim/archetype.h b/src/laige-sim/include/laige/sim/archetype.h new file mode 100644 index 0000000..afd057a --- /dev/null +++ b/src/laige-sim/include/laige/sim/archetype.h @@ -0,0 +1,293 @@ +// laige-sim archetype SoA component storage (M1-ECS-03). +// +// PRD §9.1 S-1/S-2 and FR-1.2: the ECS core is archetype/SoA storage — +// dense entity iteration, O(1) component access, component add/remove +// through pool-backed (never per-operation heap) moves. This header +// ships the storage's public types and constants; the World methods +// that use them (has/get/addComponent/removeComponent/archetypeStats) +// are declared in entity.h and defined there (the M1-ECS-02 pattern: +// public types in the subsystem header, World methods in its home). +// +// --------------------------------------------------------------------------- +// Storage layout +// --------------------------------------------------------------------------- +// +// Archetype = an ordered set of component types: the component type +// ids sorted ascending (ids are registration-order dense +// from 1, so this is the canonical, unique order for the +// set). Every entity belongs to exactly one archetype — +// the one matching its component set — or to none (an +// entity with no components is in no archetype). +// +// SoA columns: each archetype carries one contiguous column per +// component — `rowCapacity` packed rows of `sizeof(T)` +// bytes aligned to alignof(T) — plus a slot column +// (`rowCapacity` entity slot ids). Row i of every column +// is one entity's data; rows are packed with no padding +// between elements (PERF-004: contiguous, compact). +// +// Row order (the dense-id-order scheme, pinned by M1-ECS-05): the +// rows of every archetype are kept in ascending entity +// slot-id order. The row layout is therefore a pure +// function of the world state (which slots are live and +// which archetype each is in) — never of the operation +// history that produced it. Worlds that converge on the +// same state iterate identically, including after +// component moves (the property test M1-ECS-05 pins). +// +// entity -> archetype map: the per-slot record — `archetypeOf[slot]` +// (0 = no archetype) and `rowOf[slot]` (the dense row) — +// directly indexed by the 16-bit slot id. No hash table +// is needed: a slot's archetype is one table load. +// (M1-ECS-05's "one internal hash structure" allowance +// is unused by design: direct indexing is strictly +// stronger — no hash, nothing unordered to iterate.) +// +// Storage invariants (maintained by attachSlot/removeRow; the test +// suite pins their observable consequences, and ASan covers the +// memory side): +// I1 archetypeOf_[s] == 0 <=> the slot's entity has no +// components; otherwise it names exactly one live archetype. +// I2 For every live row r of archetype a: slotCol_a[r] is a live +// slot, archetypeOf_[slotCol_a[r]] == a, and rowOf_[slotCol_a[r]] +// == r. The slot column is strictly ascending, so the row +// layout is the ascending slot-id order (the dense-id-order +// scheme above). +// I3 The component columns of every live row hold valid component +// values (written by the moving operation before the row +// becomes visible). +// I4 archetype.size == the number of live slots with +// archetypeOf_ == the archetype's id; rowsLive across all +// archetypes == the number of entities with components. +// +// --------------------------------------------------------------------------- +// Budgets and the reserve policy (CORE-005, PERF-003) +// --------------------------------------------------------------------------- +// +// kMaxArchetypes 256 distinct component sets per world. +// A game with more distinct component +// combinations exceeds the M1 bound — +// raise it through an ADR, not a knob. +// kMaxArchetypeComponents 32 components per entity (per +// archetype). A 33rd addComponent fails +// with BudgetExhausted. +// kInitialArchetypeRows 16 rows reserved when an archetype is +// created (or the world capacity, when +// smaller). +// +// Reserve policy: an archetype's columns are reserved for `rowCapacity` +// rows, and rowCapacity only ever grows, in bounded chunks: when a row +// must be attached to a full archetype, that archetype reserves exactly +// double its rows (capped at the world's entity capacity) in one +// reservation per column and moves its rows into the new blocks. The +// number of growth events over a world's lifetime is therefore bounded +// per archetype by log2(worldCapacity / kInitialArchetypeRows) + 1, and +// the total reserved rows across all archetypes stays within +// 2 × live rows + kMaxArchetypes × kInitialArchetypeRows (each live +// entity holds exactly one row). Growth is a bounded, accounted, logged +// event — never a per-operation allocation: steady-state add/remove +// moves only ever memcpy within already-reserved blocks (the M1 +// zero-allocation property; M1-ALLOC-01's assertion hooks these counters +// later, and ASan + archetypeStats() is the check until then). +// +// --------------------------------------------------------------------------- +// Allocation, complexity, determinism +// --------------------------------------------------------------------------- +// +// Construction (World::create) performs the storage's backing +// allocations: the per-slot record arrays (archetypeOf_, rowOf_), the +// archetype table (kMaxArchetypes records), and the type-key index +// (kComponentKeyIndexSize slots). First creation of an archetype and +// each growth event reserve column blocks (the only other allocations, +// both bounded and accounted — see the reserve policy). +// +// has(e) O(1) entity-count: a few loads plus the +// type-key lookup (O(1) open-addressing, never +// iterated) and the column binary search +// (O(log kMaxArchetypeComponents)). No warn, no +// side effects (a pure query, like isValid). +// get(e) O(1) as has plus one dereference. Stale +// handle: nullptr after the rate-limited +// warn-once of check() (every build). +// addComponent(e) O(1) in the entity count for the bookkeeping, +// O((tail rows) × row-stride) bytes moved when the +// entity changes archetype (the slot-ordered +// insertion shifts the tail right in every +// column); no allocation in steady state (growth +// events as above). +// removeComponent as addComponent, shifting the tail left. +// +// All bookkeeping is pure integer arithmetic over pre-reserved blocks +// (PERF-004/005: contiguous, compact, no pointer chasing beyond one +// level). Determinism (ARCH-010): the same operation sequence produces +// bit-identical archetype tables, row layouts, and counter values on +// every platform — no floating point, no randomness, no addresses, no +// unordered iteration anywhere in the storage. +// +// --------------------------------------------------------------------------- +// Errors (FR-12.1, CORE-008 — never silent) +// --------------------------------------------------------------------------- +// +// stale/invalid handle in add/remove/get +// -> ErrorCode::InvalidArgument + one +// rate-limited warn (ecs/stale_entity_ +// access, via check(); every build) +// T not registered in this world +// -> ErrorCode::InvalidArgument + warn +// (ecs/component_unregistered) +// the entity would exceed kMaxArchetypeComponents +// -> ErrorCode::BudgetExhausted + warn +// (ecs/component_limit) +// more than kMaxArchetypes distinct sets +// -> ErrorCode::BudgetExhausted + warn +// (ecs/archetype_budget) +// removeComponent when the entity lacks T +// -> ok Status, no-op (a normal state, not +// an error — despawn cleanup) +// addComponent when the entity has T +// -> ok Status, the value is overwritten +// in place (create-or-update; the +// archetype does not change) +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - get(e) returns nullptr for three different reasons (stale +// handle, T unregistered, entity lacks T): use has() to branch, +// and isValid(e) to tell "stale" from "lacks" when it matters. +// - addComponent/removeComponent are per-entity mutations (the spawn/ +// despawn path). Sustained per-frame churn is what the G-R4 +// guardrail (M1-ECS-06) warns about — batch entity lifecycle in a +// spawn/despawn system. +// - An archetype is never destroyed once created (M1 keeps them +// alive; empty archetypes stay until clear()/destruction of the +// world). The kMaxArchetypes budget therefore counts sets ever +// seen, not sets currently occupied. +// - A component's alignof must be <= kArchetypeColumnAlignment +// (32) — the column blocks are aligned to 32 bytes (the P0 targets' +// maximum fundamental alignment). registerComponent() +// static-asserts this (M1-ECS-03's SoA layout bound). + +#pragma once + +#include +#include +#include + +namespace laige { + +// The engine-level cap on distinct component sets (archetypes) per +// world (CORE-005: a named engine constant, the kMaxComponentTypes +// precedent — a game's component-set vocabulary is orders of magnitude +// smaller than its entity count; raising it is an ADR, not a knob). +inline constexpr std::uint32_t kMaxArchetypes = 256; + +// The engine-level cap on components per entity (per archetype) +// (CORE-005). A 33rd distinct component on one entity fails +// addComponent with BudgetExhausted. +inline constexpr std::uint32_t kMaxArchetypeComponents = 32; + +// Rows reserved when an archetype is created (or the world's entity +// capacity, when smaller). Growth doubles from here (see the header +// preamble, "Reserve policy"). +inline constexpr std::uint32_t kInitialArchetypeRows = 16; + +// Column blocks are aligned to this many bytes: the P0 targets' +// maximum fundamental alignment (x86-64/arm64), so every trivially +// copyable component type's alignof fits (registerComponent static- +// asserts alignof(T) <= kArchetypeColumnAlignment). +inline constexpr std::uint32_t kArchetypeColumnAlignment = 32; +// Over-allocation that keeps the aligned base inside the raw block. +inline constexpr std::size_t kArchetypeColumnPad = + kArchetypeColumnAlignment - 1; + +// The "column absent" sentinel for columnIndexOf results (API-008: +// call sites never spell raw 0xFFFFFFFF). +inline constexpr std::uint32_t kInvalidColumnIndex = 0xFFFFFFFFu; + +// The "row absent" sentinel for attachSlot failure results. Rows are +// 0-based and row 0 is a valid row, so 0 cannot be the failure value +// (API-008: call sites never spell raw 0xFFFFFFFF). +inline constexpr std::uint32_t kInvalidRowIndex = 0xFFFFFFFFu; + +// One archetype's storage accounting snapshot (PRD §10.4, FR-11.4, +// G-R4 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value +// the M1 profiler (M1-PROF-01) and the churn/overflow checks pull: +// +// archetypeCount archetypes created so far (0 .. kMaxArchetypes) +// rowsLive entities in an archetype right now +// rowsReserved rows reserved across all archetypes +// bytesReserved reserved row bytes (slot column + component +// columns; alignment padding not counted) +// totalAdds successful addComponent calls (including +// in-place overwrites; destroy/clear detaches +// are not counted) +// totalRemoves successful removeComponent calls that +// detached a row (no-op removes not counted; +// destroy/clear detaches not counted) +// totalArchetypeGrowth growth events (bounded per the reserve +// policy) +// totalReservations column block reservations since construction +// (archetype creation + growth) — the pool +// accounting the zero-allocation property reads +struct ArchetypeStats { + std::uint32_t archetypeCount{}; + std::uint32_t rowsLive{}; + std::uint32_t rowsReserved{}; + std::uint64_t bytesReserved{}; + std::uint64_t totalAdds{}; + std::uint64_t totalRemoves{}; + std::uint64_t totalArchetypeGrowth{}; + std::uint64_t totalReservations{}; +}; + +namespace detail { + +// One SoA column of an archetype: a packed array of `rowCapacity` rows +// of `size` bytes each, aligned to kArchetypeColumnAlignment. The raw +// block is over-allocated by kArchetypeColumnPad bytes so the aligned +// `base` always fits inside it (the raw block owns the storage; `base` +// is a non-owning view). Row i sits at `base + i * size`, packed with +// no inter-element padding (PERF-004). +struct ArchetypeColumn { + std::unique_ptr block; + std::byte* base; + std::uint32_t size; // bytes per row (the component's sizeof(T)) +}; + +// One archetype: the ordered component set (signature) plus the SoA +// columns. The World stores kMaxArchetypes of these, indexed by +// (archetype id - 1); an id is assigned when the set is first seen. +// `sig` is 0-terminated; ids are registration-order dense from 1, so +// 0 doubles as the terminator (no separate count is needed for the +// signature scan — sigCount is kept for O(1) reads). +struct ArchetypeRecord { + std::uint32_t sig[kMaxArchetypeComponents]; // sorted component ids + std::uint16_t sigCount{}; // number of sig entries + std::uint32_t fingerprint{}; // FNV-1a 32-bit over sig (short-circuit) + std::uint32_t rowCapacity{}; // reserved rows (the reserve policy) + std::uint32_t size{}; // live rows (== entities in this archetype) + std::unique_ptr slotCol; // row -> entity slot id (ascending) + std::unique_ptr columns; // sigCount columns, in sig order +}; + +// One slot of the per-world component type-key index: the open-addressing +// table that maps a component type's identity token +// (&ComponentTypeKey::kMarker) to this world's ComponentTypeId. See +// the World::componentIdOfKey contract in entity.h for the lookup +// semantics (deterministic splitmix64 hash of the address value + linear +// probing; correctness rests on key equality only — the table is never +// iterated, so its per-process layout is not observable). +struct ComponentKeySlot { + const void* key{}; // nullptr marks an empty slot + std::uint32_t id{}; // this world's ComponentTypeId +}; + +// The type-key index size: a power of two keeping the load factor at or +// below 1/2 for kMaxComponentTypes entries (CORE-005). +inline constexpr std::uint32_t kComponentKeyIndexSize = 512; + +} // namespace detail + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index d005c84..aaaebb5 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -15,8 +15,9 @@ // no per-operation heap (PERF-002/003, S-2; M0-CORE-05 // Pool precedent). M1-ECS-02 adds the component type // registry (registerComponent, component.h); M1-ECS-03 -// adds the per-entity component storage on top of this -// slot table. +// adds the archetype SoA component storage on top of this +// slot table (archetype.h: has/get/addComponent/ +// removeComponent + the per-slot archetype record). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -114,12 +115,14 @@ #include #include +#include #include #include "laige/errors.h" #include "laige/logging.h" #include "laige/result.h" +#include "laige/sim/archetype.h" #include "laige/sim/component.h" namespace laige { @@ -192,10 +195,13 @@ class World { [[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). + // O(1) for a component-less entity; when the entity is in an + // archetype, its row is detached first — O(tail rows * row-stride) + // bytes moved, still no allocation (M1-ECS-03; archetype.h). 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 @@ -251,11 +257,70 @@ class World { // ErrorCode::InvalidArgument. [[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept; + // ------------------------------------------------------------- + // Archetype SoA component storage (M1-ECS-03; full contract in + // archetype.h) + // ------------------------------------------------------------- + + // True when `entity` is live and has a component of type T. O(1), + // no allocation, no side effects (a pure query, like isValid: a + // stale handle is simply "no", no warn). T must be a Laige + // component (LAIGE_COMPONENT); an unregistered T reads as false. + template + [[nodiscard]] bool has(Entity entity) const noexcept; + + // The entity's component of type T, or nullptr: stale/out-of-range + // handle (after the rate-limited warn-once of check(), every build), + // T not registered in this world, or the entity lacks T (a normal + // negative query, no warn). O(1) in the entity count; no allocation. + // The pointer is valid until the next mutation of that entity's + // components (an add/remove that moves it shifts the column) or of + // the world — copy the value out if you must keep it (PERF-005). + template + [[nodiscard]] T* get(Entity entity) noexcept; + + // Give `entity` a component of type T: create-or-update. When the + // entity already has T, `value` overwrites it in place (the + // archetype does not change). Otherwise the entity moves to the + // archetype of its component set plus T — a pool-backed move over + // pre-reserved columns: O((tail rows) * row-stride) bytes moved, + // no heap allocation in steady state (growth events are bounded, + // accounted, and logged — archetype.h "Reserve policy"). + // + // stale/invalid handle -> InvalidArgument + warn-once + // T not registered (this world) + // -> InvalidArgument + warn + // entity at kMaxArchetypeComponents + // -> BudgetExhausted + warn + // no free archetype slot -> BudgetExhausted + warn + template + [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept; + + // Take the component of type T from `entity` (a no-op ok Status when + // the entity lacks T or has no components). Otherwise the entity + // moves to the archetype of its component set minus T — same cost + // and allocation contract as addComponent. Stale/invalid handle or + // unregistered T -> InvalidArgument (+ warn). + template + [[nodiscard]] Status removeComponent(Entity entity) noexcept; + + // The number of distinct component sets seen by this world so far + // (0 .. kMaxArchetypes; archetypes are never destroyed in M1). + // O(1), no side effects. + [[nodiscard]] std::uint32_t archetypeCount() const noexcept; + + // Archetype storage accounting snapshot (ArchetypeStats): the + // profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks + // read this. O(kMaxArchetypes), no allocation. + [[nodiscard]] ArchetypeStats archetypeStats() 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.) + // immediately reusable. O(capacity + detached rows * row-stride), + // no allocation, idempotent. M1-ECS-03: each live entity is + // detached from its archetype first (the per-entity component data + // is released with its row); the archetypes themselves — and the + // component type registry — survive. void clear() noexcept; // Move is an O(1) pointer swap; the source becomes a valid empty @@ -265,8 +330,9 @@ class World { 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(). + // Detaches every live entity's component rows (clear()) and + // releases the backing storage (per-slot tables, archetype table + // with its column blocks, type-key index). Idempotent with clear(). ~World() noexcept; private: @@ -280,6 +346,69 @@ class World { // case the 16-bit scheme does not rule out (see the preamble). void bumpGeneration(std::uint16_t slot) noexcept; + // ------------------------------------------------------------- + // Archetype SoA storage helpers (M1-ECS-03; defined in + // archetype.cpp) + // ------------------------------------------------------------- + + // This world's ComponentTypeId for a component type identity key + // (&ComponentTypeKey::kMarker); 0 when the type is not registered + // in this world. O(1) expected (open-addressing probe, never + // iterated); no allocation. + std::uint32_t componentIdOfKey(const void* key) const noexcept; + + // Insert a freshly registered (key, id) into the type-key index + // (setup path only, called from registerComponent). + void noteComponentKey(const void* key, std::uint32_t id) noexcept; + + // The archetype whose signature is the sorted, 0-terminated + // component-id array `sig` of length `count`; nullptr when no such + // archetype exists yet. O(kMaxArchetypes) bounded scan (fingerprint + // short-circuit + lexicographic verify); no allocation. + detail::ArchetypeRecord* findArchetype(const std::uint32_t* sig, + std::uint16_t count) noexcept; + + // Create the archetype for a new sorted signature. Preconditions + // (checked by the caller): count <= kMaxArchetypeComponents and + // archetypeCount_ < kMaxArchetypes. Reserves the initial columns + // (the only allocation; accounted in ArchetypeStats). Returns the + // record (always non-null under the preconditions). + detail::ArchetypeRecord* createArchetype(const std::uint32_t* sig, + std::uint16_t count) noexcept; + + // The column index of `componentId` in `arch` (its sorted sig); + // kInvalidColumnIndex when the archetype lacks the component. + // O(log kMaxArchetypeComponents); no allocation. + std::uint32_t columnIndexOf(const detail::ArchetypeRecord& arch, + std::uint32_t componentId) const noexcept; + + // Grow `arch`'s row capacity to min(capacity_, 2 * rowCapacity) — + // one bounded reservation per column (the reserve policy, + // archetype.h). Returns false only on the unreachable + // at-world-capacity edge. Accounted + logged (ecs/archetype_grow). + bool growArchetype(detail::ArchetypeRecord& arch, + std::uint32_t archetypeId) noexcept; + + // Shift `arch`'s tail left by one row at `row` (the slot column plus + // every live column), decrement size, and re-sync the rowOf_ records + // of the shifted rows (invariant I2 — the slot column and the rowOf_ + // table must agree). The caller clears archetypeOf_/rowOf_ for the + // detached slot when it leaves the archetypes. O((size - row) * + // row-stride) bytes moved; no allocation. + void removeRow(detail::ArchetypeRecord& arch, std::uint32_t row) noexcept; + + // Attach `slot` to `arch` at its slot-ordered position: shift the + // tail right, write the slot column, set archetypeOf_/rowOf_ (the + // new row's record plus the shifted tail — invariant I2), grow first + // if the archetype is full. Returns the new row; kInvalidRowIndex + // only when growth failed (unreachable while a live entity exists — + // row 0 is a valid row, hence the sentinel). The component columns + // of the new row are zero until the caller writes them. + // O((size - row) * row-stride) bytes moved; no allocation in steady + // state. + std::uint32_t attachSlot(std::uint32_t slot, + detail::ArchetypeRecord& arch) noexcept; + std::uint32_t capacity_{0}; std::unique_ptr generations_; std::unique_ptr alive_; @@ -294,6 +423,30 @@ class World { // component data, not the type registry). std::unique_ptr components_; std::uint32_t componentCount_{0}; + // Per-slot entity record (M1-ECS-03): the archetype the slot's entity + // is in (0 = no archetype: the entity has no components) and the + // entity's dense row within that archetype (the slot-ordered row — + // see archetype.h "Row order"). Both are per-slot table loads: the + // entity -> archetype map is direct indexing, no hash. + std::unique_ptr archetypeOf_; + std::unique_ptr rowOf_; + // Archetype table (M1-ECS-03): the fixed engine budget + // (kMaxArchetypes), records indexed by (archetype id - 1); an + // archetype is created when its component set is first seen. + // clear() empties the archetypes' rows but keeps the archetypes + // themselves (M1: sets are never destroyed — archetype.h). + std::unique_ptr archetypes_; + std::uint32_t archetypeCount_{0}; + // Component type-key index (M1-ECS-03): the open-addressing table + // (kComponentKeyIndexSize slots) mapping a component type identity + // key to this world's ComponentTypeId — the O(1) type -> id + // resolution behind has/get/addComponent/removeComponent. + std::unique_ptr keyIndex_; + // Archetype storage counters (ArchetypeStats feed; G-R4/M1-PROF-01). + std::uint64_t totalAdds_{0}; + std::uint64_t totalRemoves_{0}; + std::uint64_t totalArchetypeGrowth_{0}; + std::uint64_t totalReservations_{0}; }; // Component registration (M1-ECS-02). Header-defined: it is a template, @@ -311,6 +464,12 @@ Result World::registerComponent() noexcept { "carriers (S-8): a non-trivial member (a string, a " "destructor, a vtable) is not supported by the " "M1-ECS-03 SoA storage"); + static_assert(alignof(T) <= kArchetypeColumnAlignment, + "Laige components must have alignof(T) <= 32 " + "(kArchetypeColumnAlignment): the M1-ECS-03 SoA column " + "blocks are aligned to 32 bytes (the P0 targets' " + "maximum fundamental alignment); reduce the alignment " + "or ADR the bound"); constexpr const void* key = &detail::ComponentTypeKey::kMarker; if (components_ == nullptr) { // Moved-from world: no registry (see the preamble, entity.h). @@ -337,8 +496,266 @@ Result World::registerComponent() noexcept { rec.typeKey = key; rec.size = static_cast(sizeof(T)); rec.alignment = static_cast(alignof(T)); + const ComponentTypeId id{componentCount_ + 1}; + noteComponentKey(key, id.value); // M1-ECS-03: the type -> id lookup ++componentCount_; - return ComponentTypeId{componentCount_}; // ids are dense, from 1 + return id; // ids are dense, from 1 +} + +// --------------------------------------------------------------------------- +// Archetype SoA component access (M1-ECS-03; full contract in +// archetype.h). Header-defined like registerComponent: templates must +// be visible to every translation unit that touches a component. +// --------------------------------------------------------------------------- + +// The row-copy helper lives in detail (excluded from the public API +// scan, tools/api) — an anonymous namespace is not scanner-parseable. +namespace detail { + +// Copy `size` bytes from `src` to `dst` (components are trivially +// copyable — the registerComponent static_assert — so memcpy is the +// well-defined copy). Cold-branch helper to keep the templates short. +inline void copyRow(std::byte* dst, const std::byte* src, std::uint32_t size) { + std::memcpy(dst, src, static_cast(size)); +} + +} // namespace detail + +template +bool World::has(Entity entity) const noexcept { + static_assert(detail::ComponentTraits::isComponent, + "T is not a Laige component type: write LAIGE_COMPONENT(T) " + "once at namespace scope next to the type definition " + "(FR-1.2, S-8)"); + if (!isValid(entity)) return false; + const std::uint32_t id = + componentIdOfKey(&detail::ComponentTypeKey::kMarker); + if (id == 0) return false; // T not registered in this world + const std::uint32_t archIdx = archetypeOf_[entity.id]; + if (archIdx == 0) return false; // the entity has no components + return columnIndexOf(archetypes_[archIdx - 1], id) != kInvalidColumnIndex; +} + +template +T* World::get(Entity entity) noexcept { + static_assert(detail::ComponentTraits::isComponent, + "T is not a Laige component type: write LAIGE_COMPONENT(T) " + "once at namespace scope next to the type definition " + "(FR-1.2, S-8)"); + if (!isValid(entity)) { + static_cast(check(entity)); // rate-limited warn-once, every build + return nullptr; + } + const std::uint32_t id = + componentIdOfKey(&detail::ComponentTypeKey::kMarker); + if (id == 0) return nullptr; // T not registered in this world + const std::uint32_t archIdx = archetypeOf_[entity.id]; + if (archIdx == 0) return nullptr; // the entity has no components + detail::ArchetypeRecord& arch = archetypes_[archIdx - 1]; + const std::uint32_t col = columnIndexOf(arch, id); + if (col == kInvalidColumnIndex) return nullptr; // entity lacks T + const detail::ArchetypeColumn& column = arch.columns[col]; + return reinterpret_cast(column.base + + static_cast(rowOf_[entity.id]) * + column.size); +} + +template +Status World::addComponent(Entity entity, const T& value) noexcept { + static_assert(detail::ComponentTraits::isComponent, + "T is not a Laige component type: write LAIGE_COMPONENT(T) " + "once at namespace scope next to the type definition " + "(FR-1.2, S-8)"); + static_assert(std::is_trivially_copyable_v, + "Laige components must be trivially copyable data " + "carriers (S-8) — the SoA columns memcpy them"); + if (!isValid(entity)) { + static_cast(check(entity)); // rate-limited warn-once, every build + return ErrorCode::InvalidArgument; + } + const std::uint32_t id = + componentIdOfKey(&detail::ComponentTypeKey::kMarker); + if (id == 0) { + LAIGE_LOG_WARN("ecs", "component_unregistered", + "addComponent called for a type not registered in this " + "world; register it at world setup", + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation)); + return ErrorCode::InvalidArgument; + } + const std::uint32_t curIdx = archetypeOf_[entity.id]; + const std::uint32_t curRow = rowOf_[entity.id]; + if (curIdx != 0) { + const std::uint32_t curCol = + columnIndexOf(archetypes_[curIdx - 1], id); + if (curCol != kInvalidColumnIndex) { + // Create-or-update: the entity already has T — overwrite in + // place (no archetype change, documented). + const detail::ArchetypeColumn& column = + archetypes_[curIdx - 1].columns[curCol]; + detail::copyRow(column.base + static_cast(curRow) * column.size, + reinterpret_cast(&value), sizeof(T)); + ++totalAdds_; + return Status{}; + } + } + // Build the target signature (the entity's set plus T, sorted). + std::uint32_t targetSig[kMaxArchetypeComponents + 1]; + std::uint16_t targetCount = 0; + if (curIdx == 0) { + targetSig[0] = id; + targetCount = 1; + } else { + detail::ArchetypeRecord& cur = archetypes_[curIdx - 1]; + if (cur.sigCount >= kMaxArchetypeComponents) { + LAIGE_LOG_WARN("ecs", "component_limit", + "Entity already carries kMaxArchetypeComponents " + "components; the set is full (M1 bound — ADR to raise)", + laige::log::field("entity_id", entity.id), + laige::log::field("archetype_id", curIdx), + laige::log::field("components", cur.sigCount)); + return ErrorCode::BudgetExhausted; + } + // Merge-insert `id` into the sorted cur.sig (id is absent: the + // in-place case above caught its presence). + const std::uint16_t n = cur.sigCount; + for (std::uint16_t r = 0; r < n; ++r) { + if (cur.sig[r] < id) { + targetSig[targetCount++] = cur.sig[r]; + } else { + targetSig[targetCount++] = id; + for (std::uint16_t k = r; k < n; ++k) { + targetSig[targetCount++] = cur.sig[k]; + } + break; + } + } + if (targetCount == n) targetSig[targetCount++] = id; // id is last + } + // Find the target archetype, creating it when the set is new. + detail::ArchetypeRecord* target = findArchetype(targetSig, targetCount); + if (target == nullptr) { + if (archetypeCount_ >= kMaxArchetypes) { + LAIGE_LOG_WARN("ecs", "archetype_budget", + "The world has kMaxArchetypes distinct component " + "sets; a new set cannot be created (M1 bound — " + "ADR to raise)", + laige::log::field("entity_id", entity.id), + laige::log::field("archetype_count", archetypeCount_)); + return ErrorCode::BudgetExhausted; + } + target = createArchetype(targetSig, targetCount); + } + // Attach the slot to the target (slot-ordered; grows the target + // first when it is full), write the new row, then release the old + // row. attachSlot owns archetypeOf_/rowOf_ updates; the old row is + // removed explicitly from cur (the target differs from cur: the + // signatures differ by T). + const std::uint32_t newRow = attachSlot(entity.id, *target); + if (newRow == kInvalidRowIndex) { + // Unreachable while a live entity exists (the reserve policy caps + // growth at the world capacity, and one live entity outside the + // full archetype always exists — the one being added). + LAIGE_LOG_WARN("ecs", "archetype_budget", + "Archetype row reserve failed (at world capacity — " + "should be unreachable)", + laige::log::field("entity_id", entity.id)); + return ErrorCode::BudgetExhausted; + } + for (std::uint16_t i = 0; i < target->sigCount; ++i) { + const std::uint32_t cid = target->sig[i]; + detail::ArchetypeColumn& column = target->columns[i]; + if (cid == id) { + detail::copyRow(column.base + static_cast(newRow) * column.size, + reinterpret_cast(&value), sizeof(T)); + } else if (curIdx != 0) { + const detail::ArchetypeRecord& cur = archetypes_[curIdx - 1]; + const std::uint32_t srcCol = columnIndexOf(cur, cid); + const detail::ArchetypeColumn& src = cur.columns[srcCol]; + detail::copyRow(column.base + static_cast(newRow) * column.size, + src.base + static_cast(curRow) * src.size, + src.size); + } + } + if (curIdx != 0) removeRow(archetypes_[curIdx - 1], curRow); + ++totalAdds_; + return Status{}; +} + +template +Status World::removeComponent(Entity entity) noexcept { + static_assert(detail::ComponentTraits::isComponent, + "T is not a Laige component type: write LAIGE_COMPONENT(T) " + "once at namespace scope next to the type definition " + "(FR-1.2, S-8)"); + if (!isValid(entity)) { + static_cast(check(entity)); // rate-limited warn-once, every build + return ErrorCode::InvalidArgument; + } + const std::uint32_t id = + componentIdOfKey(&detail::ComponentTypeKey::kMarker); + if (id == 0) { + LAIGE_LOG_WARN("ecs", "component_unregistered", + "removeComponent called for a type not registered in " + "this world; register it at world setup", + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation)); + return ErrorCode::InvalidArgument; + } + const std::uint32_t curIdx = archetypeOf_[entity.id]; + if (curIdx == 0) return Status{}; // no components: a no-op + detail::ArchetypeRecord& cur = archetypes_[curIdx - 1]; + const std::uint32_t curCol = columnIndexOf(cur, id); + if (curCol == kInvalidColumnIndex) return Status{}; // lacks T: a no-op + const std::uint32_t curRow = rowOf_[entity.id]; + if (cur.sigCount == 1) { + // The entity's last component: it leaves the archetypes entirely. + removeRow(cur, curRow); + archetypeOf_[entity.id] = 0; + rowOf_[entity.id] = 0; + ++totalRemoves_; + return Status{}; + } + // Build the target signature (the entity's set minus T, still + // sorted — cur.sig is sorted and unique). + std::uint32_t targetSig[kMaxArchetypeComponents]; + std::uint16_t targetCount = 0; + for (std::uint16_t r = 0; r < cur.sigCount; ++r) { + if (cur.sig[r] != id) targetSig[targetCount++] = cur.sig[r]; + } + detail::ArchetypeRecord* target = findArchetype(targetSig, targetCount); + if (target == nullptr) { + if (archetypeCount_ >= kMaxArchetypes) { + LAIGE_LOG_WARN("ecs", "archetype_budget", + "The world has kMaxArchetypes distinct component " + "sets; a new set cannot be created (M1 bound — " + "ADR to raise)", + laige::log::field("entity_id", entity.id), + laige::log::field("archetype_count", archetypeCount_)); + return ErrorCode::BudgetExhausted; + } + target = createArchetype(targetSig, targetCount); + } + const std::uint32_t newRow = attachSlot(entity.id, *target); + if (newRow == kInvalidRowIndex) { + LAIGE_LOG_WARN("ecs", "archetype_budget", + "Archetype row reserve failed (at world capacity — " + "should be unreachable)", + laige::log::field("entity_id", entity.id)); + return ErrorCode::BudgetExhausted; + } + for (std::uint16_t i = 0; i < target->sigCount; ++i) { + const std::uint32_t cid = target->sig[i]; + detail::ArchetypeColumn& column = target->columns[i]; + const std::uint32_t srcCol = columnIndexOf(cur, cid); // cid != id + const detail::ArchetypeColumn& src = cur.columns[srcCol]; + detail::copyRow(column.base + static_cast(newRow) * column.size, + src.base + static_cast(curRow) * src.size, + src.size); + } + removeRow(cur, curRow); + ++totalRemoves_; + return Status{}; } } // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 38964b2..ba355ad 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,22 +1,43 @@ -# laige-sim tests (M1-ECS-01/02): entity handle + World entity storage, -# component type registry. +# laige-sim tests (M1-ECS-01/02/03): entity handle + World entity +# storage, component type registry, archetype SoA 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` -# and `component_registry` entries are the M1-ECS-01 and M1-ECS-02 -# Verify commands (`ctest -R entity`, `ctest -R component_registry`), -# selecting exactly the suites below from the shared executable. +# gtest_main. The unfiltered entry runs the whole module; the `entity`, +# `component_registry`, and `archetype` entries are the M1-ECS-01, +# M1-ECS-02, and M1-ECS-03 Verify commands (`ctest -R entity`, +# `ctest -R component_registry`, `ctest -R archetype`), selecting +# exactly the suites below from the shared executable. -set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp) +set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp + archetype_tests.cpp) +# M1-ECS-03: the test-only allocation counter overrides the global +# operator new/new[]; the sanitizer runtimes define their own +# new/delete (strong symbols in the Clang/GCC TSan runtime archives, +# interposed by ASan), so the counter is excluded from the sanitizer +# trees. There, the step's zero-allocation property is covered by the +# leak-free sanitizer run of the same churn loop plus the +# ArchetypeStats reservation-delta assertion (the same fallback +# pattern as tests/laige-core, M0-CORE-02/05). +if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) + list(APPEND LAIGE_SIM_TEST_SOURCES logging_alloc_counter.cpp) +endif() add_executable(laige-sim_tests ${LAIGE_SIM_TEST_SOURCES}) +if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) + target_compile_definitions(laige-sim_tests PRIVATE LAIGE_ALLOC_COUNTER=1) +endif() 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) +# The M1-ECS-03 churn order draws from the shared test seed helper +# (docs/testing.md §4, M0-TEST-01: seeded laige::Prng substreams). +target_include_directories(laige-sim_tests PRIVATE + ${CMAKE_SOURCE_DIR}/tests/support) + # 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) @@ -41,9 +62,17 @@ add_test(NAME component_registry COMMAND laige-sim_tests --gtest_filter=ComponentTypeId.*:ComponentRegistry.*) +# M1-ECS-03: archetype SoA storage. The step's Verify command is +# `ctest -R archetype`; this entry selects exactly the Archetype* +# suites from the shared laige-sim_tests executable (the churn test's +# machine-greppable stats line lands in the ctest output). +add_test(NAME archetype + COMMAND laige-sim_tests + --gtest_filter=Archetype*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. - set_tests_properties(laige-sim_tests entity component_registry + set_tests_properties(laige-sim_tests entity component_registry archetype PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/archetype_tests.cpp b/tests/laige-sim/archetype_tests.cpp new file mode 100644 index 0000000..d67ec4b --- /dev/null +++ b/tests/laige-sim/archetype_tests.cpp @@ -0,0 +1,1061 @@ +// laige-sim archetype SoA storage suite (M1-ECS-03). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - archetype = ordered component set, SoA columns (one T[] per +// component per archetype), entity -> archetype map +// - add/remove component: pool-backed moves between archetypes with +// no per-operation heap allocation (the churn test proves it: +// zero ArchetypeStats reservation delta + zero process-wide +// allocations over the churn window) +// - get is O(1) (archetype lookup + column index); stale handles +// degrade per the M1-ECS-01 contract (nullptr + warn-once) +// - 10k entities x add/remove churn: zero pool overflow and constant +// per-op cost (measured, not assumed — CORE-001; the machine- +// greppable stats line lands in the ctest output) +// - memory layout is contiguous per column (property test) and the +// slot-ordered row scheme survives moves (M1-ECS-05 pins the full +// convergence property; the scheme is exercised here) +// +// Runs as CTest `archetype` (the step's Verify command: +// `ctest -R archetype`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites below. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/budget_harness.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/sim/entity.h" + +#include "laige_test_seed.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "archetype_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "archetype_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, + "archetype_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 ARCHETYPE_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define ARCHETYPE_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if ARCHETYPE_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "archetype_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test component types (global scope on purpose) +// +// LAIGE_COMPONENT specializes laige::detail::ComponentTraits, which +// the C++ standard requires to be declared in the primary template's +// enclosing namespace — so the marks cannot sit in an anonymous +// namespace. +// --------------------------------------------------------------------------- + +// 8-byte, 4-byte-aligned: the basic packed-column case. +struct ArchPos { + std::int32_t x; + std::int32_t y; +}; +LAIGE_COMPONENT(ArchPos); + +// 8-byte-aligned: the SoA column alignment case (archetype.h: column +// blocks are aligned to kArchetypeColumnAlignment). +struct ArchVel { + std::int64_t v; +}; +LAIGE_COMPONENT(ArchVel); + +// The toggled component in the churn test. +struct ArchFlag { + std::int32_t f; +}; +LAIGE_COMPONENT(ArchFlag); + +// A type marked but never registered in the test worlds: the +// unregistered-type error path. +struct ArchUnreg { + std::int32_t v; +}; +LAIGE_COMPONENT(ArchUnreg); + +// A family of distinct types for the budget tests. +template +struct ArchBulk { + std::int32_t v; +}; + +namespace laige::detail { +template +struct ComponentTraits> { + static constexpr bool isComponent = true; +}; +} // namespace laige::detail + +namespace { + +// The PRNG substream id for this file (docs/testing.md §4, M0-TEST-01). +inline constexpr std::uint32_t kArchetypeTestsSubstreamId = 1003; + +// One world, taken out of its Result (Result::value() is const; +// takeValue() && moves the storage out — the documented +// ownership-transfer path, result.h). +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(); +} + +// Register ArchBulk .. ArchBulk; false on the first failure. +// Compile-time recursion over the non-type parameter (test setup +// code, not a hot path). +template +bool registerRange(laige::World& world) { + if constexpr (Lo < Hi) { + auto r = world.registerComponent>(); + if (!r.ok()) return false; + return registerRange(world); + } + return true; +} + +// Add ArchBulk to entities[i] for i in [Lo, Hi) — compile-time +// recursion over the non-type parameter (a runtime loop cannot form +// the template argument). Test setup code, not a hot path. +template +bool addRange(laige::World& world, const std::vector& entities) { + if constexpr (Lo < Hi) { + auto r = world.addComponent>(entities[Lo], ArchBulk{Lo}); + if (!r.ok()) return false; + return addRange(world, entities); + } + return true; +} + +// Add ArchBulk to one entity for i in [Lo, Hi). +template +bool addBulkRange(laige::World& world, laige::Entity entity) { + if constexpr (Lo < Hi) { + auto r = world.addComponent>(entity, ArchBulk{Lo}); + if (!r.ok()) return false; + return addBulkRange(world, entity); + } + return true; +} + +// The aligned address of a pointer, for the alignment property checks. +inline std::uintptr_t addr(const void* p) { + return reinterpret_cast(p); +} + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; the logging 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 + +// --------------------------------------------------------------------------- +// Archetype basics: sets, SoA columns, membership +// --------------------------------------------------------------------------- + +TEST(ArchetypeBasics, AddCreatesSingleComponentArchetype) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + EXPECT_EQ(world.archetypeCount(), 0u); // no components yet + EXPECT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + + EXPECT_EQ(world.archetypeCount(), 1u); + EXPECT_TRUE(world.has(entity)); + const ArchPos* p = world.get(entity); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->x, 1); + EXPECT_EQ(p->y, 2); +} + +TEST(ArchetypeBasics, SecondComponentCreatesNewArchetype) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + EXPECT_EQ(world.archetypeCount(), 1u); + ASSERT_TRUE(world.addComponent(entity, ArchVel{9}).ok()); + // A new component set {Pos, Vel}: a second archetype — the first + // stays alive (archetypes are never destroyed in M1). + EXPECT_EQ(world.archetypeCount(), 2u); + + const ArchPos* p = world.get(entity); + const ArchVel* v = world.get(entity); + ASSERT_NE(p, nullptr); + ASSERT_NE(v, nullptr); + EXPECT_EQ(p->x, 1); + EXPECT_EQ(v->v, 9); + + // Removing one component returns the entity to the other set — + // the {Pos} archetype already exists (no third archetype). + ASSERT_TRUE(world.removeComponent(entity).ok()); + EXPECT_EQ(world.archetypeCount(), 2u); + EXPECT_TRUE(world.has(entity)); + EXPECT_FALSE(world.has(entity)); + EXPECT_EQ(world.get(entity)->x, 1); +} + +TEST(ArchetypeBasics, SameSetConvergesToSameArchetype) { + // Order-independence of the signature: two entities acquire the same + // set in opposite orders and share one archetype, with the rows in + // ascending slot order (slots: e1 = 3, e2 = 2 in a capacity-4 world). + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e1 = world.create(); + auto e2 = world.create(); + ASSERT_TRUE(e1.ok()); + ASSERT_TRUE(e2.ok()); + + ASSERT_TRUE(world.addComponent(e1.value(), ArchPos{1, 0}).ok()); + ASSERT_TRUE(world.addComponent(e1.value(), ArchVel{1}).ok()); + ASSERT_TRUE(world.addComponent(e2.value(), ArchVel{2}).ok()); + ASSERT_TRUE(world.addComponent(e2.value(), ArchPos{2, 0}).ok()); + + // {Pos}, {Vel}, {Pos,Vel} — three archetypes, one per set seen. + EXPECT_EQ(world.archetypeCount(), 3u); + const ArchPos* p1 = world.get(e1.value()); + const ArchPos* p2 = world.get(e2.value()); + ASSERT_NE(p1, nullptr); + ASSERT_NE(p2, nullptr); + // Slot-ordered rows: e2 (slot 2) sits below e1 (slot 3), packed + // exactly one element apart (the contiguity property). + EXPECT_EQ(addr(p1) - addr(p2), static_cast(sizeof(ArchPos))); + EXPECT_EQ(p1->x, 1); + EXPECT_EQ(p2->x, 2); +} + +TEST(ArchetypeBasics, RemoveAllLeavesEntityAlive) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(entity, ArchVel{9}).ok()); + ASSERT_TRUE(world.removeComponent(entity).ok()); + ASSERT_TRUE(world.removeComponent(entity).ok()); + + // The entity is alive and component-less; the archetypes it visited + // stay (M1: sets are never destroyed). + EXPECT_TRUE(world.isValid(entity)); + EXPECT_FALSE(world.has(entity)); + EXPECT_FALSE(world.has(entity)); + EXPECT_EQ(world.get(entity), nullptr); + EXPECT_EQ(world.entityCount(), 1u); + EXPECT_EQ(world.archetypeCount(), 2u); + EXPECT_EQ(world.archetypeStats().rowsLive, 0u); +} + +TEST(ArchetypeBasics, ReaddOverwritesInPlace) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + const ArchPos* first = world.get(entity); + ASSERT_NE(first, nullptr); + + // Create-or-update: the value is replaced in place — same row, + // same archetype (no move). + ASSERT_TRUE(world.addComponent(entity, ArchPos{3, 4}).ok()); + const ArchPos* second = world.get(entity); + ASSERT_NE(second, nullptr); + EXPECT_EQ(first, second); + EXPECT_EQ(second->x, 3); + EXPECT_EQ(second->y, 4); + EXPECT_EQ(world.archetypeCount(), 1u); +} + +TEST(ArchetypeBasics, RemoveMissingIsNoop) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + // Removing a component the entity does not have is a no-op ok. + EXPECT_TRUE(world.removeComponent(entity).ok()); + EXPECT_FALSE(world.has(entity)); + EXPECT_EQ(world.archetypeCount(), 0u); + + // ... including when the entity has other components. + ASSERT_TRUE(world.addComponent(entity, ArchVel{5}).ok()); + EXPECT_TRUE(world.removeComponent(entity).ok()); + EXPECT_TRUE(world.has(entity)); + EXPECT_EQ(world.get(entity)->v, 5); + EXPECT_EQ(world.archetypeCount(), 1u); +} + +TEST(ArchetypeBasics, FreshEntityIsInNoArchetype) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + EXPECT_FALSE(world.has(e.value())); + EXPECT_EQ(world.get(e.value()), nullptr); + EXPECT_EQ(world.archetypeCount(), 0u); + EXPECT_EQ(world.archetypeStats().rowsLive, 0u); +} + +// --------------------------------------------------------------------------- +// Access: O(1) reads and the stale-handle contract (M1-ECS-01) +// --------------------------------------------------------------------------- + +TEST(ArchetypeAccess, GetStaleReturnsNull) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + ASSERT_TRUE(world.destroy(entity).ok()); + + // Stale use degrades in every build: nullptr (the warn-once of + // check() is asserted by the entity suite, M1-ECS-01). + EXPECT_EQ(world.get(entity), nullptr); +} + +TEST(ArchetypeAccess, HasStaleIsFalse) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + ASSERT_TRUE(world.destroy(entity).ok()); + + // has is a pure query (like isValid): no warn, no side effect — a + // stale handle simply reads as "no". + EXPECT_FALSE(world.has(entity)); +} + +TEST(ArchetypeAccess, AddStaleReturnsInvalidArgument) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.destroy(entity).ok()); + + auto r = world.addComponent(entity, ArchPos{1, 2}); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(ArchetypeAccess, RemoveStaleReturnsInvalidArgument) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.destroy(entity).ok()); + + auto r = world.removeComponent(entity); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(ArchetypeAccess, UnregisteredTypeIsAnError) { + laige::World world = makeWorld(4); + // ArchUnreg is LAIGE_COMPONENT-marked but never registered here: the + // per-world registry has no id for it. + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + + auto add = world.addComponent(entity, ArchUnreg{7}); + EXPECT_FALSE(add.ok()); + EXPECT_EQ(add.error(), laige::ErrorCode::InvalidArgument); + auto remove = world.removeComponent(entity); + EXPECT_FALSE(remove.ok()); + EXPECT_EQ(remove.error(), laige::ErrorCode::InvalidArgument); + // The entity is untouched. + EXPECT_FALSE(world.has(entity)); + EXPECT_EQ(world.archetypeCount(), 0u); +} + +// --------------------------------------------------------------------------- +// Layout: SoA contiguity, alignment, slot-ordered rows +// --------------------------------------------------------------------------- + +TEST(ArchetypeLayout, ColumnsAreContiguousAndAligned) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + // Four entities, slots 7, 6, 5, 4 (LIFO free list): slot order is + // e3 < e2 < e1 < e0. + std::vector entities; + for (int i = 0; i < 4; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities.push_back(e.value()); + ASSERT_TRUE(world.addComponent(entities.back(), + ArchPos{i, 100}).ok()); + ASSERT_TRUE(world.addComponent(entities.back(), + ArchVel{static_cast(i * 1000)}) + .ok()); + } + + const ArchPos* pos[4]; + const ArchVel* vel[4]; + for (int i = 0; i < 4; ++i) { + pos[i] = world.get(entities[i]); + vel[i] = world.get(entities[i]); + ASSERT_NE(pos[i], nullptr); + ASSERT_NE(vel[i], nullptr); + } + // Slot-ordered rows: the smallest slot (e3) is row 0, packed + // contiguously — the memory-layout property (one element per row). + EXPECT_LT(addr(pos[3]), addr(pos[2])); + EXPECT_LT(addr(pos[2]), addr(pos[1])); + EXPECT_LT(addr(pos[1]), addr(pos[0])); + EXPECT_EQ(addr(pos[2]) - addr(pos[3]), static_cast(sizeof(ArchPos))); + EXPECT_EQ(addr(pos[1]) - addr(pos[2]), static_cast(sizeof(ArchPos))); + EXPECT_EQ(addr(pos[0]) - addr(pos[1]), static_cast(sizeof(ArchPos))); + EXPECT_EQ(addr(vel[2]) - addr(vel[3]), static_cast(sizeof(ArchVel))); + // The values follow their slots (each row's data matches its entity). + for (int i = 0; i < 4; ++i) { + EXPECT_EQ(pos[i]->x, i); + EXPECT_EQ(vel[i]->v, static_cast(i) * 1000); + } + // Column alignment: every row address is aligned to the component's + // alignment (kArchetypeColumnAlignment covers it — archetype.h). + for (int i = 0; i < 4; ++i) { + EXPECT_EQ(addr(pos[i]) % alignof(ArchPos), 0u); + EXPECT_EQ(addr(vel[i]) % alignof(ArchVel), 0u); + } +} + +TEST(ArchetypeLayout, SlotOrderDeterminesRowOrder) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + + auto eA = world.create(); // slot 7 + auto eB = world.create(); // slot 6 + ASSERT_TRUE(eA.ok()); + ASSERT_TRUE(eB.ok()); + ASSERT_TRUE(world.addComponent(eA.value(), ArchPos{1, 0}).ok()); + ASSERT_TRUE(world.addComponent(eB.value(), ArchPos{2, 0}).ok()); + const ArchPos* bBefore = world.get(eB.value()); // row 0 + const ArchPos* aBefore = world.get(eA.value()); // row 1 + ASSERT_NE(bBefore, nullptr); + ASSERT_NE(aBefore, nullptr); + EXPECT_EQ(addr(aBefore) - addr(bBefore), + static_cast(sizeof(ArchPos))); + + // eC (slot 5) is inserted at the FRONT (smallest slot): every + // existing row shifts down exactly one row — each entity lands on + // the row (and address) the row before it vacated, packed. + auto eC = world.create(); + ASSERT_TRUE(eC.ok()); + ASSERT_TRUE(world.addComponent(eC.value(), ArchPos{3, 0}).ok()); + + const ArchPos* a = world.get(eA.value()); + const ArchPos* b = world.get(eB.value()); + const ArchPos* c = world.get(eC.value()); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c, bBefore); // the head slot takes the old head row + EXPECT_EQ(b, aBefore); // each shifted row took the row above's slot + EXPECT_EQ(addr(a) - addr(aBefore), + static_cast(sizeof(ArchPos))); + EXPECT_LT(addr(c), addr(b)); + EXPECT_LT(addr(b), addr(a)); + EXPECT_EQ(addr(b) - addr(c), static_cast(sizeof(ArchPos))); + EXPECT_EQ(addr(a) - addr(b), static_cast(sizeof(ArchPos))); + // The shifted rows kept their data. + EXPECT_EQ(b->x, 2); + EXPECT_EQ(c->x, 3); +} + +// --------------------------------------------------------------------------- +// Moves: data preservation and the dense-id-order scheme +// --------------------------------------------------------------------------- + +TEST(ArchetypeMoves, AddPreservesSharedData) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(entity, ArchVel{9}).ok()); + + ASSERT_TRUE(world.addComponent(entity, ArchFlag{5}).ok()); + + // The move {Pos,Vel} -> {Pos,Vel,Flag} preserved the shared data. + EXPECT_EQ(world.get(entity)->x, 1); + EXPECT_EQ(world.get(entity)->y, 2); + EXPECT_EQ(world.get(entity)->v, 9); + EXPECT_EQ(world.get(entity)->f, 5); + EXPECT_EQ(world.archetypeCount(), 3u); +} + +TEST(ArchetypeMoves, RemovePreservesRemainingData) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE(world.addComponent(entity, ArchPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(entity, ArchVel{9}).ok()); + ASSERT_TRUE(world.addComponent(entity, ArchFlag{5}).ok()); + + ASSERT_TRUE(world.removeComponent(entity).ok()); + + EXPECT_EQ(world.get(entity)->x, 1); + EXPECT_EQ(world.get(entity)->v, 9); + EXPECT_EQ(world.get(entity), nullptr); + EXPECT_EQ(world.archetypeCount(), 3u); // {Pos,Vel,Flag} stays alive +} + +TEST(ArchetypeMoves, ConvergedWorldsShareLayout) { + // Two worlds, same final state (both entities in {Pos,Vel}, same + // slot ids) reached through different operation interleavings — + // the row layouts must agree (the dense-id-order scheme; M1-ECS-05 + // runs the full property test on this scheme). + // World A: e1 gets {Pos} first, e2 gets {Vel} first. + laige::World wa = makeWorld(4); + ASSERT_TRUE(wa.registerComponent().ok()); + ASSERT_TRUE(wa.registerComponent().ok()); + auto ea1 = wa.create(); + auto ea2 = wa.create(); + ASSERT_TRUE(ea1.ok()); + ASSERT_TRUE(ea2.ok()); + ASSERT_TRUE(wa.addComponent(ea1.value(), ArchPos{1, 0}).ok()); + ASSERT_TRUE(wa.addComponent(ea2.value(), ArchVel{2}).ok()); + ASSERT_TRUE(wa.addComponent(ea1.value(), ArchVel{1}).ok()); + ASSERT_TRUE(wa.addComponent(ea2.value(), ArchPos{2, 0}).ok()); + + // World B: e2 gets {Vel} first, e1 gets {Vel} first — same final + // state, different interleaving. + laige::World wb = makeWorld(4); + ASSERT_TRUE(wb.registerComponent().ok()); + ASSERT_TRUE(wb.registerComponent().ok()); + auto eb1 = wb.create(); + auto eb2 = wb.create(); + ASSERT_TRUE(eb1.ok()); + ASSERT_TRUE(eb2.ok()); + ASSERT_TRUE(wb.addComponent(eb2.value(), ArchVel{2}).ok()); + ASSERT_TRUE(wb.addComponent(eb1.value(), ArchVel{1}).ok()); + ASSERT_TRUE(wb.addComponent(eb2.value(), ArchPos{2, 0}).ok()); + ASSERT_TRUE(wb.addComponent(eb1.value(), ArchPos{1, 0}).ok()); + + // The sets VISITED differ (A saw {Pos} on the way; B never did), so + // the archetype counts differ — but both final {Pos,Vel} layouts + // must agree. + EXPECT_EQ(wa.archetypeCount(), 3u); // {Pos}, {Pos,Vel}, {Vel} + EXPECT_EQ(wb.archetypeCount(), 2u); // {Vel}, {Pos,Vel} + EXPECT_EQ(wa.archetypeStats().rowsLive, wb.archetypeStats().rowsLive); + // Same relative layout: e2 (slot 2) below e1 (slot 3), packed. + const ArchPos* pa1 = wa.get(ea1.value()); + const ArchPos* pa2 = wa.get(ea2.value()); + const ArchPos* pb1 = wb.get(eb1.value()); + const ArchPos* pb2 = wb.get(eb2.value()); + ASSERT_NE(pa1, nullptr); + ASSERT_NE(pa2, nullptr); + ASSERT_NE(pb1, nullptr); + ASSERT_NE(pb2, nullptr); + EXPECT_EQ(addr(pa1) - addr(pa2), static_cast(sizeof(ArchPos))); + EXPECT_EQ(addr(pb1) - addr(pb2), static_cast(sizeof(ArchPos))); + const ArchVel* va1 = wa.get(ea1.value()); + const ArchVel* vb2 = wb.get(eb2.value()); + ASSERT_NE(va1, nullptr); + ASSERT_NE(vb2, nullptr); + EXPECT_EQ(va1->v, 1); + EXPECT_EQ(vb2->v, 2); +} + +// --------------------------------------------------------------------------- +// Budgets: the engine-level caps (archetype.h) +// --------------------------------------------------------------------------- + +TEST(ArchetypeBudget, ArchetypeCountLimitHonored) { + // kMaxArchetypes = 256 distinct sets: 256 single-component archetypes + // plus one two-component set would be the 257th — BudgetExhausted. + laige::World world = makeWorld(300); + ASSERT_TRUE((registerRange<0, 256>(world))); + + constexpr int kArchetypes = 256; + std::vector entities; + entities.reserve(kArchetypes + 1); + for (int i = 0; i < kArchetypes + 1; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities.push_back(e.value()); + } + ASSERT_TRUE((addRange<0, 256>(world, entities))); + EXPECT_EQ(world.archetypeCount(), static_cast(laige::kMaxArchetypes)); + + // The extra entity joins the existing {ArchBulk<0>} archetype (ok), + // then asks for the new set {ArchBulk<0>, ArchBulk<1>} — no room. + ASSERT_TRUE(world.addComponent>(entities[kArchetypes], + ArchBulk<0>{kArchetypes}).ok()); + auto overflow = world.addComponent>(entities[kArchetypes], + ArchBulk<1>{kArchetypes}); + EXPECT_FALSE(overflow.ok()); + EXPECT_EQ(overflow.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(world.archetypeCount(), static_cast(laige::kMaxArchetypes)); + // The entity keeps what it was given. + EXPECT_TRUE(world.has>(entities[kArchetypes])); + EXPECT_FALSE(world.has>(entities[kArchetypes])); +} + +TEST(ArchetypeBudget, ComponentLimitHonored) { + // kMaxArchetypeComponents = 32: a 33rd distinct component on one + // entity fails with BudgetExhausted. + laige::World world = makeWorld(40); + ASSERT_TRUE((registerRange<0, 33>(world))); + + auto e = world.create(); + ASSERT_TRUE(e.ok()); + const laige::Entity entity = e.value(); + ASSERT_TRUE((addBulkRange<0, 32>(world, entity))); + auto overflow = world.addComponent>(entity, ArchBulk<32>{32}); + EXPECT_FALSE(overflow.ok()); + EXPECT_EQ(overflow.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_TRUE(world.has>(entity)); + EXPECT_FALSE(world.has>(entity)); +} + +TEST(ArchetypeBudget, GrowthCapsAtWorldCapacity) { + // Reserve policy: an archetype grows 16 -> min(capacity, 32) = 18 for + // a capacity-18 world — the reserve never exceeds the world budget. + laige::World world = makeWorld(18); + ASSERT_TRUE(world.registerComponent().ok()); + + for (int i = 0; i < 18; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), ArchPos{i, 0}).ok()); + } + const laige::ArchetypeStats s = world.archetypeStats(); + EXPECT_EQ(s.rowsLive, 18u); + EXPECT_EQ(s.rowsReserved, 18u); // capped at 18, not doubled to 32 + EXPECT_EQ(s.totalArchetypeGrowth, 1u); + // 2 initial reservations (slot column + Pos column) + 2 grown. + EXPECT_EQ(s.totalReservations, 4u); +} + +// --------------------------------------------------------------------------- +// Accounting: ArchetypeStats (the profiler / G-R4 feed) +// --------------------------------------------------------------------------- + +TEST(ArchetypeStats, StatsTrackRowsAndChurn) { + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + std::vector entities; + for (int i = 0; i < 4; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities.push_back(e.value()); + } + + laige::ArchetypeStats s0 = world.archetypeStats(); + EXPECT_EQ(s0.archetypeCount, 0u); + EXPECT_EQ(s0.rowsLive, 0u); + EXPECT_EQ(s0.rowsReserved, 0u); + EXPECT_EQ(s0.totalAdds, 0u); + EXPECT_EQ(s0.totalRemoves, 0u); + + for (std::size_t i = 0; i < entities.size(); ++i) { + ASSERT_TRUE(world.addComponent( + entities[i], ArchPos{static_cast(i), 0}) + .ok()); + } + laige::ArchetypeStats s1 = world.archetypeStats(); + EXPECT_EQ(s1.archetypeCount, 1u); + EXPECT_EQ(s1.rowsLive, 4u); + // Initial reserve is min(kInitialArchetypeRows, capacity) = 8 for a + // capacity-8 world (the small-world edge of the reserve policy). + EXPECT_EQ(s1.rowsReserved, 8u); + // 8 rows x (2 B slot column + 8 B Pos) = 80 reserved row bytes. + EXPECT_EQ(s1.bytesReserved, 80u); + EXPECT_EQ(s1.totalAdds, 4u); + EXPECT_EQ(s1.totalReservations, 2u); // slot column + 1 component column + + for (const auto& entity : entities) { + ASSERT_TRUE(world.addComponent(entity, ArchVel{0}).ok()); + } + laige::ArchetypeStats s2 = world.archetypeStats(); + EXPECT_EQ(s2.archetypeCount, 2u); + EXPECT_EQ(s2.rowsLive, 4u); + // 8 rows x 10 B ({Pos}) + 8 rows x 18 B ({Pos,Vel}). + EXPECT_EQ(s2.bytesReserved, 8 * 10u + 8 * 18u); + EXPECT_EQ(s2.totalAdds, 8u); + + for (const auto& entity : entities) { + ASSERT_TRUE(world.removeComponent(entity).ok()); + ASSERT_TRUE(world.removeComponent(entity).ok()); + } + laige::ArchetypeStats s3 = world.archetypeStats(); + EXPECT_EQ(s3.rowsLive, 0u); + // The archetypes stay alive (and their reserves stay accounted). + EXPECT_EQ(s3.archetypeCount, 2u); + EXPECT_EQ(s3.bytesReserved, 8 * 10u + 8 * 18u); + EXPECT_EQ(s3.totalRemoves, 8u); +} + +// --------------------------------------------------------------------------- +// Budget-exhaustion logging: warn-once (LOG-004) through the facade +// --------------------------------------------------------------------------- + +TEST(ArchetypeLogging, ArchetypeBudgetWarnsOnce) { + 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()); + // Only Warn and above from ecs: the 256 archetype_created Info + // events of the setup phase are suppressed so the burst is isolated + // (the Info path itself is exercised by the M0 logging suite). + laige::log::Logger::instance().setSubsystemLevel("ecs", laige::log::Level::Warn); + + laige::World world = makeWorld(300); + ASSERT_TRUE((registerRange<0, 256>(world))); + + constexpr int kArchetypes = 256; + std::vector entities; + entities.reserve(kArchetypes + 3); + for (int i = 0; i < kArchetypes + 3; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities.push_back(e.value()); + } + ASSERT_TRUE((addRange<0, 256>(world, entities))); + + // Three attempts to create the 257th archetype 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 = kArchetypes; i < kArchetypes + 3; ++i) { + ASSERT_TRUE(world.addComponent>(entities[i], ArchBulk<0>{i}).ok()); + auto overflow = world.addComponent>(entities[i], + ArchBulk<1>{i}); + EXPECT_FALSE(overflow.ok()); + if (overflow.isError()) { + EXPECT_EQ(overflow.error(), laige::ErrorCode::BudgetExhausted); + } + } + + 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, "archetype_budget"); + bool foundCount = false; + for (const auto& [key, value] : sinkPtr->entries[0].fields) { + if (key == "archetype_count" && value == "256") foundCount = true; + } + EXPECT_TRUE(foundCount); + + // 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 foundSuppressed = false; + for (const auto& [key, value] : sinkPtr->entries[1].fields) { + if (key == "suppressed" && value == "2") foundSuppressed = true; + } + EXPECT_TRUE(foundSuppressed); + + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +} + +// --------------------------------------------------------------------------- +// The churn step (CORE-001: measured, not assumed) — 10k entities x +// add/remove, zero pool overflow, zero allocations, flat per-op cost +// --------------------------------------------------------------------------- + +TEST(ArchetypeChurn, TenKEntitiesAddRemoveChurnZeroAllocAndFlatCost) { + constexpr std::uint32_t kEntities = 10000; + laige::World world = makeWorld(kEntities); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + std::vector entities(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities[i] = e.value(); + } + // Warm-up (setup phase; growth events are allowed and accounted): + // both working archetypes {Pos,Vel} and {Pos,Vel,Flag} are grown to + // the full working size before the measured window. + for (std::uint32_t i = 0; i < kEntities; ++i) { + ASSERT_TRUE(world.addComponent(entities[i], ArchPos{0, 0}).ok()); + ASSERT_TRUE(world.addComponent(entities[i], ArchVel{0}).ok()); + } + for (std::uint32_t i = 0; i < kEntities; ++i) { + ASSERT_TRUE(world.addComponent(entities[i], ArchFlag{1}).ok()); + } + for (std::uint32_t i = 0; i < kEntities; ++i) { + ASSERT_TRUE(world.removeComponent(entities[i]).ok()); + } + + const laige::ArchetypeStats before = world.archetypeStats(); + + // The measured window: every entity toggles Flag once — kEntities + // adds + kEntities removes = 2 * kEntities ops, in a seeded random + // order (docs/testing.md §4: deterministic per seed) so the slot- + // ordered insertion positions span the full cost range. + laige::Prng rng = laige::testing::TestPrng(kArchetypeTestsSubstreamId); + std::vector order(kEntities); + std::iota(order.begin(), order.end(), std::uint32_t{0}); + for (std::uint32_t i = kEntities; i > 1; --i) { + const std::uint32_t j = rng.next_range(0, i); // [0, i) + std::swap(order[i - 1], order[j]); + } + + laige::Histogram hist(laige::Histogram::Options{kEntities * 2}); + laige::TimeIt timer; +#if defined(LAIGE_ALLOC_COUNTER) + // The churn window starts here: the shuffle and the histogram setup + // allocated above, so the reset lands between setup and the ops. + laige::test::resetAllocCounter(); +#endif + std::uint64_t failures = 0; + for (std::uint32_t idx : order) { + timer.reset(); + if (!world.addComponent(entities[idx], + ArchFlag{static_cast(idx)}) + .ok()) { + ++failures; + } + hist.record(timer.elapsedMs()); + timer.reset(); + if (!world.removeComponent(entities[idx]).ok()) { + ++failures; + } + hist.record(timer.elapsedMs()); + } + const laige::ArchetypeStats after = world.archetypeStats(); + + // Zero pool overflow: every op succeeded (no BudgetExhausted, no + // growth failure) and the window reserved nothing new — the churn + // moved only between pre-reserved columns. + EXPECT_EQ(failures, 0u); + EXPECT_EQ(after.totalReservations, before.totalReservations); + EXPECT_EQ(after.totalArchetypeGrowth, before.totalArchetypeGrowth); + EXPECT_EQ(after.totalAdds, before.totalAdds + kEntities); + EXPECT_EQ(after.totalRemoves, before.totalRemoves + kEntities); + // All entities are back in {Pos,Vel}. + EXPECT_EQ(after.rowsLive, kEntities); + +#if defined(LAIGE_ALLOC_COUNTER) + // The roadmap's Verify property: the churn window allocates zero + // heap — only the pre-reserved column blocks are touched. (The + // sanitizer trees prove the same property with a leak-free run of + // this loop plus the reservation delta above.) + EXPECT_EQ(laige::test::allocCounter(), 0u); +#endif + + // Constant per-op cost (CORE-001: measured, not assumed): the + // distribution over the full cost range is flat — p99 within 3x + // the median (no spike beyond the documented O(tail * row-stride) + // move cost, no growth event, no hidden allocation). + const laige::HistogramStats st = hist.stats(); + ASSERT_EQ(st.n, kEntities * 2); + EXPECT_TRUE(std::isfinite(st.mean)); + EXPECT_TRUE(std::isfinite(st.p50)); + EXPECT_TRUE(std::isfinite(st.p99)); + EXPECT_GT(st.p50, 0.0); + EXPECT_LT(st.p99, st.p50 * 3.0); + // Machine-greppable stats line for the M1 baseline record (CORE-001 + // / AGENTS §12: the measured per-op cost, on every ctest run). + std::printf("archetype-churn %s\n", laige::formatStatsLine(st).c_str()); + std::fflush(stdout); + + // Spot-check the final state through the public API. + EXPECT_TRUE(world.has(entities[0])); + EXPECT_TRUE(world.has(entities[0])); + EXPECT_FALSE(world.has(entities[0])); +} + +// --------------------------------------------------------------------------- +// World lifetime: moves and clear over the archetype storage +// --------------------------------------------------------------------------- + +TEST(ArchetypeLifetime, MovedWorldKeepsComponents) { + laige::World a = makeWorld(4); + ASSERT_TRUE(a.registerComponent().ok()); + + auto e1 = a.create(); + auto e2 = a.create(); + ASSERT_TRUE(e1.ok()); + ASSERT_TRUE(e2.ok()); + ASSERT_TRUE(a.addComponent(e1.value(), ArchPos{1, 2}).ok()); + ASSERT_TRUE(a.addComponent(e2.value(), ArchPos{3, 4}).ok()); + + laige::World b = std::move(a); + + // The storage moved with the world (O(1) pointer swap of the tables). + EXPECT_EQ(b.entityCount(), 2u); + EXPECT_EQ(b.archetypeCount(), 1u); + EXPECT_TRUE(b.has(e1.value())); + EXPECT_EQ(b.get(e1.value())->x, 1); + EXPECT_EQ(b.get(e2.value())->x, 3); + EXPECT_EQ(b.archetypeStats().rowsLive, 2u); + + // The moved-from world is a valid empty world: no live entities, no + // archetypes, every handle invalid, every create fails (capacity 0). + EXPECT_EQ(a.entityCount(), 0u); + EXPECT_EQ(a.archetypeCount(), 0u); + EXPECT_FALSE(a.isValid(e1.value())); + EXPECT_FALSE(a.has(e1.value())); + auto e3 = a.create(); + EXPECT_FALSE(e3.ok()); + EXPECT_EQ(e3.error(), laige::ErrorCode::BudgetExhausted); +} + +TEST(ArchetypeLifetime, ClearDetachesAllRows) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + + std::vector entities; + for (int i = 0; i < 3; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + entities.push_back(e.value()); + ASSERT_TRUE(world.addComponent(entities.back(), + ArchPos{i, 0}).ok()); + ASSERT_TRUE(world.addComponent(entities.back(), + ArchVel{i}).ok()); + } + EXPECT_EQ(world.archetypeStats().rowsLive, 3u); + + world.clear(); + + // Every handle is stale and every row is released; the archetypes + // and the type registry survive (setup state). + EXPECT_EQ(world.entityCount(), 0u); + for (const auto& entity : entities) { + EXPECT_FALSE(world.isValid(entity)); + } + const laige::ArchetypeStats s = world.archetypeStats(); + EXPECT_EQ(s.rowsLive, 0u); + // Both visited sets stay alive ({Pos} was visited on the way to + // {Pos,Vel}). + EXPECT_EQ(s.archetypeCount, 2u); + EXPECT_EQ(world.componentCount(), 2u); + + // The world is immediately reusable: a new entity re-enters the + // surviving archetype. + auto e4 = world.create(); + ASSERT_TRUE(e4.ok()); + ASSERT_TRUE(world.addComponent(e4.value(), ArchPos{9, 9}).ok()); + EXPECT_TRUE(world.has(e4.value())); + EXPECT_EQ(world.get(e4.value())->x, 9); + EXPECT_EQ(world.archetypeStats().rowsLive, 1u); +} diff --git a/tests/laige-sim/entity_tests.cpp b/tests/laige-sim/entity_tests.cpp index 0623f63..ce5d464 100644 --- a/tests/laige-sim/entity_tests.cpp +++ b/tests/laige-sim/entity_tests.cpp @@ -521,10 +521,11 @@ TEST(WorldStats, StatsTrackBytes) { 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); + // Per-slot footprint: 11 B bookkeeping (2 B generation + 1 B alive + // flag + 2 B free-list entry + 2 B archetype slot + 4 B row index — + // M1-ECS-03) — see the header and entity.md. + EXPECT_EQ(s.bytesCapacity, 4u * 11u); + EXPECT_EQ(s.bytesInUse, 1u * 11u); } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/logging_alloc_counter.cpp b/tests/laige-sim/logging_alloc_counter.cpp new file mode 100644 index 0000000..3a1af76 --- /dev/null +++ b/tests/laige-sim/logging_alloc_counter.cpp @@ -0,0 +1,79 @@ +// Test-only global operator new/new[] overrides (M1-ECS-03; see the +// header). +// +// A strong definition of the global operator new/new[] in this +// translation unit is linked ahead of the CRT's weak defaults +// (GCC/Clang/AppleClang: the library definitions are weak; MSVC: the +// linker only pulls in a CRT allocator module to resolve undefined +// symbols, which this object already defines). Every heap allocation +// made by any translation unit in the test executable therefore +// passes through the counters below. +// +// (Same pattern as tests/laige-core/logging_alloc_counter.cpp — one +// copy per test executable, since two strong definitions in one +// binary would collide.) + +#include "logging_alloc_counter.h" + +#include +#include +#include +#include + +namespace laige::test { + +void resetAllocCounter() { + detail::allocCount.store(0, std::memory_order_relaxed); +} + +std::uint64_t allocCounter() { + return detail::allocCount.load(std::memory_order_relaxed); +} + +} // namespace laige::test + +namespace { + +void count() noexcept { + laige::test::detail::allocCount.fetch_add(1, std::memory_order_relaxed); +} + +} // namespace + +void* operator new(std::size_t size) { + count(); + void* p = std::malloc(size); + if (p == nullptr) std::terminate(); // no exceptions (NFR-8.10) + return p; +} + +void* operator new[](std::size_t size) { + count(); + void* p = std::malloc(size); + if (p == nullptr) std::terminate(); + return p; +} + +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + void* p = std::malloc(size); + if (p != nullptr) count(); + return p; +} + +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + void* p = std::malloc(size); + if (p != nullptr) count(); + return p; +} + +void operator delete(void* p) noexcept { std::free(p); } +void operator delete[](void* p) noexcept { std::free(p); } + +// The sized deallocations too: libstdc++ deallocates through +// operator delete(p, size), and without these overrides the CRT +// defaults would present the free to the sanitizer as a delete of a +// malloc-style allocation (ASan alloc-dealloc-mismatch). Routing them +// through std::free keeps every allocation/deallocation pair +// malloc/free-consistent under the sanitizers. +void operator delete(void* p, std::size_t) noexcept { std::free(p); } +void operator delete[](void* p, std::size_t) noexcept { std::free(p); } diff --git a/tests/laige-sim/logging_alloc_counter.h b/tests/laige-sim/logging_alloc_counter.h new file mode 100644 index 0000000..6d112ea --- /dev/null +++ b/tests/laige-sim/logging_alloc_counter.h @@ -0,0 +1,51 @@ +// Test-only process-wide allocation counter (M1-ECS-03). +// +// The M1-ECS-03 churn test asserts its zero-allocation property the +// way M0-CORE-02's logging test did: a strong global +// operator new/new[] override counts every heap allocation in the test +// process, so the churn window's `allocCounter() == 0` is proof that +// the add/remove churn touches no heap — only the pre-reserved SoA +// column blocks (the "pool accounting" is the ArchetypeStats +// reservation delta, asserted alongside in the test). +// +// logging_alloc_counter.cpp defines the program's global operator +// new/new[] (the strong definition overrides the CRT's weak default +// for the whole test executable), so every heap allocation made +// anywhere in the process — test framework, engine under test, test +// code — is counted. +// +// TEST-ONLY: never link this translation unit into an engine library +// or a tool — it would replace the real allocator for that binary. It +// is also excluded from the sanitizer build trees (LAIGE_ASAN/ +// LAIGE_TSAN): the sanitizer runtimes define their own new/delete, so +// the overrides cannot be linked there (see this directory's +// CMakeLists.txt; the zero-allocation property is verified in those +// trees by the leak-free sanitizer run of the same churn loop plus +// the ArchetypeStats reservation-delta assertion). +// +// (Same pattern as tests/laige-core/logging_alloc_counter.{h,cpp} — +// one copy per test executable, since two strong definitions in one +// binary would collide.) + +#pragma once + +#include +#include + +namespace laige::test { + +namespace detail { + +// The process-wide heap-allocation count (see the file header). +inline std::atomic allocCount{0}; + +} // namespace detail + +// Reset the counter to zero. Call it after the test framework has +// finished its startup allocations and before the region under test. +void resetAllocCounter(); + +// The number of heap allocations since the last reset. +std::uint64_t allocCounter(); + +} // namespace laige::test From 8c90fbf0d9c6cdc8345985cfc8e527dbedc1f04b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 22:47:28 +0200 Subject: [PATCH 2/2] [M1-ECS-03] Fill in change-log commit hash (cad0594) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 7235d76..e85cc84 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -197,7 +197,7 @@ One line per completed (or split/renumbered) step. | 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()` 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 | — | 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`/`has` O(1) (slot → record → column binary search ≤ 32 → row); `addComponent` create-or-update (in-place overwrite when present) and `removeComponent` 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 ` 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-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`/`has` O(1) (slot → record → column binary search ≤ 32 → row); `addComponent` create-or-update (in-place overwrite when present) and `removeComponent` 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 ` 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 | ---