diff --git a/docs/README.md b/docs/README.md index 3fbfc10..697e224 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ 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-03: archetype SoA -component storage). +component storage; M1-ECS-04: the query API + iteration legality). 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. @@ -38,6 +38,11 @@ still to land. 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`). +- [Query API + iteration legality](api/query.md) — + `World::each(fn, Read/Write tags...)` over the archetype + rows: superset match, per-component access, the stack-scoped + iteration-legality guard, and the 10k-entity zero-allocation window + (M1-ECS-04; `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, @@ -120,8 +125,12 @@ still to land. (they land with M1 replay and M6/M7 networking). - Per-module API docs for the remaining M1+ modules (`laige-render`, `laige-assets`, `laige-net`, `laige-server`, `laige-script`, - `laige-editor`) — they land with their modules. (`laige-sim` has its - first doc: [entity.md](api/entity.md).) + `laige-editor`) — they land with their modules. (`laige-sim` has + one per shipped piece: + [entity.md](api/entity.md), + [component_registry.md](api/component_registry.md), + [archetype.md](api/archetype.md), + [query.md](api/query.md).) ## Related diff --git a/docs/api/archetype.md b/docs/api/archetype.md index 601688a..9b74799 100644 --- a/docs/api/archetype.md +++ b/docs/api/archetype.md @@ -116,8 +116,8 @@ warn-once + `rate_limited` drain. 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). + cost, not a per-tick one (the per-tick path is the M1-ECS-04 + iteration, [query.md](query.md), which never moves rows). - **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: @@ -179,7 +179,9 @@ if (e.isError()) { /* BudgetExhausted: refuse the spawn (S-2) */ } 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): +// Read one component (O(1)); the per-tick pattern is the M1-ECS-04 +// query — world.each(fn, Read{}, Write{}) — +// see query.md. const PlayerPos* p = world.get(e.value()); // O(1), nullptr on absence if (p != nullptr) { /* use p — valid until the entity's next mutation */ } @@ -212,10 +214,13 @@ world.removeComponent(e.value()); // component-less, still alive 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-04 (done):** the query/iteration API over these columns — + `World::each` with per-component `Read`/`Write` access and the + stack-scoped iteration-legality guard — see + [query.md](query.md). +- **M1-ECS-05:** the deterministic iteration contract over the stored + row order (the slot-ordered scheme above is what the query + 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 diff --git a/docs/api/component_registry.md b/docs/api/component_registry.md index 9e73ebe..25d4428 100644 --- a/docs/api/component_registry.md +++ b/docs/api/component_registry.md @@ -197,6 +197,9 @@ if (pos.isError()) { - **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-04 (done):** the query API resolves the listed component + types to their ids (`world.each` — unregistered types + match nothing) — see [query.md](query.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 7586bd3..d92882e 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -53,7 +53,7 @@ worlds passes `isValid()` in both (the same cross-pool caveat as | `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); each live row is detached (M1-ECS-03); every handle goes stale; capacity unchanged; world immediately reusable | O(capacity) scan + detaches, 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. Returns `Status`: under a live `each` iteration, `InvalidArgument` when a matched archetype holds live rows (query.md "Iteration legality") | O(capacity) scan + detaches, no allocation, idempotent | 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 @@ -65,7 +65,9 @@ M1-ECS-03 adds the component layer on the same slot tables: `world.removeComponent(e)`, `world.archetypeCount()`, `world.archetypeStats()` — see [archetype.md](archetype.md) (the stale-handle contract above is -inherited by all four component ops). +inherited by all four component ops). M1-ECS-04 adds the query/ +iteration API `world.each(fn, Read/Write tags...)` on +the same rows — see [query.md](query.md). ## Errors (FR-12.1, CORE-008) @@ -74,6 +76,7 @@ inherited by all four component ops). | `World::create(Options)` | `capacity > Entity::kMaxEntities` (16-bit id space) | `ErrorCode::InvalidArgument` (2) | | `create()` | declared scene budget exhausted | `ErrorCode::BudgetExhausted` (4) | | `destroy(e)` / `check(e)` | stale, cleared, or out-of-range handle | `ErrorCode::InvalidArgument` (2) | +| iteration-legality violations (write during read-iteration, structural mutation / `destroy` / `clear` touching a matched archetype, nested `each`) — M1-ECS-04 | the mutation is skipped, never applied; the iteration continues | `ErrorCode::InvalidArgument` (2) from the mutating call (debug: assert instead) — [query.md](query.md) | Failures are `Result`/`Status` values — never exceptions, never silent. The world logs its stale-handle degradations through the @@ -176,7 +179,11 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } 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-04 (done):** the query API + iteration legality — + `World::each(fn, Read/Write tags...)` iterates the + archetype rows with a stack-scoped guard (no hidden allocations); + see [query.md](query.md). - **M1-ECS-05:** deterministic iteration (archetype order, entity id - order — PRD §10.3). + order — PRD §10.3) over the visit order M1-ECS-04 pins. - **M1-ECS-06:** the G-R3 warn thresholds (25%/50%/100% of the declared budget) pull `stats()`. diff --git a/docs/api/query.md b/docs/api/query.md new file mode 100644 index 0000000..eee0738 --- /dev/null +++ b/docs/api/query.md @@ -0,0 +1,222 @@ +# Query API + iteration legality (`World::each`) + +The M1 query/iteration API over the archetype SoA columns +(M1-ECS-04; PRD §9.1 S-3/S-4, §10.3, FR-12.3, AGENTS PERF-003/004/006, +API-004). Public headers: `src/laige-sim/include/laige/sim/query.h` +(`Access`, the `Read`/`Write` access tags, the `detail::IdSet256` +guard sets, the full contract) plus the `World::each` member template +in `src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/query.cpp` (the iteration-guard helpers) + the +header-defined `each` body. Unit suite: `ctest -R query` +(`tests/laige-sim/query_tests.cpp`), including the 10k-entity +zero-allocation window whose machine-greppable stats lines land in +the ctest output on every run (CORE-001). + +The PRD Appendix B sketch calls this `ctx.each, Health>()`; +in M1 the query lives on the `World` that owns the storage, and +M1-SYS-01's `SystemContext` delegates to it (one world, one owner +thread). The sketch's argument order (access flags first, callable +last) is not the final syntax: C++ cannot deduce a parameter pack +that is not last, so the access tags follow the callable — one tag +per listed component, in template order, still declared per component. + +## Query semantics + +```cpp +world.each(fn, laige::Read{}, laige::Write{}); +``` + +- **Superset match:** an archetype matches when *every* listed + component type is in its set; extra components do not exclude an + entity (an entity with `{Pos, Vel, Flag}` is visited by + `each`). +- **Reference hand-off:** `fn` is invoked once per matching entity as + `fn(Entity, R1, ..., RN)` — one reference per listed component, in + template order: a `const T&` where the tag is `Read`, a `T&` where + it is `Write` (compile-time; a write through a `Read` reference + requires a cast — a bug the compiler rejects, API-008). The + `Entity` handle is the generation-checked live handle of the row's + slot. +- **Tag discipline:** exactly one `Read`/`Write` tag per listed + component, in template order; a mismatched type or count is a + compile error (static_asserts in `each`), not a runtime surprise. +- **Empty query:** `world.each<>(fn)` visits every live entity (the + component-less ones included) in ascending slot-id order, with no + component references. It iterates no archetype rows, so its guard + matches no archetype and structural mutations remain legal under + it. +- **Unregistered type:** a listed component not registered in this + world matches nothing — the iteration runs zero times and returns + ok (a pure query, like `has` reading `false`). No archetype is + created for the query. + +## Visit order + +Ascending archetype id (creation order), then ascending slot id +within the archetype (the slot-ordered rows of archetype.h — invariant +I2). The empty query visits ascending slot id directly. M1-ECS-05 +documents this as the deterministic iteration contract; the suite +pins the exact sequence. + +## Iteration legality (API-004, FR-12.3) + +A live `each` iteration carries a **guard** (two membership-only +256-bit sets, `detail::IdSet256`, stack-owned): the matched-archetype +set (computed before the first callback, so complete for the whole +iteration) and the read-component set (the queried components +declared `Read`). Mutations are checked *before* any side effect — +a rejected mutation is **skipped, never applied**, and the +iteration continues over the unmutated storage. + +| Mutation inside a live `each` | Legal when | +|---|---| +| in-place `addComponent` (create-or-update overwrite) | `T` is declared `Write` by the query, or `T` is not listed at all | +| structural `addComponent` / `removeComponent` (archetype move) | both the source AND the target archetype are outside the matched set | +| `destroy(e)` | `e`'s archetype (0 if component-less) is outside the matched set | +| `clear()` | no matched archetype holds live rows | +| `create()` | always (touches no rows) | +| write through a `Write` reference; in-place overwrite of a `Write`-declared component | always (the intended mutation path) | +| a nested `each` | never | + +The guard is live from the first callback to the last; after +`each` returns, the same structural mutation is legal again (the +suite pins both sides). + +**Debug builds** assert (SIGABRT) on every violation — a loud +misuse crash, in the stale-handle precedent (CPP-012, S-9). +**Release builds** return `ErrorCode::InvalidArgument` from the +mutating call, log one rate-limited warn, and skip the mutation +(FR-12.3 degradation). + +## Errors (FR-12.1, CORE-008) + +| Violation | Release return | Event (subsystem `ecs`) | +|---|---|---| +| in-place write of a `Read`-declared queried component | `InvalidArgument` (2) from the mutating call | `iteration_write_during_read` | +| structural move / `destroy` touching a matched archetype | `InvalidArgument` (2) from the mutating call | `iteration_mutation` | +| `clear()` with live rows in a matched archetype | `InvalidArgument` (2) from `clear` | `iteration_clear` | +| a nested `each` | `InvalidArgument` (2) from the nested `each` | `iteration_nested` | + +No new `ErrorCode` values: the registry is stable and additive-only, +and all iteration-legality failures reuse `InvalidArgument` (the +stale-handle precedent). Every event carries the correlating fields: +`entity_id`, `generation`, plus `component_id` (write case) and +`archetype_id` / `source_archetype` / `target_archetype` / `rows` +(structural cases) — LOG-002. + +Repeats are rate-limited per (subsystem, event, severity) with the +suppressed-count summary on shutdown (LOG-004); the suite pins +warn-once + the `rate_limited` drain (release builds — in debug the +assert fires before the log). + +## Performance (DOC-004) + +- **Hot path:** the archetype scan is O(kMaxArchetypes × N) with + N ≤ 32 listed components (N `columnIndexOf` probes per archetype, + each a ≤ 32-entry lexicographic scan); visits are one cache-line + stride per row. **No heap allocation**: the iteration state is + stack-scoped and bounded — `ids[N]`, `cols[N]`, + `matchedIds[kMaxArchetypes]`, and two 256-bit guard sets (PERF-003, + PERF-006: no `std::function`, no hash map, no lock in the loop — + `fn` is a template parameter, inlined). +- **Zero-alloc evidence (CORE-001):** `QueryZeroAlloc` runs 10k + entities × `{Pos, Vel}` through a Read/Write pass (a legal in-place + write per visit) and a Read/Read pass in a reset allocation-counter + window (non-sanitizer trees): zero process-wide heap allocations, + zero reservation delta (pool-steady), and prints the machine- + greppable `query-iteration ` lines to the ctest output. + The sanitizer trees cover the same loop leak-free; M1-ALLOC-01 + lands the standing assertion. +- **Measured baseline (g++ 16.2.1, 2026-09, single-threaded + headless, Debug tree):** 10k visits × 2 passes ≈ 0.88 ms total + (≈ 0.044 µs/visit, `-O0`) — the M1-BENCH-01 tick baseline input; + numbers are machine-dependent, the *shape* (flat, no spike, no + allocation) is the tested property. +- **Traps:** + - The visit order is not sorted by component values and is not + the entity-creation order — the archetype-id/slot-id scheme is + the contract (M1-ECS-05 documents it; deterministic replay is + what M1-ECS-05 builds on). + - A query over many archetypes pays the O(256 × N) scan even when + few rows match — M1 keeps N small by design; the per-tick + system pattern is few components, many entities (API-002). + - The callback runs synchronously on the owner thread with the + guard live: the legal-mutation table above is the whole + reentrancy contract (API-005) — everything else asserts in + debug. + +## Threading and determinism + +- **Single owner thread** (CONC-001); `each` is not thread-safe + (M1 is single-threaded simulation — PRD §10.2). +- **Determinism (ARCH-010):** the visit order is a pure function of + the world state (archetype creation order × slot order); the guard + sets are membership-only and never iterated; no floating point, no + platform intrinsics. The same operation sequence produces + bit-identical visit sequences on every platform. + +## Usage (performant pattern) + +```cpp +// Setup (once): register the component types (M1-ECS-02). +ASSERT(world.registerComponent().ok()); +ASSERT(world.registerComponent().ok()); + +// Per tick: iterate every {ArchPos, ArchVel} entity and update. +// Read/Read — a pure read pass (the guard rejects any write). +world.each( + [&](laige::Entity e, const ArchPos& p, const ArchVel& v) { + /* read p, v — no mutation allowed while this runs */ + }, + laige::Read{}, laige::Read{}); + +// Per tick: integrate (Write-declared component: the reference store +// and an in-place addComponent overwrite are the intended paths). +world.each( + [&](laige::Entity e, const ArchPos& p, ArchVel& v) { + v.v += static_cast(p.x); // legal: Write tag + }, + laige::Read{}, laige::Write{}); + +// Empty query: every live entity, ascending slot order. +world.each<>([](laige::Entity e) { /* e.g. despawn sweep bookkeeping */ }); +``` + +## Misuse warnings + +- Writing through a `Read` reference compiles only with a cast — + that cast is a bug the debug guard will assert on (and the release + guard will skip) if it reaches the storage through a World call; + declare the component `Write` instead. +- Moving, destroying, or clearing an entity of a matched archetype + from inside the callback invalidates the iteration the engine is + mid-way through: it is rejected by design (assert/skip) — defer + such mutations to an explicit despawn phase (API-004). +- The nested `each` is rejected because the outer guard's state would + be clobbered by the inner scan; flatten the work into one query. +- `each` returns `Status`: the empty/valid iteration returns ok — + the error path is the guard's rejection of a nested iteration only + (a callback's own mutating call surfaces its own `Status`). +- Holding references from a callback past the callback's end is + dangling once any mutation moves rows — copy out what you need + (the row storage is the same SoA columns `get` exposes). + +## Roadmap context + +- **M1-ECS-01/02/03 (done):** the entity handle, the component + registry, and the archetype SoA columns this API iterates — + [entity.md](entity.md), + [component_registry.md](component_registry.md), + [archetype.md](archetype.md). +- **M1-ECS-04 (this step):** the query API + iteration legality + above. +- **M1-ECS-05:** the deterministic iteration contract over the visit + order pinned by this step (replay/hash tests at the promised scope, + ARCH-010). +- **M1-SYS-01/02:** the system loop and `SystemContext` — the + `ctx.each` of the PRD sketch delegates to `World::each` (one + world, one owner thread). +- **M1-ALLOC-01:** the standing zero-allocation assertion over the + window this step measured. +- **M1-BENCH-01:** the tick budget uses the measured visit cost above + as the iteration baseline. diff --git a/laige-api.json b/laige-api.json index 18f045c..a3d480a 100644 --- a/laige-api.json +++ b/laige-api.json @@ -14,7 +14,8 @@ "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" + "src/laige-sim/include/laige/sim/entity.h", + "src/laige-sim/include/laige/sim/query.h" ], "symbols": [ {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, @@ -420,45 +421,53 @@ {"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": 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} + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 137, "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": 138, "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": 139, "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": 141, "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": 142, "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": 150, "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": 153, "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": 167, "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": 168, "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": 169, "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": 170, "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": 171, "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": 172, "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": 173, "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": 245, "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": 252, "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": 253, "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": 259, "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": 264, "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": 274, "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": 281, "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": 284, "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": 287, "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": 291, "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": 295, "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": 316, "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": 321, "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": 327, "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": 338, "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": 348, "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": 365, "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": 373, "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": 379, "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": 384, "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::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 415, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... accesses) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 429, "signature": "[[nodiscard]] Status 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. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 433, "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": 434, "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": 435, "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": 436, "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": 441, "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}, + {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, + {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Read", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 246, "signature": "struct Read", "summary": "The access tags of World::each — one per listed component, in template order (the preamble \"Query semantics\"). The TYPE carries the access, so the callback's reference constness is decided at compile time:", "budget": null, "experimental": false}, + {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 1975744..ae60d83 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -49,7 +49,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R archetype` green; churn test reports allocs = 0 via pool accounting. - **Size:** ~400 lines + tests (the largest ECS step; split into storage-layout / move-if-exceeding) -- [ ] **M1-ECS-04 · Query API + iteration legality** +- [x] **M1-ECS-04 · Query API + iteration legality** - **Refs:** FR-1.2/FR-1.3 (declared access, iteration legality); AGENTS CORE-008 - **Depends:** M1-ECS-03 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index e85cc84..011dc37 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 | 3 | 🚧 in progress (M1-ECS-03) | +| M1 | 25 | 4 | 🚧 in progress (M1-ECS-04) | | 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** | **25** | | +| **Total** | **193** | **26** | | --- @@ -198,6 +198,7 @@ One line per completed (or split/renumbered) step. | 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 | `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 | +| 2026-09-14 | M1-ECS-04 | `f2f57e8` | Query API + iteration legality (M1-ECS-04 scope, nothing else): `World::each(fn, Read/Write tags...)` over the M1-ECS-03 SoA rows — superset match (extra components do not exclude), per-component access declared by tag TYPE (`Read` → `const T&`, `Write` → `T&`, decided at compile time; count/type checked by static_assert), the access tags follow the callable (a pack of parameters must be last to be deducible — the PRD sketch's `(access_flags, fn)` order settled here and documented in query.h), `each<>(fn)` visits all live entities ascending slot order, an unregistered listed type matches nothing (ok Status, zero visits); the iteration-legality guard — two stack-scoped membership-only `detail::IdSet256` sets (the matched-archetype set, complete before the first callback, plus the Read-declared component set) — enforces the query.h legality table: in-place write of a Read-declared queried component, structural add/remove/destroy/clear touching a matched source/target archetype, and a nested `each()` all **assert in debug** (six forked SIGABRT children) and in release return `ErrorCode::InvalidArgument` (no new codes — the registry stays additive-only) + one rate-limited warn + **skip-without-applying** while the iteration continues over the unmutated storage (FR-12.3; events `ecs/iteration_write_during_read`, `ecs/iteration_mutation`, `ecs/iteration_clear`, `ecs/iteration_nested`); legal paths: `create()` always, structural moves outside the matched set, writes through `Write` references, in-place overwrites of Write-declared components; no hidden allocations — iteration state is stack-scoped (`ids[N]`/`cols[N]`/`matchedIds[256]`/two 256-bit sets), the 10k-entity × 2-pass window (a Write pass storing per visit + a Read pass) measured **zero process-wide heap allocations** (test-only `operator new` counter, non-sanitizer trees; sanitizer trees leak-free) and zero reservation delta (pool-steady), ≈0.044 µs/visit on the `-O0` tree (machine-greppable `query-iteration ` lines per ctest run — CORE-001, M1-BENCH-01 baseline input); `clear()` is now `Status` (guard check first — a clear under a live iteration is rejected whole, never partial; the dtor never sees an active iteration) and all call sites honor `[[nodiscard]]`; compile-time machinery: the component/access packs ride as single tuple types (`std::tuple`, `std::tuple`) because an explicit template argument list cannot partition between consecutive packs, `rowRef` returns one `conditional_t` reference type (a `decltype(auto)` if/constexpr pair of returns forces inconsistent deduction), `visitRowRec` carries the accumulated references as a `Refs&...` reference pack (forwarded each level — no component copies); new public header `src/laige-sim/include/laige/sim/query.h` (`Access`/`Read`/`Write`, `detail::IdSet256`, the full contract) + `src/laige-sim/query.cpp` (the four guard helpers); 17 cases in the `query` CTest entry (exact mixed sets incl. superset, empty-query slot order + legal concurrent destroy, unregistered no-match, access-tag reference kinds, pinned archetype-then-slot visit order, guard release after iteration, the legal-mutation matrix, the release skip matrix (state unchanged + iteration continues + the same op succeeds afterwards), six debug assert cases, warn-once + `rate_limited` summary via a memory sink, the zero-alloc window); API contract in `docs/api/query.md` (+ cross-refs in entity.md, archetype.md, component_registry.md, docs/README, sim README); `laige-api.json` regenerated (451 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (37 tests), `ctest -R query` green (15 passed + 2 release-only skips; 11 passed + 6 debug-only skips on `build-release`), sim suites green on `build-asan` (ASan+UBSan, leak-free), `build-clang`, `build-release`, `build-shared`, `build-tsan`, `tools/laige-include-lint` OK | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 6008856..ab1e12d 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -17,8 +17,10 @@ # 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) +# lifecycle. M1-ECS-04 adds query.cpp: the iteration-legality guard +# behind World::each (the query template itself is header-defined in +# entity.h, next to the component access templates). +set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 608e603..ffc7e7e 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -20,5 +20,14 @@ 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. +M1-ECS-04 landed the query API + iteration legality — +`World::each(fn, Read/Write tags...)` over the archetype +rows with per-component access (superset match), the stack-scoped +iteration-legality guard (assert in debug, `Status` + skip-with-log in +release), and the 10k-entity zero-allocation iteration window +(`include/laige/sim/query.h`, `query.cpp`; API contract in +[docs/api/query.md](../docs/api/query.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `query`). +The deterministic iteration contract (M1-ECS-05), the system loop +(M1-SYS), and the game loop (M1-LOOP) land in the remaining M1 steps; +physics, input, and animation in M3. diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index e4d3cb0..50d9dde 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -32,7 +32,13 @@ inline constexpr std::size_t kBytesPerSlot = 11; World::World() = default; -World::~World() noexcept { clear(); } +World::~World() noexcept { + // The destructor never observes an active iteration (the iteration + // is synchronous on the owner thread and completes before the world + // can be destroyed), so the guard check inside clear() cannot fail + // here — the nodiscard Status is acknowledged. + static_cast(clear()); +} World::World(World&& other) noexcept : capacity_(other.capacity_), @@ -50,7 +56,10 @@ World::World(World&& other) noexcept keyIndex_(std::move(other.keyIndex_)), totalAdds_(other.totalAdds_), totalRemoves_(other.totalRemoves_), totalArchetypeGrowth_(other.totalArchetypeGrowth_), - totalReservations_(other.totalReservations_) { + totalReservations_(other.totalReservations_), + iterationActive_(other.iterationActive_), + iterationArchetypes_(other.iterationArchetypes_), + iterationReadComponents_(other.iterationReadComponents_) { other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -62,11 +71,24 @@ World::World(World&& other) noexcept other.totalRemoves_ = 0; other.totalArchetypeGrowth_ = 0; other.totalReservations_ = 0; + // The iteration guard travels with the storage (a live world cannot + // be moved while an iteration is active — the iteration is + // synchronous on the owner thread — but the moved-from world must + // be a valid empty world in every field). + other.iterationActive_ = false; + other.iterationArchetypes_ = detail::IdSet256{}; + other.iterationReadComponents_ = detail::IdSet256{}; } World& World::operator=(World&& other) noexcept { if (this == &other) return *this; - clear(); // release what this currently owns + // M1-ECS-04: clear() can fail only under an active iteration of + // THIS world's archetypes — impossible here: the world being + // assigned into owns its iteration state (the synchronous flow + // completes each() before any other code runs), and the guard is + // re-taken from `other` below (inactive in practice, per the move + // constructor). The nodiscard Status is therefore acknowledged. + static_cast(clear()); capacity_ = other.capacity_; generations_ = std::move(other.generations_); alive_ = std::move(other.alive_); @@ -86,6 +108,9 @@ World& World::operator=(World&& other) noexcept { totalRemoves_ = other.totalRemoves_; totalArchetypeGrowth_ = other.totalArchetypeGrowth_; totalReservations_ = other.totalReservations_; + iterationActive_ = other.iterationActive_; + iterationArchetypes_ = other.iterationArchetypes_; + iterationReadComponents_ = other.iterationReadComponents_; other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -97,6 +122,9 @@ World& World::operator=(World&& other) noexcept { other.totalRemoves_ = 0; other.totalArchetypeGrowth_ = 0; other.totalReservations_ = 0; + other.iterationActive_ = false; + other.iterationArchetypes_ = detail::IdSet256{}; + other.iterationReadComponents_ = detail::IdSet256{}; return *this; } @@ -190,8 +218,13 @@ Status World::destroy(Entity entity) noexcept { } // 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). + // archetype.h; no allocation). M1-ECS-04: the iteration-legality + // check runs before the detach — destroying an entity of an + // archetype the active query iterates would shift its tail under + // the iterator (query.h). const std::uint32_t archIdx = archetypeOf_[entity.id]; + Status guard = guardStructural("destroy", archIdx, 0, entity); + if (guard.isError()) return guard; if (archIdx != 0) { removeRow(archetypes_[archIdx - 1], rowOf_[entity.id]); archetypeOf_[entity.id] = 0; @@ -204,7 +237,12 @@ Status World::destroy(Entity entity) noexcept { return Status{}; } -void World::clear() noexcept { +Status World::clear() noexcept { + // M1-ECS-04: the iteration-legality check runs first — a clear + // under an active iteration is rejected (skipped, never partial) + // when a matched archetype still holds live rows (query.h). + Status guard = guardClear(); + if (guard.isError()) return guard; // 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). @@ -222,6 +260,7 @@ void World::clear() noexcept { } } inUse_ = 0; + return Status{}; } std::uint32_t World::capacity() const noexcept { return capacity_; } diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index aaaebb5..c0f9622 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -17,7 +17,10 @@ // registry (registerComponent, component.h); M1-ECS-03 // adds the archetype SoA component storage on top of this // slot table (archetype.h: has/get/addComponent/ -// removeComponent + the per-slot archetype record). +// removeComponent + the per-slot archetype record); +// M1-ECS-04 adds the query API and the iteration-legality +// guard (query.h: World::each(Read/Write +// tags..., fn) + the World-API mutation checks). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -124,6 +127,7 @@ #include "laige/sim/archetype.h" #include "laige/sim/component.h" +#include "laige/sim/query.h" namespace laige { @@ -169,6 +173,71 @@ struct EntityStats { std::size_t bytesInUse{}; }; +// M1-ECS-04 query helpers (detail: engine implementation, excluded +// from the public API scan). Declared before World: the member +// templates of the World class reference them by qualified name at +// the point of definition. +namespace detail { + +// The row reference of the I-th queried component (the query's +// per-row hand-off, World::each). `row` is the dense row index, +// `cols` the per-component column indices of the matched archetype +// (resolved once per archetype by each()). The declared access tag +// decides the value category — a `T&` for Write (the intended +// mutation path), a `const T&` for Read (a write through it requires +// a cast: a bug the compiler rejects, API-008). No allocation: one +// pointer arithmetic. +// +// The component TYPE and ACCESS packs arrive as single tuple types +// (std::tuple, std::tuple): an explicit template +// argument list cannot partition between two consecutive packs +// (GCC gives the first pack zero arguments), so no pack may sit in +// the template parameter list here. The reference type is one +// conditional alias (a single return statement: an if-constexpr pair +// of returns would force a consistent decltype(auto) deduction from +// both branches, and T& vs const T& never is). +template +constexpr decltype(auto) rowRef(const Row& row, const std::uint32_t* cols, + const ArchetypeRecord& arch) { + using T = std::tuple_element_t; + using A = std::tuple_element_t; + static_assert(std::is_trivially_copyable_v, + "Laige components must be trivially copyable data " + "carriers (S-8) — the SoA columns memcpy them"); + static_assert(alignof(T) <= kArchetypeColumnAlignment, + "Laige components must have alignof(T) <= 32 " + "(kArchetypeColumnAlignment): the SoA column blocks are " + "aligned to 32 bytes"); + using Ref = std::conditional_t, T&, const T&>; + const ArchetypeColumn& column = arch.columns[cols[I]]; + T* p = reinterpret_cast( + column.base + static_cast(row) * column.size); + return static_cast(*p); +} + +// Record a queried component's id in the guard's read set when its +// access tag is Read (the tags are static-checked to be Read/Write by +// World::each, so "not Write" is "Read"). id 0 (unregistered) is a +// no-op — such a component matches nothing. +template +void setQueryReadBit(IdSet256& readSet, std::uint32_t id) { + using A = std::tuple_element_t>; + if constexpr (!std::is_same_v) { + readSet.set(id); + } +} + +// True when every tag in the pack is a Read/Write access tag +// (World::each's compile-time tag check; an empty pack is true). +template +inline constexpr bool isAccessTags() { + bool ok = true; + ((ok = ok && (std::is_same_v || std::is_same_v)), ...); + return ok; +} + +} // namespace detail + // The entity storage behind laige::Entity handles (M1-ECS-01). // // See the header preamble for the handle, stale-handle, budget, @@ -314,14 +383,50 @@ class World { // read this. O(kMaxArchetypes), no allocation. [[nodiscard]] ArchetypeStats archetypeStats() const noexcept; + // ------------------------------------------------------------- + // Query API + iteration legality (M1-ECS-04; full contract in + // query.h) + // ------------------------------------------------------------- + + // Iterate every entity having ALL of T1..TN (superset match: extra + // components do not exclude an entity), invoking + // `fn(Entity, R1, ..., RN)` — one reference per listed component, + // in template order: a `const T&` where the access tag is Read, a + // `T&` where it is Write. The access tags follow `fn`, one + // Read/Write tag per listed component, in the same order (checked + // at compile time — they come after the callable because a pack of + // parameters must be the last parameters to be deducible); + // `each<>` (no components, no tags) visits every live entity in + // ascending slot-id order with no component references. + // + // Visit order ascending archetype id (creation order), then + // ascending slot id per archetype (M1-ECS-05 + // documents the contract) + // Unregistered T the query matches nothing (ok Status, zero + // visits — a pure query, like has) + // Nested each() while an iteration is active: assert in debug, + // ErrorCode::InvalidArgument + one rate-limited + // warn in release (the nested iteration is + // rejected; query.h "Iteration legality") + // Allocation none — the iteration state is stack-scoped + // (query.h) + // Complexity O(kMaxArchetypes * N) scan + one visit per + // matching entity (PERF-007) + template + [[nodiscard]] Status each(F&& fn, Acc... accesses) 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 + 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; + // component type registry — survive. M1-ECS-04: rejected with + // ErrorCode::InvalidArgument (+ one rate-limited warn) while an + // iteration is active and any matched archetype still holds live + // rows — the clear is skipped, never partial (assert in debug; + // query.h "Iteration legality"); an ok Status otherwise. + [[nodiscard]] Status clear() noexcept; // Move is an O(1) pointer swap; the source becomes a valid empty // world (capacity 0: every create() fails, every handle invalid). @@ -409,6 +514,128 @@ class World { std::uint32_t attachSlot(std::uint32_t slot, detail::ArchetypeRecord& arch) noexcept; + // ------------------------------------------------------------- + // Iteration-legality guard (M1-ECS-04; defined in query.cpp, full + // contract in query.h). Every helper asserts in debug and logs + + // returns InvalidArgument in release when the check fails; ok + // Status when the mutation is legal. + // ------------------------------------------------------------- + + // Reject a nested World::each while an iteration is active + // (ecs/iteration_nested). + [[nodiscard]] Status guardIterationStart() noexcept; + + // Reject an in-place component overwrite (the create-or-update + // branch of addComponent) of a queried component declared Read in + // the active iteration (ecs/iteration_write_during_read). Legal + // when the component is declared Write, or not listed by the query. + [[nodiscard]] Status guardInplaceWrite(std::uint32_t componentId, + std::uint32_t archIdx, + Entity entity) noexcept; + + // Reject a structural change — an archetype move (addComponent / + // removeComponent), a destroy, or a clear — whose source OR target + // archetype is in the active iteration's matched set + // (ecs/iteration_mutation). `op` names the mutating call for the + // actionable message; 0 = "no archetype" (a component-less entity, + // or an entity leaving the archetypes) and is never a member. + [[nodiscard]] Status guardStructural(const char* op, + std::uint32_t sourceArch, + std::uint32_t targetArch, + Entity entity) noexcept; + + // Reject clear() while any matched archetype of the active + // iteration still holds live rows (ecs/iteration_clear). + [[nodiscard]] Status guardClear() noexcept; + + // M1-ECS-04 query helpers: compile-time recursion over the listed + // components (N ≤ 32 — the M1 bound). Recursion, not a fold: the + // per-index component TYPE must reach a template argument, which a + // fold's `...` does not expand (it only expands packs in the + // operand expression). + // The pack comes LAST in the template parameter list: an explicit + // argument list cannot partition unambiguously when a pack is + // followed by non-pack parameters (GCC gives the pack zero + // arguments), so the fixed parameters go first. + template + void fillQueryIdImpl(std::uint32_t* ids) const noexcept { + if constexpr (I < N) { + ids[I] = componentIdOfKey( + &detail::ComponentTypeKey< + std::tuple_element_t>>::kMarker); + this->template fillQueryIdImpl(ids); + } + } + + // Resolve the world id of every listed query component into + // `ids[I]` (0 when T is not registered in this world). + template + void fillQueryIds(std::uint32_t* ids) const noexcept { + this->template fillQueryIdImpl<0, sizeof...(Ts), Ts...>(ids); + } + + template + void fillQueryReadSetImpl(detail::IdSet256& readSet, + const std::uint32_t* ids) const noexcept { + if constexpr (I < N) { + detail::setQueryReadBit(readSet, ids[I]); + this->template fillQueryReadSetImpl(readSet, ids); + } + } + + // Record every query component declared Read into `readSet` (the + // guard's write-during-read set); ids[I] is the resolved id (0 = + // unregistered: IdSet256::set is a no-op for it). + template + void fillQueryReadSet(detail::IdSet256& readSet, + const std::uint32_t* ids) const noexcept { + this->template fillQueryReadSetImpl<0, sizeof...(Acc), Acc...>( + readSet, ids); + } + + // The recursive per-row reference builder (M1-ECS-04): I walks the + // query components, and the references built so far ride along as + // the Refs pack, DEDUCED from the call arguments (an explicitly + // filled pack cannot also grow from arguments — hence the parameter + // order: fixed template arguments first, F and Refs deduced last). + // Refs&... is a pack of REFERENCE parameters (the row references + // must reach the callback without a copy — PERF-005), and the + // recursive call forwards refs... so the accumulated references + // grow one per level. The component and access packs ride as + // single tuple types (std::tuple, std::tuple): an + // explicit template argument list cannot partition between two + // consecutive packs, so no pack may sit in this template parameter + // list (see rowRef). + template + void visitRowRec(F& fn, const Entity& entity, std::uint32_t row, + const std::uint32_t* cols, detail::ArchetypeRecord& arch, + Refs&... refs) const noexcept { + if constexpr (I < N) { + this->template visitRowRec( + fn, entity, row, cols, arch, refs..., + detail::rowRef(row, cols, arch)); + } else { + fn(entity, refs...); + } + } + + // Visit every row of `arch` (ascending row = ascending slot order), + // invoking `fn(entity, row references...)` — the per-archetype row + // loop of World::each. The component and access packs arrive as + // single tuple types (see visitRowRec): an explicit template + // argument list cannot partition between two consecutive packs. + template + void visitArchetype(F& fn, detail::ArchetypeRecord& arch, + const std::uint32_t* cols) const noexcept { + for (std::uint32_t row = 0; row < arch.size; ++row) { + const std::uint16_t slot = arch.slotCol[row]; + const Entity entity{slot, generations_[slot]}; + this->template visitRowRec<0, std::tuple_size_v, TList, AList>( + fn, entity, row, cols, arch); + } + } + std::uint32_t capacity_{0}; std::unique_ptr generations_; std::unique_ptr alive_; @@ -447,6 +674,15 @@ class World { std::uint64_t totalRemoves_{0}; std::uint64_t totalArchetypeGrowth_{0}; std::uint64_t totalReservations_{0}; + // Iteration-legality guard (M1-ECS-04; query.h): live while a + // World::each runs, on the world's single owner thread. The matched + // set names the archetypes the active query visits (complete before + // the first callback); the read set names the queried components + // declared Read (the in-place-write check). Both are membership-only + // fixed 256-bit sets — never iterated (PERF-006). + bool iterationActive_{false}; + detail::IdSet256 iterationArchetypes_{}; + detail::IdSet256 iterationReadComponents_{}; }; // Component registration (M1-ECS-02). Header-defined: it is a template, @@ -590,7 +826,12 @@ Status World::addComponent(Entity entity, const T& value) noexcept { columnIndexOf(archetypes_[curIdx - 1], id); if (curCol != kInvalidColumnIndex) { // Create-or-update: the entity already has T — overwrite in - // place (no archetype change, documented). + // place (no archetype change, documented). M1-ECS-04: the + // iteration-legality check runs first — an in-place write to a + // component the active query declared Read is rejected (assert + // in debug; Status + skip-with-log in release; query.h). + Status guard = guardInplaceWrite(id, curIdx, entity); + if (guard.isError()) return guard; const detail::ArchetypeColumn& column = archetypes_[curIdx - 1].columns[curCol]; detail::copyRow(column.base + static_cast(curRow) * column.size, @@ -634,6 +875,18 @@ Status World::addComponent(Entity entity, const T& value) noexcept { } // Find the target archetype, creating it when the set is new. detail::ArchetypeRecord* target = findArchetype(targetSig, targetCount); + // M1-ECS-04: the iteration-legality check runs before any side + // effect — a rejected mutation creates no archetype and logs no + // archetype_created (the target's id is known either way: the + // created archetype would be the next dense id). + const std::uint32_t targetArch = target != nullptr + ? static_cast( + std::distance(archetypes_.get(), target)) + 1 + : archetypeCount_ + 1; + { + Status guard = guardStructural("addComponent", curIdx, targetArch, entity); + if (guard.isError()) return guard; + } if (target == nullptr) { if (archetypeCount_ >= kMaxArchetypes) { LAIGE_LOG_WARN("ecs", "archetype_budget", @@ -709,7 +962,14 @@ Status World::removeComponent(Entity entity) noexcept { 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. + // The entity's last component: it leaves the archetypes entirely + // (target archetype 0 = "none"). M1-ECS-04: the iteration- + // legality check runs first (query.h). + { + Status guard = + guardStructural("removeComponent", curIdx, 0, entity); + if (guard.isError()) return guard; + } removeRow(cur, curRow); archetypeOf_[entity.id] = 0; rowOf_[entity.id] = 0; @@ -724,6 +984,17 @@ Status World::removeComponent(Entity entity) noexcept { if (cur.sig[r] != id) targetSig[targetCount++] = cur.sig[r]; } detail::ArchetypeRecord* target = findArchetype(targetSig, targetCount); + // M1-ECS-04: the iteration-legality check runs before any side + // effect (same placement and reasoning as addComponent). + { + const std::uint32_t targetArch = target != nullptr + ? static_cast( + std::distance(archetypes_.get(), target)) + 1 + : archetypeCount_ + 1; + Status guard = guardStructural("removeComponent", curIdx, targetArch, + entity); + if (guard.isError()) return guard; + } if (target == nullptr) { if (archetypeCount_ >= kMaxArchetypes) { LAIGE_LOG_WARN("ecs", "archetype_budget", @@ -758,4 +1029,102 @@ Status World::removeComponent(Entity entity) noexcept { return Status{}; } +// --------------------------------------------------------------------------- +// Query API (M1-ECS-04; full contract in query.h). Header-defined like +// registerComponent: a template, visible to every translation unit. +// --------------------------------------------------------------------------- + +template +Status World::each(F&& fn, Acc... accesses) noexcept { + static_assert(sizeof...(Ts) <= kMaxArchetypeComponents, + "a query lists at most kMaxArchetypeComponents (32) " + "components: no entity can carry more (the M1 bound — " + "ADR to raise)"); + static_assert(sizeof...(Ts) == sizeof...(Acc), + "World::each takes exactly one access tag per listed " + "component, in the same order: " + "each(Read/Write, ..., Read/Write, fn)"); + static_assert(detail::isAccessTags(), + "World::each access tags must be laige::Read or " + "laige::Write (one per listed component)"); + + // M1-ECS-04: one active iteration per world — a nested each() is + // rejected (assert in debug; Status + warn in release; query.h). + Status start = guardIterationStart(); + if (start.isError()) return start; + + // Resolve the queried components (world ids; 0 = unregistered — + // such a component matches nothing) and the read set (queried + // components declared Read — the guard's write-during-read check). + const std::size_t n = sizeof...(Ts); + std::uint32_t ids[n == 0 ? 1 : n]; + detail::IdSet256 readSet{}; + if constexpr (n > 0) { + this->template fillQueryIds(ids); + this->template fillQueryReadSet(readSet, ids); + } + + // The matched archetypes (superset match: every queried component is + // in the archetype's set), in ascending archetype-id order + // (= creation order — the visit order M1-ECS-05 documents). The + // scan finishes before the first callback, so the guard's matched + // set is complete for the whole iteration. + std::uint32_t matchedIds[kMaxArchetypes]; + std::uint32_t matchedCount = 0; + detail::IdSet256 matchedArchs{}; + if constexpr (n > 0) { + for (std::uint32_t a = 0; a < archetypeCount_; ++a) { + const detail::ArchetypeRecord& rec = archetypes_[a]; + bool matches = true; + for (std::size_t i = 0; i < n; ++i) { + if (columnIndexOf(rec, ids[i]) == kInvalidColumnIndex) { + matches = false; + break; + } + } + if (matches) { + matchedArchs.set(a + 1); + matchedIds[matchedCount++] = a + 1; + } + } + } + + // The guard is live from the first callback to the last (the + // synchronous single-threaded flow: no early exit, no exceptions). + iterationActive_ = true; + iterationArchetypes_ = matchedArchs; + iterationReadComponents_ = readSet; + + if constexpr (n == 0) { + // The empty query: every live entity, ascending slot-id order + // (the dense-id order M1-ECS-05 documents). It iterates no + // archetype rows, so the guard's matched set stays empty and + // structural mutations remain legal under it. + for (std::uint32_t slot = 0; slot < capacity_; ++slot) { + if (alive_[slot] != 0) { + fn(Entity{static_cast(slot), generations_[slot]}); + } + } + } else { + // Rows of every matched archetype, ascending row = ascending + // slot order (the slot column is strictly ascending — invariant + // I2). The column indices are resolved once per archetype. + for (std::uint32_t mi = 0; mi < matchedCount; ++mi) { + detail::ArchetypeRecord& arch = archetypes_[matchedIds[mi] - 1]; + std::uint32_t cols[n]; + for (std::size_t i = 0; i < n; ++i) { + cols[i] = columnIndexOf(arch, ids[i]); // present: it matched + } + this->template visitArchetype, std::tuple>( + fn, arch, cols); + } + } + + // The guard releases with the iteration. + iterationActive_ = false; + iterationArchetypes_.clear(); + iterationReadComponents_.clear(); + return Status{}; +} + } // namespace laige diff --git a/src/laige-sim/include/laige/sim/query.h b/src/laige-sim/include/laige/sim/query.h new file mode 100644 index 0000000..f8528de --- /dev/null +++ b/src/laige-sim/include/laige/sim/query.h @@ -0,0 +1,292 @@ +// laige-sim query API + iteration legality (M1-ECS-04). +// +// FR-1.2/FR-1.3 (declared access, iteration legality; AGENTS CORE-008): +// this header ships the public types of the per-tick iteration API: +// +// Access The declared per-component access of a query: +// Read or Write (a value type; M1-SYS-01's system I/O +// declarations reuse it). +// Read / Write The per-component access tags — the access_flags of +// World::each. The tag's TYPE carries the access, so the +// callback's reference constness is a compile-time +// property (API-008: the invalid state is +// unrepresentable — a Read component comes out `const` +// and cannot be written through without a cast). +// +// World::each(fn, Read/Write tags...) +// Declared in entity.h (the World home) and defined in +// the same header (a template): iterates every entity +// having ALL the listed components (superset match: an +// archetype matches when every listed type is in its +// set; extra components do not exclude it). +// +// The PRD Appendix B sketch calls this `ctx.each<...>()`; in M1 the +// query lives on the World that owns the storage, and M1-SYS-01's +// SystemContext delegates to it (one world, one owner thread). The +// sketch's argument order (access flags first, callable last) is not +// the final syntax: C++ cannot deduce a parameter pack that is not +// last, so the tags follow the callable — one tag per listed +// component, in template order, still declared per component. +// +// --------------------------------------------------------------------------- +// Query semantics +// --------------------------------------------------------------------------- +// +// world.each(fn, Read{}, Write{}) +// +// - `fn` is invoked once per matching entity, as +// `fn(Entity, const ArchPos&, ArchVel&)`: one reference per listed +// component, in template order — a `const T&` where the access is +// Read, a `T&` where it is Write. The Entity handle is the +// generation-checked live handle of the row's slot. +// - Superset match: an entity with {ArchPos, ArchVel, ArchFlag} is +// visited by `each`; an entity with {ArchPos} +// only is not. +// - `each<>` (no components) visits every LIVE entity — including +// component-less ones — in ascending slot-id order. It iterates +// no archetype rows, so no component storage is under an +// iterator: structural mutations stay legal under it (see below). +// - A listed component that is not registered in this world matches +// nothing: the iteration runs zero times and returns an ok Status +// (a pure query, like has() reading false). +// - `each` returns a Status: ok on every completed iteration (a +// zero-match iteration is ok), `ErrorCode::InvalidArgument` when +// a nested iteration is rejected (below). +// +// --------------------------------------------------------------------------- +// Iteration order (M1-ECS-05 documents the contract) +// --------------------------------------------------------------------------- +// +// Archetypes in ascending archetype-id order (= creation order, +// the order component sets are first seen), and within an +// archetype in ascending entity slot-id order (the slot-ordered row +// scheme, archetype.h "Row order"). No unordered container is +// touched: the archetype scan is the bounded fixed table in id +// order, the guard sets are membership-only bit sets (never +// iterated), and the row walk is the packed slot column. The order +// is a pure function of the world state (which archetypes exist +// and which slots are live in each), never of the operation +// history — M1-ECS-05 pins the convergence property over it. +// +// --------------------------------------------------------------------------- +// Iteration legality (FR-1.3: the engine enforces it, FR-12.3) +// --------------------------------------------------------------------------- +// +// At most ONE iteration is active per world at a time (M1 is +// single-threaded; the guard is world state on the owner thread). +// While `each` is active, the world records: +// +// - the MATCHED set: the archetypes the query visits (complete +// before the first callback runs — the archetype scan finishes +// before any callback), and +// - the READ set: the queried components declared Read. +// +// A World-API mutation is then checked against the guard: +// +// LEGAL +// - an in-place create-or-update overwrite (addComponent on an +// entity that already has T) of a component declared Write, or +// of a component the query does not list at all (its column is +// not under an iterator; no row moves); +// - a structural change (addComponent/removeComponent that moves +// the entity between archetypes, destroy, clear) whose SOURCE +// and TARGET archetypes are both outside the matched set; +// - create() — it touches no archetype rows (a new slot, no +// components); +// - the callback writing through its Write references — the +// intended per-tick mutation path (no World call, no row move). +// +// ILLEGAL (the mutation is REJECTED — skipped, never applied): +// - an in-place write to a queried component declared Read +// ("write during a read iteration"); +// - a structural change whose source OR target archetype is in +// the matched set ("add/remove during iteration of the +// affected archetype" — the tail of that archetype would shift +// under the iterator); +// - clear() while any matched archetype still holds live rows; +// - a nested each() while an iteration is active. +// +// The rejected mutation's row moves nowhere, so the running +// iteration's view stays valid and the iteration CONTINUES over the +// unmutated storage (the roadmap's "skip-with-log"). +// +// Build behavior of a violation (the same split as the stale-handle +// uses, M1-ECS-01 S-9; FR-12.3, CORE-008: never silent): +// +// debug builds assert() fires in the mutating call (a loud +// SIGABRT with an actionable message); +// release builds the mutation returns `ErrorCode::InvalidArgument`, +// one rate-limited structured Warn is logged (LOG-004), +// and the mutation is skipped. +// +// The guard is NOT a check on raw pointers: writing through a +// `get(e)` pointer during a read iteration bypasses the guard — +// the access tags are the contract, and systems write through the +// query's Write references, not through get (misuse warning +// below). get/has/check/isValid stay pure reads and are never +// rejected by the guard. +// +// --------------------------------------------------------------------------- +// Allocation and complexity (PERF-003, PERF-007) +// --------------------------------------------------------------------------- +// +// each archetype scan O(kMaxArchetypes × N) bounded +// passes (N = listed components, ≤ 32) + one row +// visit per matching entity. No heap allocation +// anywhere in the query path: the iteration state +// is stack-scoped (two fixed id arrays of ≤ 32 +// entries, one 256-entry match list, two 256-bit +// guard sets on the World) and the guard is +// rebuilt per call from fixed-size state (no pool +// needed — the state is one level deep, one +// iteration at a time). No logging on the success +// path (LOG-003), no lock (single owner thread). +// guard check a few loads per World-API mutation: the active +// flag plus up to two 256-bit membership tests +// (4 × 64-bit words). Negligible on the hot path. +// +// The standing zero-allocation assertion lands with M1-ALLOC-01; +// until then the QueryZeroAlloc suite (test-only operator-new +// counter, non-sanitizer trees) plus the ASan run is the check. +// +// --------------------------------------------------------------------------- +// Errors and logging (FR-12.1, CORE-008, LOG-001/002/004) +// --------------------------------------------------------------------------- +// +// nested each() while an iteration is active +// -> InvalidArgument + warn +// (ecs/iteration_nested); assert in +// debug +// in-place write to a Read-declared queried component +// -> InvalidArgument + warn +// (ecs/iteration_write_during_read); +// assert in debug +// structural mutation (add/remove/destroy) touching an iterated +// archetype +// -> InvalidArgument + warn +// (ecs/iteration_mutation); assert in +// debug +// clear() with live rows in an iterated archetype +// -> InvalidArgument + warn +// (ecs/iteration_clear); assert in +// debug +// +// Repeats of each event are rate-limited per (subsystem, event, +// severity) with the suppressed-count summary (LOG-004). +// +// --------------------------------------------------------------------------- +// Threading and determinism +// --------------------------------------------------------------------------- +// +// Single owner thread (CONC-001); the guard is World state mutated in +// an explicit phase (the each() call) — no concurrent access (M1 is +// single-threaded, PRD §10.2). +// +// Determinism (ARCH-010): the visit order is pure integer order over +// the fixed tables (archetype id, slot id) — no floating point, no +// randomness, no addresses. Two worlds that reach the same state +// through different operation histories visit identically (M1-ECS-05 +// pins it); the guard sets carry only membership (never an iteration +// order), so they add no non-determinism. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - One access tag per listed component, in template order: +// `each(fn, Read, Write)` pairs Read with T1 and Write +// with T2. The count is checked at compile time; a mismatched tag +// type or count is a compile error, not a runtime surprise. +// - A Read reference is const: writing through it (a cast) defeats +// both the type system and the legality guard. Declare Write for +// a component a system mutates. +// - Never write through `get(e)` during a read iteration: the +// guard cannot see pointer writes. The query's Write reference is +// the mutation path. +// - A rejected mutation in release is a Status, not a crash: check +// it (CORE-008). The usual cause is entity lifecycle inside a +// per-tick system — move create/destroy/addComponent-set-changes +// to a spawn/despawn system (the G-R4 advice, M1-ECS-06). +// - The empty query `each<>` sees a changing live set if the +// callback destroys entities: a destroyed entity is simply not +// visited when the scan reaches its slot (documented despawn-sweep +// semantics, not an error). +// - Holding a query reference past the callback (or across another +// mutation of that entity) is dangling: the row can move. Copy +// out what must survive (PERF-005). + +#pragma once + +#include +#include +#include + +#include "laige/sim/archetype.h" + +namespace laige { + +// The declared per-component access of a query (FR-1.3). Read: the +// component is only read during the iteration; Write: the system +// mutates it (through the query's reference or an in-place +// addComponent overwrite — both legal, see the preamble "Iteration +// legality"). M1-SYS-01's system I/O declarations reuse this value +// type. +enum class Access : std::uint8_t { + Read = 0, + Write = 1, +}; + +// The access tags of World::each — one per listed component, in +// template order (the preamble "Query semantics"). The TYPE carries +// the access, so the callback's reference constness is decided at +// compile time: +// +// world.each(Read{}, Write{}, fn) +// -> fn(Entity, const Pos&, Vel&) +struct Read { + static constexpr Access value = Access::Read; +}; +struct Write { + static constexpr Access value = Access::Write; +}; + +namespace detail { + +// A fixed 256-bit set over 1-based ids: bit (id - 1) of word +// (id - 1) / 64. The iteration guard's state (M1-ECS-04): both guard +// sets index the 1..256 id spaces (archetype ids ≤ kMaxArchetypes, +// component ids ≤ kMaxComponentTypes — both 256). Membership tests +// ONLY — the set is never iterated, so its word order is not an +// iteration order (PERF-006: no unordered containers in sim paths; +// M1-ECS-05). Fixed 4 × 64-bit words: no allocation. +struct IdSet256 { + std::uint64_t word[kMaxArchetypes / 64]{}; + + // True when `id` (1..256) is a member. id 0 ("no archetype" / + // unregistered) is never a member. + bool contains(std::uint32_t id) const noexcept { + if (id == 0) return false; + return (word[(id - 1) / 64] & (1uLL << ((id - 1) % 64))) != 0; + } + + // Insert `id`; id 0 is a no-op. + void set(std::uint32_t id) noexcept { + if (id == 0) return; + word[(id - 1) / 64] |= 1uLL << ((id - 1) % 64); + } + + void clear() noexcept { + word[0] = 0; + word[1] = 0; + word[2] = 0; + word[3] = 0; + } + + bool empty() const noexcept { + return (word[0] | word[1] | word[2] | word[3]) == 0; + } +}; + +} // namespace detail + +} // namespace laige diff --git a/src/laige-sim/query.cpp b/src/laige-sim/query.cpp new file mode 100644 index 0000000..d3c7fd5 --- /dev/null +++ b/src/laige-sim/query.cpp @@ -0,0 +1,128 @@ +// laige-sim World iteration-legality guard (M1-ECS-04). +// +// Implementation of the guard helpers declared in +// include/laige/sim/entity.h — see include/laige/sim/query.h for the +// full contract (query semantics, the legality rules, the guard +// lifecycle, the error table, the no-allocation guarantee). +// +// Behavior of a violation (FR-12.3, CORE-008, S-9): the assert fires in +// debug builds (a loud SIGABRT with an actionable message); release +// builds log one rate-limited structured Warn (LOG-004) and return +// ErrorCode::InvalidArgument, and the caller skips the mutation — +// never applied, never silent. + +#include "laige/sim/entity.h" + +#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"; + +} // namespace + +Status World::guardIterationStart() noexcept { + if (iterationActive_) { + assert(!iterationActive_ && + "World::each: a nested iteration started while another " + "iteration is active — one active iteration per world " + "(M1-ECS-04, query.h); end the outer iteration before " + "starting another"); + LAIGE_LOG_WARN(kEcsSubsystem, "iteration_nested", + "A World::each iteration started while another " + "iteration is active; the nested iteration was " + "rejected"); + return ErrorCode::InvalidArgument; + } + return Status{}; +} + +Status World::guardInplaceWrite(std::uint32_t componentId, + std::uint32_t archIdx, + Entity entity) noexcept { + if (iterationActive_ && iterationArchetypes_.contains(archIdx) && + iterationReadComponents_.contains(componentId)) { + assert(false && + "World::addComponent: an in-place write to a component the " + "active World::each iteration declared Read (a write during " + "a read iteration — M1-ECS-04, query.h); declare Write " + "access for the component in the query, or perform the " + "update outside the iteration"); + LAIGE_LOG_WARN(kEcsSubsystem, "iteration_write_during_read", + "An in-place component write was rejected: the " + "component is declared Read for the active " + "World::each iteration; the mutation was skipped", + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation), + laige::log::field("component_id", componentId), + laige::log::field("archetype_id", archIdx)); + return ErrorCode::InvalidArgument; + } + return Status{}; +} + +Status World::guardStructural(const char* op, std::uint32_t sourceArch, + std::uint32_t targetArch, + Entity entity) noexcept { + // A structural change (an archetype move, a destroy, or a clear — + // the caller names the operation in `op`) is illegal when its + // source OR target archetype is in the active iteration's matched + // set: the tail of an iterated archetype would shift under the + // iterator (M1-ECS-04, query.h). 0 = "no archetype" and is never a + // member of the matched set. + if (iterationActive_ && + (iterationArchetypes_.contains(sourceArch) || + iterationArchetypes_.contains(targetArch))) { + assert(false && + "World component mutation (" + "addComponent/removeComponent/destroy): a structural " + "change — an archetype move, or a destroy — while the " + "affected archetype is being iterated by World::each " + "(M1-ECS-04, query.h); perform entity lifecycle in a " + "spawn/despawn system, or end the iteration first"); + LAIGE_LOG_WARN(kEcsSubsystem, "iteration_mutation", + "A structural component mutation was rejected while " + "the affected archetype is being iterated; the " + "mutation was skipped", + laige::log::field("op", op), + laige::log::field("entity_id", entity.id), + laige::log::field("generation", entity.generation), + laige::log::field("source_archetype", sourceArch), + laige::log::field("target_archetype", targetArch)); + return ErrorCode::InvalidArgument; + } + return Status{}; +} + +Status World::guardClear() noexcept { + if (!iterationActive_) return Status{}; + // clear() detaches every live row; it is illegal exactly when a + // matched archetype of the active iteration still holds live rows + // (an O(kMaxArchetypes) bounded pass over the fixed table — clear() + // is a shutdown-path operation, never a hot path). + for (std::uint32_t i = 0; i < archetypeCount_; ++i) { + if (iterationArchetypes_.contains(i + 1) && archetypes_[i].size > 0) { + assert(false && + "World::clear: clearing entities of an archetype currently " + "being iterated by World::each (M1-ECS-04, query.h); end " + "the iteration before clearing the world"); + LAIGE_LOG_WARN(kEcsSubsystem, "iteration_clear", + "clear() was rejected: live entities of an " + "archetype being iterated by World::each exist; " + "the clear was skipped", + laige::log::field("archetype_id", i + 1), + laige::log::field("rows", archetypes_[i].size)); + return ErrorCode::InvalidArgument; + } + } + return Status{}; +} + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index ba355ad..075f3ad 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,16 +1,18 @@ -# laige-sim tests (M1-ECS-01/02/03): entity handle + World entity -# storage, component type registry, archetype SoA storage. +# laige-sim tests (M1-ECS-01/02/03/04): entity handle + World entity +# storage, component type registry, archetype SoA storage, query API + +# iteration legality. # # 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`, -# `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. +# `component_registry`, `archetype`, and `query` entries are the +# M1-ECS-01, M1-ECS-02, M1-ECS-03, and M1-ECS-04 Verify commands +# (`ctest -R entity`, `ctest -R component_registry`, `ctest -R archetype`, +# `ctest -R query`), selecting exactly the suites below from the shared +# executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp - archetype_tests.cpp) + archetype_tests.cpp query_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, @@ -70,9 +72,18 @@ add_test(NAME archetype COMMAND laige-sim_tests --gtest_filter=Archetype*) +# M1-ECS-04: query API + iteration legality. The step's Verify command +# is `ctest -R query`; this entry selects exactly the Query* suites +# from the shared laige-sim_tests executable (the 10k-entity +# zero-alloc window's machine-greppable stats lines land in the ctest +# output). +add_test(NAME query + COMMAND laige-sim_tests + --gtest_filter=Query*) + 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 archetype - PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + query 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 index d67ec4b..4b61491 100644 --- a/tests/laige-sim/archetype_tests.cpp +++ b/tests/laige-sim/archetype_tests.cpp @@ -1035,7 +1035,7 @@ TEST(ArchetypeLifetime, ClearDetachesAllRows) { } EXPECT_EQ(world.archetypeStats().rowsLive, 3u); - world.clear(); + ASSERT_TRUE(world.clear().ok()); // Every handle is stale and every row is released; the archetypes // and the type registry survive (setup state). diff --git a/tests/laige-sim/component_registry_tests.cpp b/tests/laige-sim/component_registry_tests.cpp index 1b957df..5d68506 100644 --- a/tests/laige-sim/component_registry_tests.cpp +++ b/tests/laige-sim/component_registry_tests.cpp @@ -342,7 +342,7 @@ TEST(ComponentRegistry, ClearLeavesRegistryUntouched) { auto a = world.registerComponent(); ASSERT_TRUE(a.ok()); ASSERT_TRUE(world.destroy(e.value()).ok()); - world.clear(); + ASSERT_TRUE(world.clear().ok()); EXPECT_EQ(world.entityCount(), 0u); EXPECT_EQ(world.componentCount(), 1u); auto info = world.componentInfo(a.value()); diff --git a/tests/laige-sim/entity_tests.cpp b/tests/laige-sim/entity_tests.cpp index ce5d464..ff044ae 100644 --- a/tests/laige-sim/entity_tests.cpp +++ b/tests/laige-sim/entity_tests.cpp @@ -215,7 +215,7 @@ TEST(WorldBasics, ClearInvalidatesAllAndIsReusable) { ASSERT_TRUE(r.ok()); e2 = r.value(); } - world.clear(); + ASSERT_TRUE(world.clear().ok()); EXPECT_EQ(world.entityCount(), 0u); EXPECT_FALSE(world.isValid(e0)); EXPECT_FALSE(world.isValid(e1)); @@ -227,7 +227,7 @@ TEST(WorldBasics, ClearInvalidatesAllAndIsReusable) { ASSERT_TRUE(r.ok()); EXPECT_EQ(r.value().id, 2u); // LIFO: the top of the cleared stack EXPECT_EQ(r.value().generation, 2u); - world.clear(); // idempotent + ASSERT_TRUE(world.clear().ok()); // idempotent EXPECT_EQ(world.entityCount(), 0u); } @@ -324,7 +324,7 @@ TEST(WorldCapacity, ZeroCapacityRefusesEveryCreate) { EXPECT_FALSE(r.ok()); EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); EXPECT_FALSE(world.isValid(laige::Entity{})); - world.clear(); // idempotent on an empty world + ASSERT_TRUE(world.clear().ok()); // idempotent on an empty world EXPECT_EQ(world.entityCount(), 0u); } @@ -371,7 +371,7 @@ TEST(WorldStale, IsFalseForStaleClearedOutOrRangeAndDefault) { // The default handle is never valid: EXPECT_FALSE(world.isValid(laige::Entity{})); // Cleared: - world.clear(); + ASSERT_TRUE(world.clear().ok()); EXPECT_FALSE(world.isValid(e0)); } diff --git a/tests/laige-sim/query_tests.cpp b/tests/laige-sim/query_tests.cpp new file mode 100644 index 0000000..8bfc7d4 --- /dev/null +++ b/tests/laige-sim/query_tests.cpp @@ -0,0 +1,1022 @@ +// laige-sim query API + iteration-legality suite (M1-ECS-04). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - ctx.each(access_flags, fn) — in M1 the query is +// World::each (M1-SYS-01's SystemContext delegates to it): iterate +// the entities having ALL the listed components (superset match), +// access declared per component (Read / Write tags; the callback +// gets a const T& for Read, a T& for Write) +// - iteration-legality enforcement: a write during a read +// iteration, or an add/remove/destroy/clear touching an iterated +// archetype (and a nested each()) asserts in debug (forked +// SIGABRT child) and returns Status + skip-with-log in release +// (FR-12.3; the rules are documented in query.h) +// - no hidden allocations in the query path: the 10k-entity +// iteration window allocates zero heap (test-only operator-new +// counter, non-sanitizer trees; the sanitizer trees cover the +// property with their leak-free run of the same loop) +// - mixed-component queries return the exact sets; the visit order +// is archetype-id order, then ascending slot order (M1-ECS-05 +// pins the deterministic contract over this order) +// +// Runs as CTest `query` (the step's Verify command: `ctest -R query`): +// a filtered view of the shared laige-sim_tests executable, selecting +// exactly the suites below (the Query* prefix). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim/entity.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +#if defined(__unix__) +#include +#include +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "query_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "query_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, + "query_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 QUERY_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define QUERY_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if QUERY_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "query_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. QueryUnreg is marked but never registered (the +// "unregistered query matches nothing" property). +// --------------------------------------------------------------------------- + +struct QueryPos { + std::int32_t x; + std::int32_t y; +}; +LAIGE_COMPONENT(QueryPos) + +struct QueryVel { + std::int64_t v; +}; +LAIGE_COMPONENT(QueryVel) + +struct QueryFlag { + std::int32_t f; +}; +LAIGE_COMPONENT(QueryFlag) + +struct QueryUnreg { + std::int32_t v; +}; +LAIGE_COMPONENT(QueryUnreg) + +namespace { + +// One world, taken out of its Result so the test holds a mutable +// lvalue (Result::value() is const; takeValue() && moves the storage +// out — the documented ownership-transfer path). +laige::World makeWorld(std::uint32_t capacity) { + auto w = laige::World::create(laige::World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +// The visited handles' slot ids, sorted — the exact-set comparison +// (visit order is pinned separately, where the test cares about it). +std::vector sortedSlotIds(const std::vector& v) { + std::vector out; + out.reserve(v.size()); + for (const auto& e : v) out.push_back(e.id); + std::sort(out.begin(), out.end()); + return out; +} + +// The expected slot ids, sorted (the set comparison is order-blind). +std::vector sortedIds(std::initializer_list ids) { + std::vector out(ids); + std::sort(out.begin(), out.end()); + return out; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The forked debug-assert child (POSIX, debug builds only) +// +// One world, one live entity e = {QueryPos} (plus QueryVel for the +// remove case), then scenario k runs ONE illegal mutation inside an +// active each(Read) iteration. The M1-ECS-04 guard must +// abort the process (SIGABRT) inside the mutation; the trailing +// _exit(1) is reached only when NO assert fired (a regression — the +// parent fails the test). The child builds its world itself (no +// gtest calls after fork()). +// --------------------------------------------------------------------------- + +#if defined(__unix__) && !defined(NDEBUG) +void runGuardViolationCase(std::uint32_t k) { + auto w = laige::World::create(laige::World::Options{2}); + if (!w.ok()) _exit(117); + laige::World world = std::move(w).takeValue(); + if (!world.registerComponent().ok() || + !world.registerComponent().ok()) { + _exit(117); + } + auto r = world.create(); + if (!r.ok()) _exit(117); + const laige::Entity e = r.value(); + if (!world.addComponent(e, QueryPos{1, 2}).ok()) _exit(117); + if (k == 2 && !world.addComponent(e, QueryVel{7}).ok()) { + _exit(117); + } + switch (k) { + case 0: // in-place write to the Read-declared component + (void)world.each([&](laige::Entity e2, const QueryPos&) { + (void)world.addComponent( + e2, QueryPos{9, 9}); + }, + laige::Read{}); + break; + case 1: // structural add: {Pos} -> {Pos,Vel}; the source is iterated + (void)world.each([&](laige::Entity e2, const QueryPos&) { + (void)world.addComponent( + e2, QueryVel{9}); + }, + laige::Read{}); + break; + case 2: // structural remove: e leaves the iterated archetype + (void)world.each([&](laige::Entity e2, const QueryPos&) { + (void)world.removeComponent(e2); + }, + laige::Read{}); + break; + case 3: // destroy of an iterated entity + (void)world.each([&](laige::Entity e2, const QueryPos&) { + (void)world.destroy(e2); + }, + laige::Read{}); + break; + case 4: // clear() under the active iteration + (void)world.each([&](laige::Entity, const QueryPos&) { + (void)world.clear(); + }, + laige::Read{}); + break; + case 5: // nested iteration + (void)world.each([&](laige::Entity, const QueryPos&) { + (void)world.each( + [](laige::Entity, const QueryPos&) {}, + laige::Read{}); + }, + laige::Read{}); + break; + default: + _exit(118); + } + _exit(1); // unreachable when the guard works +} +#endif + +// The six debug-assert tests: one per illegal-mutation scenario. +// POSIX + debug only (fork); the Windows and release jobs skip with +// a reason (the release degradation path is the *InRelease test). +#if defined(__unix__) +# if defined(NDEBUG) +# define QUERY_DEBUG_ASSERT_TEST(Name, CaseId) \ + TEST(QueryLegality, Name) { \ + GTEST_SKIP() << "assert-based iteration-legality detection is a " \ + "debug-build property (NDEBUG build)"; \ + } +# else +# define QUERY_DEBUG_ASSERT_TEST(Name, CaseId) \ + TEST(QueryLegality, Name) { \ + const pid_t pid = fork(); \ + ASSERT_GE(pid, 0); \ + if (pid == 0) { \ + runGuardViolationCase(CaseId); \ + } \ + int status = 0; \ + ASSERT_EQ(waitpid(pid, &status, 0), pid); \ + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT) \ + << "expected the iteration-legality assert to abort the child " \ + "(SIGABRT)"; \ + } +# endif +#else +# define QUERY_DEBUG_ASSERT_TEST(Name, CaseId) \ + TEST(QueryLegality, Name) { \ + GTEST_SKIP() << "fork() is not available on Windows; the debug assert " \ + "is exercised on the POSIX jobs."; \ + } +#endif + +// --------------------------------------------------------------------------- +// Query semantics: exact sets, order, unregistered types, access tags +// --------------------------------------------------------------------------- + +TEST(QueryBasics, MixedComponentsExactSet) { + // Six entities spanning five component sets; every query returns + // exactly its superset match (extra components do not exclude an + // entity; a missing one does). + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e0, e1, e2, e3, e4, e5; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); e0 = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); e1 = r2.value(); + auto r3 = world.create(); ASSERT_TRUE(r3.ok()); e2 = r3.value(); + auto r4 = world.create(); ASSERT_TRUE(r4.ok()); e3 = r4.value(); + auto r5 = world.create(); ASSERT_TRUE(r5.ok()); e4 = r5.value(); + auto r6 = world.create(); ASSERT_TRUE(r6.ok()); e5 = r6.value(); + } + ASSERT_TRUE(world.addComponent(e0, QueryPos{0, 0}).ok()); + ASSERT_TRUE(world.addComponent(e1, QueryPos{10, 11}).ok()); + ASSERT_TRUE(world.addComponent(e1, QueryVel{100}).ok()); + ASSERT_TRUE(world.addComponent(e2, QueryPos{20, 21}).ok()); + ASSERT_TRUE(world.addComponent(e2, QueryVel{200}).ok()); + ASSERT_TRUE(world.addComponent(e2, QueryFlag{2}).ok()); + ASSERT_TRUE(world.addComponent(e3, QueryVel{300}).ok()); + ASSERT_TRUE(world.addComponent(e5, QueryPos{50, 51}).ok()); + ASSERT_TRUE(world.addComponent(e5, QueryFlag{5}).ok()); + // e4 carries no components. + + // matches {Pos,Vel} (e1) and {Pos,Vel,Flag} (e2) — e2 is + // the superset case: its extra Flag does not exclude it. + { + std::vector vis; + std::int32_t sumX = 0; + std::int64_t sumV = 0; + auto s = world.each( + [&](laige::Entity e, const QueryPos& p, const QueryVel& v) { + vis.push_back(e); + sumX += p.x; + sumV += v.v; + }, + laige::Read{}, laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(vis.size(), 2u); + EXPECT_EQ(sortedSlotIds(vis), sortedIds({e1.id, e2.id})); + EXPECT_EQ(sumX, 10 + 20); + EXPECT_EQ(sumV, 100 + 200); + } + // matches exactly the two Flag carriers. + { + std::vector vis; + std::int32_t sumF = 0; + auto s = world.each([&](laige::Entity e, const QueryFlag& f) { + vis.push_back(e); + sumF += f.f; + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(sortedSlotIds(vis), sortedIds({e2.id, e5.id})); + EXPECT_EQ(sumF, 2 + 5); + } + // matches the four Pos carriers. + { + std::vector vis; + auto s = world.each([&](laige::Entity e, const QueryPos&) { + vis.push_back(e); + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(sortedSlotIds(vis), sortedIds({e0.id, e1.id, e2.id, e5.id})); + } + // matches {Pos,Flag} (e5) and {Pos,Vel,Flag} (e2). + { + std::vector vis; + auto s = world.each( + [&](laige::Entity e, const QueryPos&, const QueryFlag&) { + vis.push_back(e); + }, + laige::Read{}, laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(sortedSlotIds(vis), sortedIds({e2.id, e5.id})); + } +} + +TEST(QueryBasics, EmptyQueryVisitsAllLiveInSlotOrder) { + // each<> visits every live entity (component-less ones included) in + // ascending slot-id order. + laige::World world = makeWorld(8); + laige::Entity e0, e1, e2, e3; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); e0 = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); e1 = r2.value(); + auto r3 = world.create(); ASSERT_TRUE(r3.ok()); e2 = r3.value(); + auto r4 = world.create(); ASSERT_TRUE(r4.ok()); e3 = r4.value(); + } + ASSERT_TRUE(world.destroy(e2).ok()); + std::vector vis; + auto s = world.each<>([&](laige::Entity e) { vis.push_back(e.id); }); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(vis.size(), 3u); + // Strictly ascending slot order: + for (std::size_t i = 1; i < vis.size(); ++i) { + EXPECT_LT(vis[i - 1], vis[i]); + } + EXPECT_EQ(vis, + (std::vector{e3.id, e1.id, e0.id})); +} + +TEST(QueryBasics, EmptyQueryToleratesConcurrentDestroy) { + // The empty query iterates no archetype rows, so a destroy inside + // the callback is LEGAL (query.h): the destroyed entity is simply + // not visited when the scan has not reached it yet; a destroyed + // entity is never re-visited. No guard violation, no Status. + laige::World world = makeWorld(8); + laige::Entity e0, e1, e2, e3; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); e0 = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); e1 = r2.value(); + auto r3 = world.create(); ASSERT_TRUE(r3.ok()); e2 = r3.value(); + auto r4 = world.create(); ASSERT_TRUE(r4.ok()); e3 = r4.value(); + } + int visits = 0; + int destroys = 0; + auto s = world.each<>([&](laige::Entity e) { + visits++; + if (e == e1) { + destroys += world.destroy(e).ok() ? 1 : 0; + } + }); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(visits, 4); // e1 was visited before its destroy ran + EXPECT_EQ(destroys, 1); + EXPECT_FALSE(world.isValid(e1)); + EXPECT_EQ(world.entityCount(), 3u); +} + +TEST(QueryBasics, UnregisteredTypeMatchesNothing) { + // A listed component not registered in this world matches nothing: + // the iteration runs zero times and returns ok (a pure query, like + // has reading false). No archetype is created for the query. + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int visits = 0; + auto s = world.each( + [&](laige::Entity, const QueryUnreg&) { visits++; }, laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(visits, 0); + EXPECT_EQ(world.archetypeCount(), 1u); +} + +TEST(QueryBasics, AccessTagsDetermineReferenceKind) { + // Read gives a const reference (a write through it would not + // compile); Write gives a mutable reference — the intended mutation + // path, legal by the guard (a write during a WRITE iteration). + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(e, QueryVel{0}).ok()); + + int reads = 0; + auto s = world.each([&](laige::Entity, const QueryPos& p) { + reads += (p.x == 1 && p.y == 2) ? 1 : 0; + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(reads, 1); + + int writes = 0; + auto s2 = world.each( + [&](laige::Entity, const QueryPos& p, QueryVel& v) { + if (p.x == 1) { + v.v = 12345; + writes++; + } + }, + laige::Read{}, laige::Write{}); + ASSERT_TRUE(s2.ok()); + EXPECT_EQ(writes, 1); + EXPECT_EQ(world.get(e)->v, 12345); +} + +TEST(QueryBasics, IterationOrderIsArchetypeThenSlot) { + // Visit order = ascending archetype id (creation order), then + // ascending slot id within the archetype (M1-ECS-05 pins this as + // the deterministic contract). + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity a, b, c, d; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); a = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); b = r2.value(); + auto r3 = world.create(); ASSERT_TRUE(r3.ok()); c = r3.value(); + auto r4 = world.create(); ASSERT_TRUE(r4.ok()); d = r4.value(); + } + // Archetype creation order: {Pos} (a) -> {Pos,Vel} (a) -> {Vel} (c). + ASSERT_TRUE(world.addComponent(a, QueryPos{0, 0}).ok()); + ASSERT_TRUE(world.addComponent(a, QueryVel{0}).ok()); + ASSERT_TRUE(world.addComponent(b, QueryPos{1, 0}).ok()); + ASSERT_TRUE(world.addComponent(c, QueryVel{1}).ok()); + // d joins the existing {Pos} archetype (no new archetype). + ASSERT_TRUE(world.addComponent(d, QueryPos{2, 0}).ok()); + + // visits {Pos} (id 1: d, b in slot order) then {Pos,Vel} + // (id 2: a). + { + std::vector vis; + auto s = world.each([&](laige::Entity e, const QueryPos&) { + vis.push_back(e.id); + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(vis, + (std::vector{d.id, b.id, a.id})); + } + // visits {Pos,Vel} (id 2: a) then {Vel} (id 3: c). + { + std::vector vis; + auto s = world.each([&](laige::Entity e, const QueryVel&) { + vis.push_back(e.id); + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(vis, (std::vector{a.id, c.id})); + } + // visits only {Pos,Vel}. + { + std::vector vis; + auto s = world.each( + [&](laige::Entity e, const QueryPos&, const QueryVel&) { + vis.push_back(e.id); + }, + laige::Read{}, laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(vis, (std::vector{a.id})); + } +} + +// --------------------------------------------------------------------------- +// Iteration legality: the legal mutations, the guard's release, and the +// release-mode skip behavior +// --------------------------------------------------------------------------- + +TEST(QueryLegality, LegalMutationsDuringIteration) { + // The legal side of the legality rules (query.h): create() touches + // no rows; a structural move whose source AND target archetypes are + // outside the matched set is legal; Write references and in-place + // addComponent overwrites of Write-declared components are the + // intended mutation paths. + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity eIter, eOther; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); eIter = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); eOther = r2.value(); + } + ASSERT_TRUE(world.addComponent(eIter, QueryPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(eIter, QueryVel{0}).ok()); + ASSERT_TRUE(world.addComponent(eOther, QueryFlag{9}).ok()); + + laige::Entity created{}; + int visits = 0, creates = 0, moves = 0; + auto s = world.each([&](laige::Entity, const QueryPos&) { + visits++; + auto r = world.create(); + if (r.ok()) { + created = r.value(); + creates++; + } + // {Flag} -> {Flag,Vel}: a new archetype + // (not in 's matched set) and the + // source {Flag} is not matched either. + moves += world.addComponent( + eOther, QueryVel{1}) + .ok() + ? 1 + : 0; + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(visits, 1); + EXPECT_EQ(creates, 1); + EXPECT_TRUE(world.isValid(created)); + EXPECT_EQ(moves, 1); + EXPECT_TRUE(world.has(eOther)); + EXPECT_TRUE(world.has(eOther)); + // {Pos} (eIter, now empty), {Pos,Vel} (eIter), {Flag} (eOther, now + // empty), {Flag,Vel} (eOther) — the move created the fourth + // archetype (empty archetypes are kept: archetype.h). + EXPECT_EQ(world.archetypeCount(), 4u); + // The iterated entity was untouched by the callback's mutations. + EXPECT_EQ(world.get(eIter)->v, 0); + + // Write-declared component: the reference store and the in-place + // addComponent overwrite are both legal writes. + int writes = 0; + auto s2 = world.each( + [&](laige::Entity e, const QueryPos&, QueryVel& v) { + v.v += 1; // direct store through the Write reference + writes += world.addComponent(e, QueryVel{7}).ok() ? 1 : 0; + }, + laige::Read{}, laige::Write{}); + ASSERT_TRUE(s2.ok()); + EXPECT_EQ(writes, 1); + EXPECT_EQ(world.get(eIter)->v, 7); +} + +TEST(QueryLegality, GuardReleasesAfterIteration) { + // The guard lives from the first callback to the last: after each() + // returns, the same structural mutation is legal again. + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int visits = 0; + auto s = world.each( + [&](laige::Entity, const QueryPos&) { visits++; }, laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(visits, 1); + // Legal now — no active iteration: + EXPECT_TRUE(world.addComponent(e, QueryVel{9}).ok()); + EXPECT_TRUE(world.has(e)); +} + +TEST(QueryLegality, IllegalMutationsSkippedInRelease) { + // The release degradation path (FR-12.3): every illegal mutation + // returns InvalidArgument, is SKIPPED (never applied), and the + // iteration CONTINUES over the unmutated storage. Debug builds + // assert instead (the *AbortsInDebug tests). +#ifdef NDEBUG + // In-place write to a Read-declared component: + { + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int calls = 0; + int errors = 0; + auto s = world.each([&](laige::Entity e2, const QueryPos&) { + calls++; + auto s2 = world.addComponent( + e2, QueryPos{9, 9}); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(calls, 1); + EXPECT_EQ(errors, 1); + EXPECT_EQ(world.get(e)->x, 1); // the write was skipped + // The same mutation succeeds after the iteration ended: + EXPECT_TRUE(world.addComponent(e, QueryPos{9, 9}).ok()); + EXPECT_EQ(world.get(e)->x, 9); + } + // Structural add (source archetype iterated); the iteration + // CONTINUES over both entities: + { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e0, e1; + { + auto r = world.create(); ASSERT_TRUE(r.ok()); e0 = r.value(); + auto r2 = world.create(); ASSERT_TRUE(r2.ok()); e1 = r2.value(); + } + ASSERT_TRUE(world.addComponent(e0, QueryPos{0, 0}).ok()); + ASSERT_TRUE(world.addComponent(e1, QueryPos{1, 0}).ok()); + int visits = 0, errors = 0; + auto s = world.each([&](laige::Entity e2, const QueryPos&) { + visits++; + auto s2 = world.addComponent( + e2, QueryVel{9}); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(visits, 2); // both entities visited + EXPECT_EQ(errors, 2); // both moves rejected + EXPECT_FALSE(world.has(e0)); + EXPECT_FALSE(world.has(e1)); + EXPECT_EQ(world.archetypeCount(), 1u); // no {Pos,Vel} created + } + // Structural remove (the entity leaves the iterated archetype): + { + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + ASSERT_TRUE(world.addComponent(e, QueryVel{7}).ok()); + int errors = 0; + auto s = world.each([&](laige::Entity e2, const QueryPos&) { + auto s2 = + world.removeComponent(e2); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(errors, 1); + EXPECT_TRUE(world.has(e)); // still in {Pos,Vel} + EXPECT_EQ(world.entityCount(), 1u); + } + // Destroy of an iterated entity: + { + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int errors = 0; + auto s = world.each([&](laige::Entity e2, const QueryPos&) { + auto s2 = world.destroy(e2); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(errors, 1); + EXPECT_TRUE(world.isValid(e)); // still alive + } + // clear() under the iteration: + { + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int errors = 0; + auto s = world.each([&](laige::Entity, const QueryPos&) { + auto s2 = world.clear(); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(errors, 1); + EXPECT_TRUE(world.isValid(e)); + EXPECT_EQ(world.entityCount(), 1u); + } + // Nested iteration: + { + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + int outer = 0, inner = 0, errors = 0; + auto s = world.each([&](laige::Entity, const QueryPos&) { + outer++; + auto s2 = world.each( + [&](laige::Entity, const QueryPos&) { + inner++; + }, + laige::Read{}); + if (s2.isError() && + s2.error() == + laige::ErrorCode::InvalidArgument) { + errors++; + } + }, + laige::Read{}); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(outer, 1); + EXPECT_EQ(inner, 0); // the nested iteration never ran + EXPECT_EQ(errors, 1); + } +#else + GTEST_SKIP() << "release degradation path (NDEBUG); the debug assert " + "path is covered by the *AbortsInDebug tests."; +#endif +} + +QUERY_DEBUG_ASSERT_TEST(InplaceWriteToReadComponentAbortsInDebug, 0) +QUERY_DEBUG_ASSERT_TEST(AddDuringIterationAbortsInDebug, 1) +QUERY_DEBUG_ASSERT_TEST(RemoveDuringIterationAbortsInDebug, 2) +QUERY_DEBUG_ASSERT_TEST(DestroyDuringIterationAbortsInDebug, 3) +QUERY_DEBUG_ASSERT_TEST(ClearDuringIterationAbortsInDebug, 4) +QUERY_DEBUG_ASSERT_TEST(NestedEachAbortsInDebug, 5) + +// --------------------------------------------------------------------------- +// Logging (LOG-001/002/004): one rate-limited warn per violation event, +// the suppressed repeats summarized on shutdown (release builds only — +// in debug the assert fires before the log) +// --------------------------------------------------------------------------- + +namespace { + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; this test owns its window and +// restores the default console sink at the end). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +} // namespace + +TEST(QueryLogging, IllegalMutationWarnsOnceInRelease) { +#ifdef NDEBUG + // The world setup comes first so its archetype_created Info goes to + // the console; the sink window then holds only the violation burst. + laige::World world = makeWorld(2); + auto reg = world.registerComponent(); + ASSERT_TRUE(reg.ok()); + const std::uint32_t posId = reg.value().value; + laige::Entity e; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + ASSERT_TRUE(world.addComponent(e, QueryPos{1, 2}).ok()); + + auto sink = std::make_unique(); + auto* 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()); + + // Three illegal in-place writes: one logged warn, two suppressed + // (the same rate-limit window the stale-handle suite relies on). + for (int i = 0; i < 3; ++i) { + ASSERT_TRUE(world + .each([&](laige::Entity e2, const QueryPos&) { + (void)world.addComponent( + e2, QueryPos{9, 9}); + }, + laige::Read{}) + .ok()); + } + // 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[0].subsystem, "ecs"); + EXPECT_EQ(sinkPtr->entries[0].event, "iteration_write_during_read"); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + bool foundEntity = false, foundGeneration = false; + bool foundComponent = false, foundArchetype = false; + for (const auto& [key, value] : sinkPtr->entries[0].fields) { + if (key == "entity_id" && + value == std::to_string(e.id)) foundEntity = true; + if (key == "generation" && + value == std::to_string(e.generation)) foundGeneration = true; + if (key == "component_id" && value == std::to_string(posId)) { + foundComponent = true; + } + if (key == "archetype_id") foundArchetype = true; + } + EXPECT_TRUE(foundEntity); + EXPECT_TRUE(foundGeneration); + EXPECT_TRUE(foundComponent); + EXPECT_TRUE(foundArchetype); + EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + bool foundEventField = false, foundCountField = false; + for (const auto& [key, value] : sinkPtr->entries[1].fields) { + if (key == "event" && value == "iteration_write_during_read") { + foundEventField = true; + } + if (key == "suppressed" && value == "2") foundCountField = true; + } + EXPECT_TRUE(foundEventField); + EXPECT_TRUE(foundCountField); + + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +#else + GTEST_SKIP() << "the warn path is release-only (NDEBUG): in debug builds " + "the assert fires before the log."; +#endif +} + +// --------------------------------------------------------------------------- +// Zero allocations in the query path (PERF-003; the M1-ALLOC-01 standing +// assertion lands later — the test-only operator-new counter, non- +// sanitizer trees; the sanitizer trees cover the property with their +// leak-free run of the same loop) +// --------------------------------------------------------------------------- + +TEST(QueryZeroAlloc, TenKEntityIterationZeroAlloc) { + 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; + entities.reserve(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + entities.push_back(r.value()); + ASSERT_TRUE(world.addComponent( + entities.back(), QueryPos{static_cast(i), 0}) + .ok()); + ASSERT_TRUE(world.addComponent(entities.back(), QueryVel{0}) + .ok()); + } + // Warm-up: exercise archetype growth (Flag added then removed from + // every entity) so the window under test starts in steady state. + for (std::uint32_t i = 0; i < kEntities; ++i) { + ASSERT_TRUE(world + .addComponent( + entities[i], QueryFlag{static_cast(i)}) + .ok()); + ASSERT_TRUE(world.removeComponent(entities[i]).ok()); + } + const laige::ArchetypeStats before = world.archetypeStats(); + EXPECT_EQ(before.rowsLive, kEntities); + + std::uint64_t visits = 0; + std::int64_t sumX = 0; +#if defined(LAIGE_ALLOC_COUNTER) + laige::test::resetAllocCounter(); +#endif + const auto t0 = std::chrono::steady_clock::now(); + // Pass 1: Read/Write — a legal in-place write through the reference + // on every visit (the system mutation path, no World call). + auto s = world.each( + [&](laige::Entity e, const QueryPos& p, QueryVel& v) { + visits++; + sumX += p.x; + v.v = static_cast(e.id); + }, + laige::Read{}, laige::Write{}); + ASSERT_TRUE(s.ok()); + // Pass 2: Read/Read — a pure read pass in the same window. + std::uint64_t visits2 = 0; + auto s2 = world.each([&](laige::Entity, const QueryPos&) { + visits2++; + }, + laige::Read{}); + ASSERT_TRUE(s2.ok()); + const auto t1 = std::chrono::steady_clock::now(); + EXPECT_EQ(visits, static_cast(kEntities)); + EXPECT_EQ(visits2, static_cast(kEntities)); + EXPECT_EQ(sumX, 49995000); // 0 + 1 + ... + 9999 + + // The writes landed (in place, no archetype change): the callback + // stored each entity's own slot id (first created = slot 9999, + // last created = slot 0 — LIFO slot allocation). + EXPECT_EQ(world.get(entities[0])->v, + static_cast(entities[0].id)); + EXPECT_EQ(world.get(entities[9999])->v, + static_cast(entities[9999].id)); + // No structural change: the reservation is unchanged (no growth, no + // moves) — the query path is pool-steady. + const laige::ArchetypeStats after = world.archetypeStats(); + EXPECT_EQ(after.rowsLive, kEntities); + EXPECT_EQ(after.totalReservations, before.totalReservations); + EXPECT_EQ(after.bytesReserved, before.bytesReserved); + EXPECT_EQ(after.totalAdds, before.totalAdds); + EXPECT_EQ(after.totalRemoves, before.totalRemoves); + EXPECT_EQ(after.totalArchetypeGrowth, before.totalArchetypeGrowth); + +#if defined(LAIGE_ALLOC_COUNTER) + const std::uint64_t allocs = laige::test::allocCounter(); + std::printf( + "query-iteration zero-alloc window: %llu heap allocations over " + "2 passes x %u entity visits\n", + static_cast(allocs), + static_cast(kEntities)); + EXPECT_EQ(allocs, 0u); +#else + // Sanitizer trees: the counter is excluded there; the leak-free + // sanitizer run of this same loop is the zero-allocation evidence + // (the M1-ECS-03 churn test's fallback, ASan + pool accounting). +#endif + // Measured, not assumed (CORE-001): the machine-greppable timing + // line for the M1-BENCH-01 tick baseline. + const double us = + std::chrono::duration(t1 - t0).count(); + std::printf( + "query-iteration %u visits x 2 passes took %.1f us (%.3f us/visit)\n", + static_cast(kEntities), us, us / (2.0 * kEntities)); +}