diff --git a/docs/README.md b/docs/README.md index e2102e0..70299cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,8 @@ 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; M1-ECS-04: the query API + iteration legality; M1-ECS-05: the deterministic iteration contract; M1-ECS-06: the -ECS guardrails G-R3/G-R4). +ECS guardrails G-R3/G-R4; M1-ECS-07: the ECS stress + memory +accounting suite; M1-SYS-01: the system registry). 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. @@ -53,6 +54,11 @@ still to land. order, entities in ascending slot id, the dense-id-order scheme under moves, no unordered containers in the iteration path, and the convergence property test (M1-ECS-05; `laige-sim`). +- [System registry](api/system_registry.md) — plain registered + functions (`LAIGE_SYSTEM` + `SystemDef`) with declared time budgets + (fpx16_16 ms) and declared component I/O (`Io`), + `World::registerSystem`/`system`/`systemCount`, and + `SystemContext`'s delegated `each` (M1-SYS-01; `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, diff --git a/docs/api/system_registry.md b/docs/api/system_registry.md new file mode 100644 index 0000000..307de73 --- /dev/null +++ b/docs/api/system_registry.md @@ -0,0 +1,166 @@ +# System registry (`World::registerSystem`, `LAIGE_SYSTEM`) + +The M1 system framework (M1-SYS-01; PRD §9.1 S-8, FR-1.3, AGENTS +API-006, PERF-003): plain, registered functions with declared time +budgets and declared component I/O. Public header: +`src/laige-sim/include/laige/sim/system.h` (`SystemId`, `SystemDef`, +`SystemFn`, `SystemContext`, `Io`, `SystemInfo`, the +`LAIGE_SYSTEM` macro, the `detail::SystemRecord`/trait types, the +full contract) plus the `World::registerSystem`/`system`/`systemCount` +members in `src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/systems.cpp` (the non-template World methods and the +`SystemInfo` queries) + the header-defined `registerSystem` template +(entity.h). Unit suite: `ctest -R system_registry` +(`tests/laige-sim/system_registry_tests.cpp`). + +A game's setup path registers components and systems once, in one +documented place: + +```cpp +// The plain system function (FR-1.3: no class, no inheritance). The +// LAIGE_SYSTEM macro declares it and builds its def. +LAIGE_SYSTEM(Movement, 1) +void Movement(laige::World& world, laige::SystemContext& ctx) { + world.each( + [](laige::Entity e, SimVel& v) { /* integrate */ }, laige::Write{}); +} + +// World setup (before the loop): +auto r = world.registerSystem(Movement_Def, + laige::Io{}, + laige::Io{}); +// r is a Result; check it (no exceptions). +``` + +## The system shape (FR-1.3) + +A system is a **plain free function** with the signature +`SystemFn = void (*)(World&, SystemContext&)` — no class, no +inheritance, no state object. The function plus its `SystemDef` +(the `Movement_Def` variable the macro builds) IS the system: + +- `world` — the world the system runs on (one world, one owner + thread; PRD §10.2: simulation is single-threaded). +- `ctx` — that tick's `SystemContext`: a non-owning view of the same + world whose `each(fn, Read/Write tags...)` delegates + to `World::each` (the PRD Appendix B sketch's `ctx.each<...>()`; + identical semantics, visit order, and iteration-legality behavior — + [query.md](query.md)). The context is built per system per tick by + the scheduler (M1-SYS-02); never store it across ticks. +- Systems are deterministic when the engine runs in deterministic + mode (M1-DET-01) and must stay within their declared budget + (M1-SYS-03 measures per-system time). + +`LAIGE_SYSTEM(Name, budget_ms)` (namespace scope, directly above the +function) expands to the function declaration plus + +```cpp +inline const laige::SystemDef Name##_Def = laige::SystemDef{ + #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; +``` + +so `Name` is both the C++ function name and the system's +registration name (stringified), and the def variable is `Name##Def`. +`budget_ms` is a numeric literal in milliseconds (1, 0.5, …); the +conversion to the exact `fpx16_16` happens once, at program start +(setup path, never a hot path). The macro and the function +definition live in the same translation unit. + +## SystemIds and registration (component.h id contract) + +`World::registerSystem(def, Io<...>...)` is a setup-phase operation +(before the loop), like `registerComponent`: + +- **Ids** — `SystemId`s are assigned in registration order, densely + from 1, per world (0 is reserved and never assigned). + `World::systemCount()` is the high water mark; + `World::system(id)` returns the `SystemInfo` snapshot for a valid + id, `ErrorCode::InvalidArgument` otherwise (a pure query, like + `componentInfo`). +- **Determinism (ARCH-010)** — id assignment and the name-uniqueness + check are pure integer/string bookkeeping: no addresses, hashes, + or platform state enter the id. Two worlds, two process runs, or + two builds that register the same systems in the same order + produce bit-identical id sequences, so SystemIds are replay state + from M1 on (M1-DET-01/02). Ids are per-world: never compare them + across worlds (the `Entity`/`ComponentTypeId` cross-world caveat). +- **Engine budget** — `kMaxSystems` (256) is the engine-level cap on + systems per world (CORE-005, the `kMaxComponentTypes` precedent). + Registering a 257th system is `BudgetExhausted`, never silent. +- **Lifetime** — the def is **copied by value** into the world's + fixed record table at registration, so a def on the stack is safe; + the record table is the world's setup-path allocation (like the + component registry). The registry travels with the world on move + and survives `clear()` (a system is not per-entity data). + +## Declared component I/O (FR-1.3) + +The declared I/O is part of the **registration**, not the def: +component ids are per-world runtime values (component.h) and cannot +be baked into a compile-time def. One `Io{}` tag value per +declared component: + +- `T` must be a Laige component (`LAIGE_COMPONENT`) **registered in + this world**; a non-component `T` is a compile error + (static_assert in `registerSystem`). +- A component appears **at most once** per system, in any access + combination (`Read+Write` of the same component is ambiguous — + rejected). The declaration is a set, not a multiset. +- The I/O is resolved at registration into the disjoint read/write + id sets of the world's record and read back through + `SystemInfo::declaresRead`/`declaresWrite` (O(1), no allocation). + The documented I/O list order is ascending `ComponentTypeId` (a + pure function of the sets). +- M1-SYS-02 consumes these sets: two systems writing the same + component type in one tick is rejected at scheduling time + (FR-12.3), and read-after-write orderings are warned where + declared. + +## Registration validation (FR-12.1, CORE-008) + +The order is normative — the first failure wins. Every failure is one +rate-limited structured warn (subsystem `system`, LOG-004) plus a +`Status`; no exceptions (NFR-8.10). + +| Condition | Result | Event | +|---|---|---| +| moved-from world (no registry) | `InvalidArgument` | — (pure failure) | +| `def.name` null or empty | `InvalidArgument` + warn | `system/name_invalid` | +| `def.run` null | `InvalidArgument` + warn | `system/run_invalid` | +| `def.budgetMs` ≤ 0 | `InvalidArgument` + warn | `system/budget_invalid` | +| duplicate name in this world | `InvalidArgument` + warn | `system/duplicate` | +| `Io`: `T` not a Laige component | compile error | — | +| `Io`: `T` not registered (this world) | `InvalidArgument` + warn | `system/io_unregistered` | +| same component declared twice (any access) | `InvalidArgument` + warn | `system/io_duplicate` | +| more than `kMaxSystems` systems | `BudgetExhausted` + warn | `system/budget_exhausted` | + +The `budget_raw` log field is the rejected budget in Q16.16 raw +units (value = raw / 2^16 ms, ADR 0002); `existing_system_id` / +`component_id` identify the conflicting registrations. + +## Performance (DOC-004) + +- **Registration (setup path):** O(n) in the number of registered + systems (the duplicate-name scan); the def copy and the I/O sets + are in-place writes into the fixed record table — **no allocation + at registration** (asserted by the `system_registry` suite's + zero-alloc window on the non-sanitizer trees; the sanitizer trees + prove it leak-free). +- **Per tick:** the registry is read-only during the loop — the + scheduler (M1-SYS-02) reads `SystemInfo` O(1) per system; no + allocation, no logging by default (LOG-003). +- **`World::system` / `systemCount`:** O(1), no allocation. +- **`SystemContext::each`:** the `World::each` cost — the + O(kMaxArchetypes · N) archetype scan plus one visit per matching + entity; no allocation (query.md). +- **Misuse:** registering near the `kMaxSystems` bound makes the + duplicate scan O(n²) across setup — a game that outgrows 256 + systems raises the constant through an ADR, not a hot path. + +## Threading and failure (CONC-001, API-004) + +Registration mutates the registry in an explicit setup phase on the +world's single owner thread (mutation phase, like +`registerComponent`); systems run on the sim thread (PRD §10.2). +All failures are `Result`/`Status` values — the engine core and +public API use no exceptions (FR-12.1, NFR-8.10). diff --git a/laige-api.json b/laige-api.json index 8bf52c8..3b8b39f 100644 --- a/laige-api.json +++ b/laige-api.json @@ -15,7 +15,8 @@ "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/query.h" + "src/laige-sim/include/laige/sim/query.h", + "src/laige-sim/include/laige/sim/system.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}, @@ -422,65 +423,90 @@ {"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": 172, "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": 173, "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": 174, "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": 176, "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": 177, "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": 185, "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": 188, "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": 202, "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": 203, "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": 204, "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": 205, "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": 206, "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": 207, "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": 208, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 219, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 236, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 237, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 238, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 239, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 240, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 241, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 242, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 243, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 315, "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": 319, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 324, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 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::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 330, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 336, "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": 341, "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": 351, "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": 358, "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": 361, "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": 364, "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": 368, "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": 372, "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::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 387, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, - {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 393, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 414, "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": 419, "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": 425, "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": 436, "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": 446, "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": 463, "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": 471, "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": 477, "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": 482, "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": 517, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) 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": 531, "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": 535, "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": 536, "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": 537, "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": 538, "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": 543, "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": 177, "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": 178, "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": 179, "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": 181, "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": 182, "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": 190, "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": 193, "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": 207, "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": 208, "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": 209, "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": 210, "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": 211, "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": 212, "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": 213, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 224, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 241, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 242, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 243, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 244, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 245, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 246, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 247, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 248, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 320, "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": 324, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 329, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 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::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 335, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 341, "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": 346, "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": 356, "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": 363, "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": 366, "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": 369, "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": 373, "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": 377, "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::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 392, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, + {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 398, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 419, "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": 424, "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": 430, "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": 441, "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": 451, "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": 468, "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": 476, "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": 482, "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": 487, "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": 522, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) 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::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 559, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, + {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 564, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 571, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 584, "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": 588, "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": 589, "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": 590, "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": 591, "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": 596, "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} + {"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}, + {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 175, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, + {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 176, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 181, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 183, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 186, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 194, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, + {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 200, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, + {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 212, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, + {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 213, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 214, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 215, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 225, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 227, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 236, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, + {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 253, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, + {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 256, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 266, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 267, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 268, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 272, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 276, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 306, "signature": "#define LAIGE_SYSTEM(Name, budget_ms)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start). Expands to the function declaration plus", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 754b96a..76e0cea 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -92,7 +92,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A ## System framework -- [ ] **M1-SYS-01 · System registry** +- [x] **M1-SYS-01 · System registry** - **Refs:** FR-1.3 (plain registered functions, declared budgets + I/O) - **Depends:** M1-ECS-04 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 363c1a2..6492506 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 | 7 | 🚧 in progress (M1-ECS-07) | +| M1 | 25 | 8 | 🚧 in progress (M1-SYS-01) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **27** | | +| **Total** | **193** | **28** | | --- @@ -202,6 +202,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-ECS-05 | `3cf8f91` | Deterministic iteration order (M1-ECS-05 scope, nothing else): the documented contract over the `World::each` visit order — archetypes in ascending archetype id (= the order a component set is first seen by a mutation, i.e. creation order), entities within an archetype in ascending slot id, the empty query ascending slot id; the order is a pure function of the world state, never of the operation history; the dense-id-order scheme (rows kept in ascending slot-id order through insert/remove — archetype.h invariants I1–I4) documented as what makes convergent histories visit identically; no unordered containers in the iteration path (only the fixed 256-record archetype table scanned in id order, packed slot columns, per-slot direct-index records, and membership-only 256-bit guard sets — the anticipated entity→archetype "one internal hash structure" is direct indexing, not even a hash, a stricter reading of the allowance; the one hash structure in `laige-sim`, the component type-key index, is lookup-only and never iterated, and sits on the setup path, not any tick); convergence property test: two worlds whose operation sequences interleave create/destroy differently (sequence A: serial per-entity scripts + end-phase dead destroys; sequence B: the same create phase — LIFO requires it for the identical entity→id assignment — with PRNG-scattered dead-destroys at round boundaries, round-robin component steps over the live entities in per-round PRNG permutations, and PRNG-placed component-less scratch pairs) converge on the identical final state including the entity→id assignment and iterate identically for five queries (`each<>`, `each`, `each`, `each`, `each`), verified per-visit (slot, generation, component values) against an independent oracle (free-list simulation + first-seen archetype order); component moves are structural throughout (adds of absent / removes of present components — the dense-id scheme pinned, not assumed); the archetype-1/archetype-2 boundary is non-vacuously distinguished from a global slot order (archetype 2 opens at slot 4, below archetype 1's top slot 19); KAT test pins the exact degenerate visit sequences; fixed PRNG seed (`TestPrng` substreams 1005–1007, `LAIGE_TEST_SEED` overridable) with machine-greppable `iter-order … fnv1a=0x…` lines — the visit hash is byte-identical across all four scenario instantiations and across g++/Clang/ASan/TSan/release trees and overridden seeds; new `iter_order` CTest entry (the step's Verify command, added to the TSan property list) over the shared `laige-sim_tests` executable; no public API added — `laige-api.json` unchanged (452 symbols, scanner rerun clean), no include-graph change (comments only; `tools/laige-include-lint` OK); API contract in `docs/api/iteration_order.md` (+ cross-refs in query.md, entity.md, archetype.md, component_registry.md, docs/README, sim README); local Verify: `ctest -R iter_order` green on `build` (g++), `build-asan`, `build-tsan`, `build-clang`, `build-release`; full `laige-sim_tests` suite green on `build` | | 2026-09-14 | M1-ECS-06 | `675fb94` | ECS guardrails (G-R3 entity-count thresholds, G-R4 per-frame component churn; M1-ECS-06 scope, nothing else): create() warns exactly when the live count REACHES 25/50/100% of the declared scene budget (integer thresholds capacity*pct/100; a level whose threshold computes to 0 never fires), at most once per level per frame (events `ecs/entity_budget_{25,50,100}`); the G-R4 per-frame churn counter (successful addComponent calls, including in-place overwrites, plus row-detaching removeComponent calls) warns when the per-frame total STRICTLY EXCEEDS `Options::churnPerFrameBudget` (default 256; 0 disables), at most once per frame (event `ecs/churn_per_frame`); both O(1) integer bookkeeping, no hot-path allocation (the warn paths are cold, LOG-003); NFR-13.3 5-field message grammar, build-stable; debug builds carry the PRD §9.3 advice as a structured `advice` FIELD (never message text); beginFrame() drives the per-frame windows (M1-LOOP-01 will wire it); guardrailStats() is the M1-PROF-01 feed; new EcsGuardrails suite (`ctest -R ecs_guardrails` — exact threshold firing, once-per-level-per-frame dedup, degenerate thresholds, churn budget exceed/strictness, per-frame reset, no-op removes uncounted, budget-0 disable, grammar parse, profiler feed, zero-alloc below-threshold window); local Verify: canonical g++ tree zero-warning, full ctest green, `ctest -R ecs_guardrails` green, sim suites green on build-asan/build-clang/build-release/build-shared/build-tsan, include-lint OK; (board/changelog row retroactively added 2026-09-14 by the M1-ECS-07 PR — the step merged as `675fb94`/PR #22 without updating this board or log) | | 2026-09-14 | M1-ECS-07 | `2995ec7` | ECS stress + memory accounting test (M1-ECS-07 scope, nothing else): new EcsStress suite — the step's Verify command `ctest -R ecs_stress` (added to the TSan property list) over the shared `laige-sim_tests` executable: 10k entities at the 100% scene budget (capacity 10000), 6 registered component types (Pos/Vel/Flag/Quad/Pair/Tag — 4/8/8/16/8/4 B, distinct strides), 10k frames of add/remove churn — each frame `beginFrame()` (drives G-R3/G-R4) + 64 seeded Tag adds + 64 Tag removes (cyclic Fisher-Yates permutation, `TestPrng` substream 1008, default-seed deterministic; 128 ops/frame, the default 256 G-R4 budget never exceeded) + one `each` iteration (Read, Read) with visit count + 64-bit FNV-1a checksum over (slot, generation, tag value); 700-frame warm-up (one full 625-frame cohort period — 8 cycles × 10000/128 picks/cycle — plus margin) brings every archetype's columns to their high water before the window, so the window's zero `totalReservations`/`totalArchetypeGrowth` delta IS the "pool high-water stable" claim (measured high water: 9 archetypes — 4 base + 4 Tagged + transient {Pos} — 24592 reserved rows, 549024 bytes); iteration within the documented cost (query.h: bounded archetype scan + one visit per matching entity) via a window-wide ns-per-visit throughput floor (600 ns, ≥8x the slowest measured: 58.7 ns g++ 16.2.1 / 72.3 ns clang++ 22.1.8, -O0 Debug); zero-allocation window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free run + reservation delta); no NEW guardrail warns in the window (churnWarns delta 0; the entity-budget 25/50/100% warns fire exactly once at setup and never re-cross — the entity count never changes); memory accounting (PRD §8.1 base memory, accounted bytes): 110000 entity bookkeeping bytes (11 B/slot × 10k) + 549024 reserved row bytes at the 100%-full scene; machine-greppable `ecs-stress window/iteration/memory` lines on every ctest run — the window/memory lines are BYTE-IDENTICAL across g++/clang++ (pure integer workload, ARCH-010) and across repeated runs; no-leak Verify: `ctest -R ecs_stress` green on `build-asan` (37.7 s, no ASan/UBSan report); second baseline `docs/benchmarks/baselines/m1-ecs-stress.md` (AGENTS §12 fields, verbatim runs, cross-tree results; not a `budgets.json` workload — no `measured` field updated); baselines index + benchmarks README updated; no public API added — `laige-api.json` unchanged (452 symbols, `api-real-tree` green in the full-suite runs), no include-graph change (comments only); local Verify: `ctest -R ecs_stress` green on `build` (Debug g++, 14.7 s), `build-asan` (required, leak-free), `build-release`, `build-clang`, `build-tsan`, `build-shared`; full suite 40/40 on `build`/`build-asan`/`build-clang`, `laige-sim_tests` + `ecs_stress` green on the other trees; zero new warnings under NFR-8.10 | +| 2026-09-14 | M1-SYS-01 | `115d28c` | System registry (FR-1.3: plain registered functions with declared time budgets and declared component I/O; M1-SYS-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/system.h` — `SystemId` (32-bit dense id from 1, registration order, per-world, deterministic — component.h id contract), `SystemDef` (name + `SystemFn` = `void(*)(World&, SystemContext&)` + `budgetMs` in ms as `fpx16_16` — exact, ADR 0002, keeps the future sim source scan float-free), the `LAIGE_SYSTEM(Name, budget_ms)` macro (namespace scope: the plain function declaration + the `Name##Def` def variable — no class, no inheritance), `SystemContext` (per-tick world view; `each(fn, Read/Write tags...)` delegates to `World::each` — definition out-of-line in entity.h where World is complete), `Io` (the per-component declared I/O tag; a component appears at most once per system, any access combination — the I/O is a set, not a multiset, stored as disjoint read/write id sets; ascending-id enumeration order documented), `SystemInfo` (the def value copy + `declaresRead`/`declaresWrite`, the M1-SYS-02/M1-PROF-01 feed), `kMaxSystems = 256` (engine-level bound, CORE-005), `detail::SystemRecord` + the `IsIoTag`/`IsIoComponent`/`IoComponent` traits (class form — the api scanner parses class partial specializations; variable templates are an unsupported scanner construct); `World::registerSystem(def, Io<...>...)` (header-defined template, entity.h), `World::systemCount()`, `World::system(id)` (`systems.cpp`); the fixed record table is allocated in `create()` like the component registry, travels with the world on move, and survives `clear()`; validation (first failure wins; every failure one rate-limited structured warn + `Status` — FR-12.3/LOG-004): moved-from world → `InvalidArgument` (no warn, the registerComponent precedent), null/empty name → `system/name_invalid`, null run → `system/run_invalid`, budget ≤ 0 → `system/budget_invalid` (the budget must be explicit and positive), duplicate name → `system/duplicate` (the roadmap's named property), Io T not a component → compile error (static_assert), Io T unregistered in this world → `system/io_unregistered`, same component twice (any access) → `system/io_duplicate`, > kMaxSystems → `BudgetExhausted` + `system/budget_exhausted`; the def is value-copied into the record table: no allocation at registration (setup path — PERF-003); new `SystemRegistry` suite (25 tests, CTest entry `system_registry`, added to the TSan property list): registration, ids dense from 1, the def value copy, the I/O sets + zero-I/O pack, context delegation (write + read paths through the plain functions), every validation error, the kMaxSystems budget (257 distinct names), `system()` id validation, id stability across two worlds + registration-order-determines-ids (ARCH-010), move/clear/moved-from-world lifetime, the zero-alloc registration window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free), warn-once + `rate_limited` sink checks (`system/duplicate` name/existing_system_id fields, `system/budget_invalid` budget_raw field); docs: `docs/api/system_registry.md` (full contract + Performance section) linked from `docs/README.md`, `src/laige-sim/README.md` status updated (incl. the M1-ECS-06/07 lines), entity.h preamble + member docs carry the M1-SYS-01 note; `laige-api.json` regenerated (489 symbols; `api-real-tree` green); local Verify: `ctest -R system_registry` green on `build` (25/25 incl. the zero-alloc window), full suite 41/41 on `build`/`build-asan` (leak-free)/`build-tsan`/`build-clang`/`build-release`/`build-shared`, zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (27 source files, 1/10 vendored deps) | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 8be8e15..6855dae 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -22,8 +22,13 @@ # entity.h, next to the component access templates). M1-ECS-06 adds # guardrails.cpp: the G-R3 entity-count thresholds and the G-R4 # per-frame churn budget (the check hooks live in entity.cpp's -# create() and entity.h's add/remove templates). -set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp) +# create() and entity.h's add/remove templates). M1-SYS-01 adds +# systems.cpp: the non-template World methods of the system registry +# (systemCount, system + the SystemInfo queries); the registration +# template (registerSystem) is header-defined in entity.h like +# registerComponent (the M1-ECS-02 pattern). +set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp + systems.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 f99bba4..c608585 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -35,5 +35,18 @@ component moves, the no-unordered-containers rule for the iteration path, and the convergent-worlds property test (API contract in [docs/api/iteration_order.md](../docs/api/iteration_order.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `iter_order`). -The system loop (M1-SYS) and the game loop (M1-LOOP) land in the -remaining M1 steps; physics, input, and animation in M3. +M1-ECS-06 landed the ECS guardrails — the G-R3 entity-count +thresholds and the G-R4 per-frame churn budget (`guardrails.cpp`; +CTest entry `ecs_guardrails`). M1-ECS-07 landed the ECS stress + +memory accounting suite (CTest entry `ecs_stress`). M1-SYS-01 landed +the system registry — the plain registered functions +(`LAIGE_SYSTEM` + `SystemDef`), declared time budgets (fpx16_16 ms) +and declared component I/O (`Io`), +`World::registerSystem`/`system`/`systemCount`, and +`SystemContext`'s delegated `each` (`include/laige/sim/system.h`, +`systems.cpp`; API contract in +[docs/api/system_registry.md](../docs/api/system_registry.md), tests +under [tests/laige-sim](../tests/laige-sim), CTest entry +`system_registry`). +The system loop (M1-SYS-02 onward) 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 b67b113..96d9b86 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -72,7 +72,9 @@ World::World(World&& other) noexcept churnWarns_(other.churnWarns_), iterationActive_(other.iterationActive_), iterationArchetypes_(other.iterationArchetypes_), - iterationReadComponents_(other.iterationReadComponents_) { + iterationReadComponents_(other.iterationReadComponents_), + systems_(std::move(other.systems_)), + systemCount_(other.systemCount_) { other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -105,6 +107,10 @@ World::World(World&& other) noexcept other.iterationActive_ = false; other.iterationArchetypes_ = detail::IdSet256{}; other.iterationReadComponents_ = detail::IdSet256{}; + // M1-SYS-01: the system registry travels with the storage; the + // moved-from world is a valid empty world in every field (no + // registry: registerSystem returns InvalidArgument on it). + other.systemCount_ = 0; } World& World::operator=(World&& other) noexcept { @@ -150,6 +156,9 @@ World& World::operator=(World&& other) noexcept { iterationActive_ = other.iterationActive_; iterationArchetypes_ = other.iterationArchetypes_; iterationReadComponents_ = other.iterationReadComponents_; + // M1-SYS-01: the system registry travels with the storage. + systems_ = std::move(other.systems_); + systemCount_ = other.systemCount_; other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -177,6 +186,9 @@ World& World::operator=(World&& other) noexcept { other.iterationActive_ = false; other.iterationArchetypes_ = detail::IdSet256{}; other.iterationReadComponents_ = detail::IdSet256{}; + // M1-SYS-01: the moved-from world is a valid empty world in every + // field (no registry: registerSystem returns InvalidArgument on it). + other.systemCount_ = 0; return *this; } @@ -195,6 +207,11 @@ Result World::create(Options options) noexcept { // budget (kMaxComponentTypes), a setup-path allocation like the // entity tables below. w.components_ = std::make_unique(kMaxComponentTypes); + // System registry table (M1-SYS-01): the fixed engine-level budget + // (kMaxSystems records, value-initialized), a setup-path + // allocation like the component registry above — allocated even + // for a zero-capacity world so it stays a valid empty world. + w.systems_ = std::make_unique(kMaxSystems); // Archetype storage (M1-ECS-03): the fixed archetype table // (kMaxArchetypes records, value-initialized) and the type-key // index (kComponentKeyIndexSize slots) — setup-path allocations, diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 3b30b85..26f5624 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -20,7 +20,10 @@ // 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). +// tags..., fn) + the World-API mutation checks); M1-SYS-01 +// adds the system registry (system.h: SystemDef, +// SystemContext, the LAIGE_SYSTEM macro, +// World::registerSystem/system/systemCount). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -155,6 +158,7 @@ #include #include #include +#include #include "laige/errors.h" #include "laige/logging.h" @@ -163,6 +167,7 @@ #include "laige/sim/archetype.h" #include "laige/sim/component.h" #include "laige/sim/query.h" +#include "laige/sim/system.h" namespace laige { @@ -517,6 +522,54 @@ class World { template [[nodiscard]] Status each(F&& fn, Acc...) noexcept; + // ------------------------------------------------------------- + // System registry (M1-SYS-01; full contract in system.h) + // ------------------------------------------------------------- + + // Register the system described by `def` in this world, declaring + // its component I/O as the Io<...> pack (zero entries = a system + // that touches no components). Setup phase (world construction, + // before the loop), like registerComponent: O(n) in the number + // of registered systems, no allocation (the def is copied into the + // fixed kMaxSystems record table; the I/O sets are written in + // place). + // + // moved-from world (no registry) -> InvalidArgument + // def.name null or empty -> InvalidArgument + warn + // (system/name_invalid) + // def.run nullptr -> InvalidArgument + warn + // (system/run_invalid) + // def.budgetMs <= 0 -> InvalidArgument + warn + // (system/budget_invalid) — + // the budget must be explicit + // duplicate name in this world -> InvalidArgument + warn + // (system/duplicate) + // Io T not a Laige component -> compile error (static_assert) + // Io T not registered (this world) + // -> InvalidArgument + warn + // (system/io_unregistered) + // the same component declared twice by one system (any access + // combination) -> InvalidArgument + warn + // (system/io_duplicate) + // more than kMaxSystems -> BudgetExhausted + warn + // (system/budget_exhausted) + // + // Returns the new SystemId (dense, from 1, in registration order — + // the component.h id contract). + template + [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept; + + // The number of systems registered so far (0 .. kMaxSystems). O(1), + // no side effects. + [[nodiscard]] std::uint32_t systemCount() const noexcept; + + // The registered system's record under `id` (SystemInfo: the def + // value copy plus the declared I/O membership queries). O(1), no + // allocation. `id` invalid (0 or above systemCount()) or a + // moved-from world -> ErrorCode::InvalidArgument (a pure query, + // like componentInfo). + [[nodiscard]] Result system(SystemId id) const noexcept; + // Destroy every live entity (shutdown path, CONC-006). Every handle // becomes stale; the capacity is unchanged and the world is // immediately reusable. O(capacity + detached rows * row-stride), @@ -650,6 +703,21 @@ class World { // iteration still holds live rows (ecs/iteration_clear). [[nodiscard]] Status guardClear() noexcept; + // ------------------------------------------------------------- + // M1-SYS-01 system-registry helper (defined in this header with + // registerSystem; full contract in system.h) + // ------------------------------------------------------------- + + // Resolve one Io entry of registerSystem: resolve T's + // ComponentTypeId in this world (0 when T is unregistered) and set + // the declared bit in the matching set. The sets are mutated only + // on Ok. `failedId` receives the resolved component id for the + // Duplicate case (the log field; left 0 otherwise). + template + detail::IoResolution resolveIoEntry(detail::IdSet256& read, + detail::IdSet256& write, + std::uint32_t* failedId) const noexcept; + // ------------------------------------------------------------- // M1-ECS-06 guardrail checks (G-R3/G-R4; defined in guardrails.cpp) // ------------------------------------------------------------- @@ -819,6 +887,13 @@ class World { bool iterationActive_{false}; detail::IdSet256 iterationArchetypes_{}; detail::IdSet256 iterationReadComponents_{}; + // System registry (M1-SYS-01; system.h): the fixed engine budget + // (kMaxSystems records), indexed by (system id - 1); a dense id is + // assigned at registration (registration order, component.h + // precedent). Setup state: clear() does not touch it (a system is + // not per-entity data). + std::unique_ptr systems_; + std::uint32_t systemCount_{0}; }; // Component registration (M1-ECS-02). Header-defined: it is a template, @@ -874,6 +949,148 @@ Result World::registerComponent() noexcept { return id; // ids are dense, from 1 } +// --------------------------------------------------------------------------- +// System registry (M1-SYS-01). Header-defined like registerComponent: +// templates must be visible to every translation unit that registers a +// system. See system.h for the full contract (the validation order +// below, the I/O resolution, and the determinism note). +// --------------------------------------------------------------------------- + +template +detail::IoResolution World::resolveIoEntry(detail::IdSet256& read, + detail::IdSet256& write, + std::uint32_t* failedId) const noexcept { + // Tag is an Io (the static_asserts in registerSystem). + using C = detail::IoComponent::type; + const std::uint32_t id = + componentIdOfKey(&detail::ComponentTypeKey::kMarker); + if (id == 0) return detail::IoResolution::Unregistered; + if (read.contains(id) || write.contains(id)) { + // The component is already declared by this system (any access + // combination): the I/O is a set, not a multiset (system.h). + *failedId = id; + return detail::IoResolution::Duplicate; + } + if (detail::IoComponent::access == Access::Read) read.set(id); + else write.set(id); + return detail::IoResolution::Ok; +} + +template +Result World::registerSystem(const SystemDef& def, Ios...) noexcept { + // I/O pack validation (compile time, not runtime surprises): + // every entry must be an Io tag and its component type + // must be a Laige component (FR-1.2, S-8). + static_assert((detail::IsIoTag::value && ...), + "registerSystem: every I/O entry must be an " + "Io tag value (laige/sim/system.h)"); + static_assert((detail::IsIoComponent::value && ...), + "registerSystem: every Io component type must " + "be marked with LAIGE_COMPONENT(T) (FR-1.2, S-8)"); + if (systems_ == nullptr) { + // Moved-from world: no registry (the same "valid empty world" + // contract as the component registry, component.h). + return ErrorCode::InvalidArgument; + } + // The validation order is normative (system.h preamble): the def's + // fields first, then the name uniqueness, then the I/O entries, + // lastly the engine budget. Every failure is one rate-limited + // structured warn + a Status (FR-12.3: never silent; LOG-004). + if (def.name == nullptr || def.name[0] == '\0') { + // The name cannot be logged (it is null or empty); the message + // names the field, and the budget identifies the def. + LAIGE_LOG_WARN("system", "name_invalid", + "System has no registration name (the def's name is " + "null or empty)", + laige::log::field("budget_raw", def.budgetMs.raw)); + return ErrorCode::InvalidArgument; + } + if (def.run == nullptr) { + LAIGE_LOG_WARN("system", "run_invalid", + "System has no run function; build the def with " + "LAIGE_SYSTEM or set run explicitly", + laige::log::field("name", def.name)); + return ErrorCode::InvalidArgument; + } + if (def.budgetMs.raw <= 0) { + // The budget must be explicit and strictly positive (FR-1.3; + // fpx16_16 is exact, so raw <= 0 is exactly "not > 0 ms"). + LAIGE_LOG_WARN("system", "budget_invalid", + "System time budget must be explicit and > 0 ms", + laige::log::field("name", def.name), + laige::log::field("budget_raw", def.budgetMs.raw)); + return ErrorCode::InvalidArgument; + } + for (std::uint32_t i = 0; i < systemCount_; ++i) { + if (std::strcmp(systems_[i].def.name, def.name) == 0) { + // Duplicate name is an error (M1-SYS-01 scope); the facade + // rate-limits the warn per event (LOG-004). + LAIGE_LOG_WARN("system", "duplicate", + "System name is already registered in this world; " + "duplicate names are an error", + laige::log::field("name", def.name), + laige::log::field("existing_system_id", i + 1u)); + return ErrorCode::InvalidArgument; + } + } + // The I/O entries, resolved against this world's component registry + // (per-world ids, component.h). The fold short-circuits on the first + // failure (the sets are mutated only on Ok, so a failed registration + // changes nothing). The unnamed Ios pack is intentional: only the + // TYPES are used — a named pack would be an unreferenced parameter + // (MSVC C4100, fatal under /WX; NFR-8.10), the each() precedent. + detail::IdSet256 read, write; + std::uint32_t failedId = 0; + detail::IoResolution io = detail::IoResolution::Ok; + ((io = (io == detail::IoResolution::Ok + ? this->template resolveIoEntry(read, write, &failedId) + : io)), + ...); + if (io != detail::IoResolution::Ok) { + if (io == detail::IoResolution::Unregistered) { + LAIGE_LOG_WARN("system", "io_unregistered", + "System declares I/O for a component type that is " + "not registered in this world; register it at world " + "setup", + laige::log::field("name", def.name)); + } else { + LAIGE_LOG_WARN("system", "io_duplicate", + "System declares the same component more than once " + "in its I/O list (any access combination)", + laige::log::field("name", def.name), + laige::log::field("component_id", failedId)); + } + return ErrorCode::InvalidArgument; + } + if (systemCount_ >= kMaxSystems) { + // The engine-level system budget (system.h preamble). + LAIGE_LOG_WARN("system", "budget_exhausted", + "The world has kMaxSystems systems; a new system " + "cannot be registered (M1 bound - raise it through " + "an ADR)", + laige::log::field("name", def.name), + laige::log::field("systems", systemCount_)); + return ErrorCode::BudgetExhausted; + } + detail::SystemRecord& rec = systems_[systemCount_]; + rec.def = def; // value copy: the user's def may be stack-scoped + rec.readComponents = read; + rec.writeComponents = write; + const std::uint32_t id = systemCount_ + 1; + ++systemCount_; + return SystemId{id}; // ids are dense, from 1 +} + +// The context's delegated each (M1-SYS-01). Out-of-line here (not in +// system.h) because the delegated call is checked against the +// complete World: World is only forward-declared in system.h. The +// pack is named so its VALUES can be forwarded (an unnamed pack +// cannot be forwarded — C++ has no pack of packless values). +template +[[nodiscard]] Status SystemContext::each(F&& fn, Acc... acc) noexcept { + return world.each(std::forward(fn), std::forward(acc)...); +} + // --------------------------------------------------------------------------- // Archetype SoA component access (M1-ECS-03; full contract in // archetype.h). Header-defined like registerComponent: templates must diff --git a/src/laige-sim/include/laige/sim/system.h b/src/laige-sim/include/laige/sim/system.h new file mode 100644 index 0000000..a55b907 --- /dev/null +++ b/src/laige-sim/include/laige/sim/system.h @@ -0,0 +1,365 @@ +// laige-sim system registry (M1-SYS-01). +// +// FR-1.3 (plain registered functions with declared time budgets and +// declared component I/O): this header ships the system framework's +// public types; the World methods that use them (registerSystem, +// systemCount, system) are declared in entity.h (the World home) and +// the registration template is defined there (the M1-ECS-02 pattern: +// public types in the subsystem header, World methods in its home): +// +// SystemId The stable per-world system id: a 32-bit dense +// value assigned in registration order (0 is +// reserved and never assigned). +// SystemDef The static declaration of a system: name, the +// plain run function, and the declared time budget +// in milliseconds (fpx16_16, exact — ADR 0002). +// SystemContext The per-tick context handed to a system: the world +// view plus the delegated World::each (the PRD +// Appendix B sketch's `ctx.each<...>()`). +// Io One declared component I/O entry of a system: +// component type T and its declared access. +// SystemInfo A registered system's snapshot (def + the declared +// I/O membership queries) for the scheduler +// (M1-SYS-02) and the profiler (M1-PROF-01). +// LAIGE_SYSTEM The one-line declaration of a system: the plain +// function declaration plus the SystemDef, at +// namespace scope directly above the function. +// +// --------------------------------------------------------------------------- +// The system shape (FR-1.3: plain functions, no inheritance) +// --------------------------------------------------------------------------- +// +// A system is a plain free function with the signature SystemFn: +// +// LAIGE_SYSTEM(Movement, 1) +// void Movement(laige::World& world, laige::SystemContext& ctx) { +// world.each( +// [](laige::Entity e, SimVel& v) { /* ... */ }, laige::Write{}); +// } +// +// No class, no inheritance, no state object: the function plus its +// SystemDef (the `Movement_Def` variable the macro builds) IS the +// system. `world` is the world the system runs on (one world, one +// owner thread — PRD §10.2); `ctx` is that tick's SystemContext, a +// view of the same world that delegates iteration to World::each +// (query.h). Systems are deterministic when the engine runs in +// deterministic mode (M1-DET-01) and must stay within their declared +// budget (M1-SYS-03 measures it). +// +// The LAIGE_SYSTEM macro expands at namespace scope to +// +// void Movement(laige::World&, laige::SystemContext&); +// inline const laige::SystemDef Movement_Def = laige::SystemDef{ +// "Movement", &Movement, laige::fpx16_16::fromFloat(1)}; +// +// so `Name` is both the C++ function name and the system's +// registration name (stringified), and the def variable is `Name##Def`. +// The macro and the function definition live in the same translation +// unit (the macro takes the function's address). `budget_ms` is a +// numeric literal in milliseconds (1, 0.5, ...); the conversion to +// the exact fpx16_16 happens once, at program start (a setup path, +// never a hot path). +// +// --------------------------------------------------------------------------- +// Registration and the id contract (FR-1.3, component.h precedent) +// --------------------------------------------------------------------------- +// +// A system is registered into a world at world setup (before the +// loop), like a component type: +// +// auto r = world.registerSystem(Movement_Def, +// laige::Io{}); +// +// SystemId is assigned in registration order, densely from 1, per +// world (0 is reserved); kMaxSystems (256) is the engine-level cap. +// Determinism (ARCH-010): assignment and the name-uniqueness check +// are pure integer/string bookkeeping — no addresses, hashes, or +// platform state enter the id. Two worlds, two process runs, or +// two builds that register the same systems in the same order +// produce bit-identical id sequences, so ids are replay state from +// M1 on (M1-DET-01/02). Ids are per-world, like ComponentTypeIds. +// +// The declared component I/O is part of the REGISTRATION, not the +// def: component ids are per-world runtime values (component.h id +// contract) and cannot be baked into a compile-time def. The Io<...> +// pack is resolved against the world at registration into the +// disjoint read/write id sets stored in the world's record, and read +// back through SystemInfo::declaresRead/declaresWrite. The documented +// I/O list order is ascending ComponentTypeId (a pure function of +// the sets). +// +// Validation (FR-12.1, CORE-008 — never silent; order is normative, +// the first failure wins): +// +// moved-from world (no registry) -> ErrorCode::InvalidArgument +// def.name null or empty -> InvalidArgument + warn +// (system/name_invalid) +// def.run nullptr -> InvalidArgument + warn +// (system/run_invalid) +// def.budgetMs <= 0 -> InvalidArgument + warn +// (system/budget_invalid) — +// the budget must be explicit +// and positive (FR-1.3) +// duplicate name in this world -> InvalidArgument + warn +// (system/duplicate) +// Io T not a Laige component -> compile error (static_assert) +// Io T not registered in this +// world -> InvalidArgument + warn +// (system/io_unregistered) +// the same component declared twice -> InvalidArgument + warn +// by one system (any access (system/io_duplicate) — a +// combination: read+read, system's I/O is a set, not a +// read+write, write+read) multiset +// more than kMaxSystems -> BudgetExhausted + warn +// (system/budget_exhausted) +// +// On success the def is COPIED by value into the world's fixed record +// table (kMaxSystems records, a setup-path allocation like the +// component registry — the user's def may be a stack variable), and +// the I/O sets are written in place: no allocation at registration +// and none per tick (the registry is read-only during the loop). +// +// --------------------------------------------------------------------------- +// Threading and failure +// --------------------------------------------------------------------------- +// +// Registration is a setup-phase operation on the world's single +// owner thread (CONC-001; API-004: mutation in an explicit phase). +// The run function is sim-thread code (PRD §10.2: simulation is +// single-threaded). All failures are Result/Status values with one +// rate-limited structured warn each (LOG-004); no exceptions +// (FR-12.1, NFR-8.10). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - A budget is a declaration, not a knob: 0, negative, or a +// missing budget is a registration error (FR-1.3: "declared time +// budgets" — an undeclared budget is an undeclared system). +// - Two systems must never write the same component type in one +// tick: the M1-SYS-02 scheduler rejects such a registration pair +// at scheduling time (FR-12.3). Declare the I/O the system +// really uses — an undeclared write is invisible to the +// scheduler's conflict check. +// - A system's I/O may declare a component it does not touch (a +// conservative declaration), never one it touches without +// declaring. +// - The LAIGE_SYSTEM macro is namespace-scope only: the def +// variable it builds is `inline const` (block scope is +// ill-formed) and the function it declares must match the +// definition's linkage. +// - SystemContext is a non-owning per-tick view: never store it +// (the world owns the storage; the context outlives nothing). +// - A moved-from world has no system registry: registerSystem on +// one returns InvalidArgument (the same "valid empty world" +// contract as the component registry). + +#pragma once + +#include + +#include "laige/fpx16_16.h" +#include "laige/result.h" +#include "laige/sim/component.h" +#include "laige/sim/query.h" + +namespace laige { + +class World; // the World home is entity.h; only the reference is used here +struct SystemContext; // declared below; only the reference is used in SystemFn + +// The stable per-world system id (FR-1.3): assigned in registration +// order, densely from 1. See the header preamble for the id and +// determinism contract. +struct SystemId { + std::uint32_t value{}; // 0 is reserved: never a registered id +}; + +// The never-assigned id (API-008: the invalid state is representable +// and checkable; call sites never spell raw 0s). +inline constexpr SystemId kInvalidSystemId{0}; + +inline bool operator==(SystemId a, SystemId b) noexcept { + return a.value == b.value; +} +inline bool operator!=(SystemId a, SystemId b) noexcept { + return !(a == b); +} + +// The engine-level cap on systems per world (CORE-005: a named engine +// constant, the kMaxComponentTypes precedent — a game's system count +// is orders of magnitude smaller than its entity count; raising it is +// an ADR, not a knob). +inline constexpr std::uint32_t kMaxSystems = 256; + +// The system function signature (FR-1.3): a plain free function — no +// class, no inheritance. `world` is the world the system runs on; +// `ctx` is that tick's SystemContext (one world, one owner thread, +// PRD §10.2). +using SystemFn = void (*)(World&, SystemContext&); + +// The static declaration of a system (FR-1.3): one per system, built +// by the LAIGE_SYSTEM macro (see the header preamble for the shape). +// `name` is the stable registration name (unique per world); `run` is +// the plain system function; `budgetMs` is the declared per-tick time +// budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR +// 0002). The declared component I/O is NOT part of the def (per-world +// runtime ids, see the preamble): it is declared at registration +// (the Io<...> pack of World::registerSystem) and stored in the +// world's record. The def is a small trivially-copyable value — +// registerSystem copies it, so a def on the stack is safe. +struct SystemDef { + const char* name; + SystemFn run; + fpx16_16 budgetMs; +}; + +// The per-tick context handed to a system's run() (FR-1.3; the PRD +// Appendix B sketch's `ctx`). It names the world the system runs on +// and delegates iteration to World::each (query.h) — the sketch's +// `ctx.each<...>()`. The context is built per system per tick by the +// scheduler (M1-SYS-02); until then games and tests build it +// directly. It is a non-owning view (the world owns the storage): +// never store it across ticks. +struct SystemContext { + // The world the system runs on (one world, one owner thread). + World& world; + + // Delegate to World::each(fn, Read/Write tags...) on the + // same world: identical semantics, visit order, iteration-legality + // behavior, and Status results (query.h). No allocation. The + // definition is out-of-line in entity.h (the World home): World is + // incomplete here, and the delegated call is checked at + // instantiation — which needs the complete World. + // @budget O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation. + template + [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept; +}; + +// One declared component I/O entry of a system (FR-1.3): component +// type T and its declared access. Pass one value of this tag type per +// component in the Io<...> pack of World::registerSystem: +// +// world.registerSystem(Movement_Def, +// laige::Io{}, +// laige::Io{}); +// +// T must be a Laige component (LAIGE_COMPONENT) registered on the +// world being registered to; a component may appear at most ONCE in +// one system's I/O (any access combination — the declaration is a +// set, not a multiset: M1-SYS-02 reads the disjoint read/write sets +// for its conflict checks). +template +struct Io { + // The declared access of the entry (Read or Write). + static constexpr Access access = kAccess; +}; + +// A registered system's snapshot (a plain value; the M1-SYS-02 +// scheduler and the M1-PROF-01 profiler pull it). `def` is the value +// copy of the registered def; `id` is the world's SystemId. The +// declared component I/O list (FR-1.3) is stored as the disjoint +// read/write id sets and read back through the membership queries: +// the documented list order is ascending ComponentTypeId (component.h +// id contract — a pure function of the sets). +struct SystemInfo { + SystemDef def{}; + SystemId id{}; + + // True when the system declares `componentId` for reading. + // @budget O(1); no allocation. + [[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept; + + // True when the system declares `componentId` for writing. + // @budget O(1); no allocation. + [[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept; + + private: + // World::system (systems.cpp) builds the snapshot from the world's + // record: the only outside access to the set members. + friend class World; + // The declared I/O sets (the storage form of the I/O list; + // membership only — never iterated, PERF-006). Set in place at + // registration (systems.cpp builds the snapshot from the world's + // record). + detail::IdSet256 readComponents_{}; + detail::IdSet256 writeComponents_{}; +}; + +// Declare a system (FR-1.3): at namespace scope, directly above the +// plain system function's definition. `Name` is both the C++ function +// name and the system's registration name (stringified); `budget_ms` +// is the declared per-tick time budget in milliseconds (a numeric +// literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at +// program start). Expands to the function declaration plus +// +// inline const laige::SystemDef Name##Def = laige::SystemDef{ +// #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; +// +// The def variable (e.g. `Movement_Def`) is what World::registerSystem +// takes, together with the Io<...> pack. The macro and the function +// definition live in the same translation unit. +#define LAIGE_SYSTEM(Name, budget_ms) \ + void Name(laige::World&, laige::SystemContext&); \ + inline const laige::SystemDef Name##_Def = laige::SystemDef{ \ + #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; + +namespace detail { + +// One registry record per registered system. World stores a dense +// array of these indexed by (system id - 1) — ids are dense by +// construction (the component.h ComponentRecord precedent). `def` is +// the value copy from registration; the I/O is the disjoint read/write +// id sets (the storage form of the declared I/O list — system.h +// preamble). +struct SystemRecord { + SystemDef def{}; + IdSet256 readComponents; // declared Read component ids (1..256) + IdSet256 writeComponents; // declared Write component ids (1..256) +}; + +// The outcome of one Io entry's resolution in World::registerSystem +// (the fold short-circuits on the first failure). +enum class IoResolution : std::uint8_t { + Ok = 0, + Unregistered = 1, // T is not registered in this world + Duplicate = 2, // T is already declared by this system (any access) +}; + +// True when `T` is an Io tag (the registerSystem pack +// check, compile time; class form — the public-header convention for +// traits, the ComponentTraits precedent in component.h). +template +struct IsIoTag { + static constexpr bool value = false; +}; +template +struct IsIoTag> { + static constexpr bool value = true; +}; + +// True when the component type of an Io tag is a Laige component +// (the second registerSystem pack check, compile time). +template +struct IsIoComponent { + static constexpr bool value = false; +}; +template +struct IsIoComponent> { + static constexpr bool value = ComponentTraits::isComponent; +}; + +// Extracts the component type and the declared access of an Io tag: +// IoComponent>::type == T and ::access == A. +template +struct IoComponent; +template +struct IoComponent> { + using type = T; + static constexpr Access access = A; +}; + +} // namespace detail + +} // namespace laige diff --git a/src/laige-sim/systems.cpp b/src/laige-sim/systems.cpp new file mode 100644 index 0000000..26bd9e9 --- /dev/null +++ b/src/laige-sim/systems.cpp @@ -0,0 +1,46 @@ +// laige-sim system registry (M1-SYS-01). +// +// The non-template World methods of the system registry (systemCount, +// system) and the SystemInfo snapshot queries. The registration +// template (registerSystem, with the resolveIoEntry helper) is +// header-defined in entity.h (the M1-ECS-02/03 template precedent). +// See include/laige/sim/system.h for the full contract (the id and +// determinism contract, the validation order, the I/O sets). + +#include "laige/sim/entity.h" + +#include + +namespace laige { + +std::uint32_t World::systemCount() const noexcept { + return systemCount_; +} + +Result World::system(SystemId id) const noexcept { + // Ids are dense from 1, so a valid registered id is exactly the + // range [1, systemCount_] (system.h contract; the componentInfo + // precedent: an invalid id is a pure-query failure, no warn). + if (id.value == 0 || id.value > systemCount_ || systems_ == nullptr) { + return ErrorCode::InvalidArgument; + } + const detail::SystemRecord& rec = systems_[id.value - 1]; + SystemInfo info; + info.def = rec.def; // value copy: the snapshot owns its def + info.id = id; + info.readComponents_ = rec.readComponents; + info.writeComponents_ = rec.writeComponents; + return info; +} + +bool SystemInfo::declaresRead(ComponentTypeId componentId) const noexcept { + // The id is 1..kMaxComponentTypes by the component.h contract + // (IdSet256::contains treats 0 as "not a member" — a pure query). + return readComponents_.contains(componentId.value); +} + +bool SystemInfo::declaresWrite(ComponentTypeId componentId) const noexcept { + return writeComponents_.contains(componentId.value); +} + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 68c1d8e..d246098 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,24 +1,27 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05/06/07): entity handle + World -# entity storage, component type registry, archetype SoA storage, -# query API + iteration legality, deterministic iteration order, the -# ECS guardrails (G-R3/G-R4), and the ECS stress + memory accounting -# suite. +# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01): entity +# handle + World entity storage, component type registry, archetype +# SoA storage, query API + iteration legality, deterministic iteration +# order, the ECS guardrails (G-R3/G-R4), the ECS stress + memory +# accounting suite, and the system registry (plain registered +# functions, declared budgets + component I/O). # # 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`, `archetype`, `query`, `iter_order`, -# `ecs_guardrails`, and `ecs_stress` entries are the M1-ECS-01, -# M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, and M1-ECS-07 -# Verify commands (`ctest -R entity`, `ctest -R component_registry`, -# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`, -# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`), selecting exactly -# the suites below from the shared executable. +# `ecs_guardrails`, `ecs_stress`, and `system_registry` entries are the +# M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, +# M1-ECS-07, and M1-SYS-01 Verify commands (`ctest -R entity`, +# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R query`, +# `ctest -R iter_order`, `ctest -R ecs_guardrails`, `ctest -R +# ecs_stress`, `ctest -R system_registry`), selecting exactly the +# suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp iter_order_tests.cpp ecs_guardrails_tests.cpp - ecs_stress_tests.cpp) + ecs_stress_tests.cpp + system_registry_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, @@ -114,10 +117,17 @@ add_test(NAME ecs_stress COMMAND laige-sim_tests --gtest_filter=EcsStress.*) +# M1-SYS-01: system registry. The step's Verify command is +# `ctest -R system_registry`; this entry selects exactly the +# SystemRegistry suites from the shared laige-sim_tests executable. +add_test(NAME system_registry + COMMAND laige-sim_tests + --gtest_filter=SystemRegistry.*) + 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 - query iter_order ecs_guardrails ecs_stress + query iter_order ecs_guardrails ecs_stress system_registry PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/system_registry_tests.cpp b/tests/laige-sim/system_registry_tests.cpp new file mode 100644 index 0000000..10e9554 --- /dev/null +++ b/tests/laige-sim/system_registry_tests.cpp @@ -0,0 +1,729 @@ +// laige-sim system registry suite (M1-SYS-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - registration: the def is stored by value (name/run/budget), +// SystemIds are dense from 1 in registration order, the I/O pack +// resolves into the disjoint read/write sets (Io tags) +// - validation errors: null/empty name, null run, budget <= 0 +// (the budget must be explicit), unregistered I/O component, +// duplicate I/O component (any access combination) +// - duplicate detection: duplicate system names are an error, not a +// no-op (FR-12.3: never silent) +// - the engine-level system budget (kMaxSystems) is honored with +// BudgetExhausted +// - the LAIGE_SYSTEM macro: the plain function + the def variable +// (`Name##Def`), no class, no inheritance (FR-1.3) +// - SystemContext delegates World::each (the query.h contract) +// - id stability across two worlds with the same registration order +// (ARCH-010; system.h preamble) +// - registry lifetime: move, clear(), moved-from world +// - no heap allocation at registration (setup path; the +// operator-new counter on the non-sanitizer trees, the +// M1-ECS-03 pattern; the sanitizer trees prove it leak-free) +// +// Runs as CTest `system_registry` (the step's Verify command: +// `ctest -R system_registry`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites below. + +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim/entity.h" +#include "laige/sim/system.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "system_registry_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "system_registry_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, + "system_registry_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 SYSTEM_REGISTRY_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define SYSTEM_REGISTRY_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if SYSTEM_REGISTRY_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "system_registry_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. +// --------------------------------------------------------------------------- + +struct SysTestPos { + std::int32_t x; + std::int32_t y; +}; +LAIGE_COMPONENT(SysTestPos); + +struct SysTestVel { + std::int64_t vx; +}; +LAIGE_COMPONENT(SysTestVel); + +struct SysTestHealth { + std::int32_t current; + std::int32_t max; +}; +LAIGE_COMPONENT(SysTestHealth); + +// --------------------------------------------------------------------------- +// The plain systems (FR-1.3: plain functions, no class, no +// inheritance). The LAIGE_SYSTEM macro declares each function and +// builds its def (`Name##Def`) directly above the definition. +// --------------------------------------------------------------------------- + +// The per-tick write path: the query's Write reference (query.h +// "Iteration legality"). +LAIGE_SYSTEM(SysMoveVel, 1) +void SysMoveVel(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx.each( + [](laige::Entity e, SysTestVel& v) { + static_cast(e); + v.vx = 0x1234; // the sentinel the tests read back + }, + laige::Write{})); +} + +// The per-tick read path: counts the health carriers through the +// context's delegated each (the file-scope counter is test plumbing). +namespace { +std::uint32_t sysReadHealthCount = 0; +} // namespace + +LAIGE_SYSTEM(SysReadHealth, 2) +void SysReadHealth(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + sysReadHealthCount = 0; + static_cast(ctx.each( + [](laige::Entity e, const SysTestHealth& h) { + static_cast(e); + static_cast(h); + ++sysReadHealthCount; + }, + laige::Read{})); +} + +namespace { + +// One world, taken out of its Result (Result::value() is const; +// takeValue() && moves the storage out — the documented +// ownership-transfer path, result.h). +laige::World makeWorld(std::uint32_t capacity) { + auto w = laige::World::create(laige::World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +// The manual SystemDef shape (the LAIGE_SYSTEM macro builds the same +// struct); used for the validation-error cases. +laige::SystemDef makeDef(const char* name, laige::SystemFn run, + laige::fpx16_16 budgetMs) { + return laige::SystemDef{name, run, budgetMs}; +} + +// Plain helper functions (anonymous namespace: their addresses are +// taken directly, no macro involved). +void fnA(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} +void fnB(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +// Distinct registration names for the bulk tests (static storage: the +// names must outlive the registration — system.h). +inline char* bulkName(std::uint32_t i) { + static char names[laige::kMaxSystems + 1][16]; + std::snprintf(names[i], sizeof(names[i]), "Sys%03u", i); + return names[i]; +} + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; the tests that use it restore the +// default console sink at the end — the ComponentRegistry pattern). +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; +}; + +// Find one field of a recorded event (test plumbing). +const char* fieldValue(const MemorySink::Entry& entry, + const char* key) { + for (const auto& [k, v] : entry.fields) { + if (k == key) return v.c_str(); + } + return ""; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The id type (FR-1.3; the component.h precedent) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, IdTypeBasics) { + EXPECT_EQ(laige::kInvalidSystemId.value, 0u); + EXPECT_NE(laige::SystemId{1}, laige::kInvalidSystemId); + EXPECT_EQ(laige::SystemId{1}, laige::SystemId{1}); + EXPECT_NE(laige::SystemId{1}, laige::SystemId{2}); + EXPECT_FALSE(laige::SystemId{1} != laige::SystemId{1}); +} + +// --------------------------------------------------------------------------- +// The LAIGE_SYSTEM macro (FR-1.3: plain registered functions) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, MacroBuildsDefAndFunction) { + // The macro built SysMoveVel_Def from the plain function: the name + // is the stringified Name, the run pointer is the function, the + // budget is the declared value (exact fpx16_16 — 1 ms). + EXPECT_STREQ(SysMoveVel_Def.name, "SysMoveVel"); + EXPECT_EQ(SysMoveVel_Def.run, &SysMoveVel); + EXPECT_EQ(SysMoveVel_Def.budgetMs, laige::fpx16_16::fromInt32(1)); + // The second macro use (2 ms, a different function): + EXPECT_STREQ(SysReadHealth_Def.name, "SysReadHealth"); + EXPECT_EQ(SysReadHealth_Def.run, &SysReadHealth); + EXPECT_EQ(SysReadHealth_Def.budgetMs, laige::fpx16_16::fromInt32(2)); +} + +// --------------------------------------------------------------------------- +// Registration: ids, the def value copy, the I/O sets +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, IdsAssignedInRegistrationOrder) { + laige::World world = makeWorld(0); + auto a = world.registerSystem(SysMoveVel_Def); + ASSERT_TRUE(a.ok()); + auto b = world.registerSystem(SysReadHealth_Def); + ASSERT_TRUE(b.ok()); + // Dense from 1, in the order of the successful calls: + EXPECT_EQ(a.value().value, 1u); + EXPECT_EQ(b.value().value, 2u); + EXPECT_EQ(world.systemCount(), 2u); + // 0 is never assigned: + EXPECT_NE(a.value(), laige::kInvalidSystemId); +} + +TEST(SystemRegistry, DefStoredByValue) { + laige::World world = makeWorld(0); + // A stack-scoped def: registerSystem must copy it (system.h: the + // user's def may be a stack variable). + const laige::SystemDef def = + makeDef("StackDef", &fnA, laige::fpx16_16{1 << 15}); // 0.5 ms + auto r = world.registerSystem(def); + ASSERT_TRUE(r.ok()); + auto info = world.system(r.value()); + ASSERT_TRUE(info.ok()); + EXPECT_STREQ(info.value().def.name, "StackDef"); + EXPECT_EQ(info.value().def.run, &fnA); + EXPECT_EQ(info.value().def.budgetMs, laige::fpx16_16{1 << 15}); + EXPECT_EQ(info.value().id, r.value()); +} + +TEST(SystemRegistry, IoDeclaredAndQueryable) { + laige::World world = makeWorld(1); + auto pos = world.registerComponent(); + auto vel = world.registerComponent(); + ASSERT_TRUE(pos.ok() && vel.ok()); + auto r = world.registerSystem(SysMoveVel_Def, + laige::Io{}, + laige::Io{}); + ASSERT_TRUE(r.ok()); + auto info = world.system(r.value()); + ASSERT_TRUE(info.ok()); + // The declared I/O, as stored (the disjoint read/write sets): + EXPECT_TRUE(info.value().declaresWrite(vel.value())); + EXPECT_TRUE(info.value().declaresRead(pos.value())); + EXPECT_FALSE(info.value().declaresRead(vel.value())); + EXPECT_FALSE(info.value().declaresWrite(pos.value())); + // A registered component the system does not declare: + auto health = world.registerComponent(); + ASSERT_TRUE(health.ok()); + EXPECT_FALSE(info.value().declaresRead(health.value())); + EXPECT_FALSE(info.value().declaresWrite(health.value())); +} + +TEST(SystemRegistry, ZeroIoPackLegal) { + // A system that touches no components declares no I/O: the empty + // pack is legal (a pure query system). + laige::World world = makeWorld(0); + ASSERT_TRUE(world.registerComponent().ok()); + auto r = world.registerSystem(SysMoveVel_Def); + ASSERT_TRUE(r.ok()); + auto info = world.system(r.value()); + ASSERT_TRUE(info.ok()); + EXPECT_FALSE(info.value().declaresRead(laige::ComponentTypeId{1})); + EXPECT_FALSE(info.value().declaresWrite(laige::ComponentTypeId{1})); +} + +// --------------------------------------------------------------------------- +// SystemContext: the delegated World::each (query.h contract) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, ContextDelegatesEach) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + auto e1 = world.create(); + auto e2 = world.create(); + ASSERT_TRUE(e1.ok() && e2.ok()); + ASSERT_TRUE(world.addComponent(e1.value(), SysTestVel{0}).ok()); + ASSERT_TRUE(world + .addComponent(e2.value(), + SysTestHealth{1, 10}) + .ok()); + laige::SystemContext ctx{world}; + // The plain function IS the system: calling it directly is what + // the M1-SYS-02 scheduler will do. The write path lands the + // sentinel on exactly the SysTestVel carrier. + static_cast(SysMoveVel(world, ctx)); + const SysTestVel* v1 = world.get(e1.value()); + ASSERT_NE(v1, nullptr); + EXPECT_EQ(v1->vx, 0x1234); + EXPECT_EQ(world.get(e2.value()), nullptr); + // The read path counts exactly the SysTestHealth carrier (the + // const reference: no write is possible through it, API-008). + static_cast(SysReadHealth(world, ctx)); + EXPECT_EQ(sysReadHealthCount, 1u); +} + +// --------------------------------------------------------------------------- +// Validation errors (system.h "Registration"; the first failure wins) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, NullNameRejected) { + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef(nullptr, &fnA, + laige::fpx16_16::fromInt32(1))); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, EmptyNameRejected) { + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef("", &fnA, + laige::fpx16_16::fromInt32(1))); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, NullRunRejected) { + laige::World world = makeWorld(0); + auto r = + world.registerSystem(makeDef("NoRun", nullptr, + laige::fpx16_16::fromInt32(1))); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, ZeroBudgetRejected) { + // The budget must be explicit and strictly positive (FR-1.3): 0 is + // not "unset" — it is an undeclared budget (API-008). + laige::World world = makeWorld(0); + auto r = world.registerSystem( + makeDef("NoBudget", &fnA, laige::fpx16_16{})); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, NegativeBudgetRejected) { + laige::World world = makeWorld(0); + auto r = world.registerSystem( + makeDef("NegBudget", &fnA, laige::fpx16_16::fromInt32(-1))); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, FractionalBudgetAccepted) { + // 0.5 ms = 32768 raw Q16.16 units (fpx16_16: exact, no rounding): + // a positive sub-millisecond budget is legal and stored exactly. + laige::World world = makeWorld(0); + auto r = world.registerSystem( + makeDef("HalfMs", &fnA, laige::fpx16_16{1 << 15})); + ASSERT_TRUE(r.ok()); + auto info = world.system(r.value()); + ASSERT_TRUE(info.ok()); + EXPECT_EQ(info.value().def.budgetMs, laige::fpx16_16{1 << 15}); +} + +TEST(SystemRegistry, DuplicateNameIsAnError) { + // The roadmap's named property: duplicate system names are an + // error, not a no-op (FR-12.3: never silent). + laige::World world = makeWorld(0); + auto first = world.registerSystem( + makeDef("Dup", &fnA, laige::fpx16_16::fromInt32(1))); + ASSERT_TRUE(first.ok()); + // A different function, the same registration name: + auto second = world.registerSystem( + makeDef("Dup", &fnB, laige::fpx16_16::fromInt32(2))); + EXPECT_FALSE(second.ok()); + EXPECT_EQ(second.error(), laige::ErrorCode::InvalidArgument); + // The registry is unchanged: same count, the first def intact: + EXPECT_EQ(world.systemCount(), 1u); + auto info = world.system(first.value()); + ASSERT_TRUE(info.ok()); + EXPECT_STREQ(info.value().def.name, "Dup"); + EXPECT_EQ(info.value().def.run, &fnA); + EXPECT_EQ(info.value().def.budgetMs, laige::fpx16_16::fromInt32(1)); +} + +TEST(SystemRegistry, IoUnregisteredComponentIsAnError) { + // SysTestVel is not registered in this world: the I/O entry cannot + // be resolved (component.h id contract: per-world ids). + laige::World world = makeWorld(1); + auto r = world.registerSystem( + SysMoveVel_Def, laige::Io{}); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemRegistry, IoDuplicateComponentIsAnError) { + laige::World world = makeWorld(1); + ASSERT_TRUE(world.registerComponent().ok()); + // Read + Write of the same component: ambiguous — the I/O is a set, + // not a multiset (system.h; M1-SYS-02 reads the disjoint sets). + auto rw = world.registerSystem(SysMoveVel_Def, + laige::Io{}, + laige::Io{}); + EXPECT_FALSE(rw.ok()); + EXPECT_EQ(rw.error(), laige::ErrorCode::InvalidArgument); + // Read + Read: also a duplicate: + auto rr = world.registerSystem(SysReadHealth_Def, + laige::Io{}, + laige::Io{}); + EXPECT_FALSE(rr.ok()); + EXPECT_EQ(rr.error(), laige::ErrorCode::InvalidArgument); + // The failed registrations change nothing: + EXPECT_EQ(world.systemCount(), 0u); + // The same component with a DIFFERENT one is fine: + ASSERT_TRUE(world.registerComponent().ok()); + auto ok = world.registerSystem(SysMoveVel_Def, + laige::Io{}, + laige::Io{}); + EXPECT_TRUE(ok.ok()); +} + +// --------------------------------------------------------------------------- +// The engine-level system budget (kMaxSystems) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, EngineBudgetHonored) { + // kMaxSystems is the engine-level system budget (system.h): + // reaching it turns further registration into BudgetExhausted (the + // world never grows silently, S-2/G-R1). + laige::World world = makeWorld(0); + for (std::uint32_t i = 0; i < laige::kMaxSystems; ++i) { + auto r = world.registerSystem(makeDef(bulkName(i), &fnA, + laige::fpx16_16::fromInt32(1))); + ASSERT_TRUE(r.ok()); + } + EXPECT_EQ(world.systemCount(), laige::kMaxSystems); + // A distinct 257th system: this is the budget path, not the + // duplicate path: + auto over = world.registerSystem(makeDef(bulkName(laige::kMaxSystems), + &fnB, + laige::fpx16_16::fromInt32(1))); + EXPECT_FALSE(over.ok()); + EXPECT_EQ(over.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(world.systemCount(), laige::kMaxSystems); +} + +// --------------------------------------------------------------------------- +// system() id validation (the componentInfo precedent) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, SystemQueryValidatesTheId) { + laige::World world = makeWorld(0); + // The reserved id: + auto invalid = world.system(laige::kInvalidSystemId); + EXPECT_FALSE(invalid.ok()); + EXPECT_EQ(invalid.error(), laige::ErrorCode::InvalidArgument); + // Nothing registered yet: + auto empty = world.system(laige::SystemId{1}); + EXPECT_FALSE(empty.ok()); + EXPECT_EQ(empty.error(), laige::ErrorCode::InvalidArgument); + // Register one; id 2 is still unregistered: + auto first = world.registerSystem(SysMoveVel_Def); + ASSERT_TRUE(first.ok()); + auto above = world.system(laige::SystemId{first.value().value + 1}); + EXPECT_FALSE(above.ok()); + EXPECT_EQ(above.error(), laige::ErrorCode::InvalidArgument); + // The registered id resolves: + auto ok = world.system(first.value()); + EXPECT_TRUE(ok.ok()); + EXPECT_STREQ(ok.value().def.name, "SysMoveVel"); +} + +// --------------------------------------------------------------------------- +// Determinism of the id assignment (ARCH-010; system.h contract) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, SystemIdsStableAcrossWorldsWithSameOrder) { + // The id assignment is pure integer bookkeeping (system.h preamble, + // ARCH-010): two worlds that register the same systems in the same + // order produce bit-identical id sequences (the property also + // holds across process runs and builds). + laige::World w1 = makeWorld(0); + laige::World w2 = makeWorld(0); + ASSERT_TRUE(w1.registerComponent().ok()); + ASSERT_TRUE(w2.registerComponent().ok()); + ASSERT_TRUE(w1.registerComponent().ok()); + ASSERT_TRUE(w2.registerComponent().ok()); + auto a1 = w1.registerSystem(SysMoveVel_Def, + laige::Io{}); + auto b1 = w1.registerSystem(SysReadHealth_Def, + laige::Io{}); + auto a2 = w2.registerSystem(SysMoveVel_Def, + laige::Io{}); + auto b2 = w2.registerSystem(SysReadHealth_Def, + laige::Io{}); + ASSERT_TRUE(a1.ok() && b1.ok() && a2.ok() && b2.ok()); + EXPECT_EQ(a1.value(), a2.value()); + EXPECT_EQ(b1.value(), b2.value()); + EXPECT_EQ(a1.value().value, 1u); + EXPECT_EQ(b1.value().value, 2u); +} + +TEST(SystemRegistry, RegistrationOrderDeterminesIds) { + // The documented contract (system.h): ids follow registration order, + // so a different order produces different ids. Games register their + // systems once at world setup, in one documented place (M1-HEAD-01 + // wires the loop around it). + laige::World w1 = makeWorld(0); + laige::World w2 = makeWorld(0); + auto a1 = w1.registerSystem(SysMoveVel_Def); // id 1 in w1 + auto b1 = w1.registerSystem(SysReadHealth_Def); // id 2 in w1 + auto a2 = w2.registerSystem(SysReadHealth_Def); // id 1 in w2 + auto b2 = w2.registerSystem(SysMoveVel_Def); // id 2 in w2 + ASSERT_TRUE(a1.ok() && b1.ok() && a2.ok() && b2.ok()); + EXPECT_EQ(a1.value().value, 1u); + EXPECT_EQ(b1.value().value, 2u); + EXPECT_EQ(a2.value().value, 1u); + EXPECT_EQ(b2.value().value, 2u); + // The same def in different worlds/orders: different ids — + // SystemIds are per-world and never compared across worlds. + EXPECT_NE(a1.value(), b2.value()); + EXPECT_NE(b1.value(), a2.value()); +} + +// --------------------------------------------------------------------------- +// Registry lifetime: move, clear, moved-from world +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, MovedWorldCarriesRegistry) { + laige::World w1 = makeWorld(2); + ASSERT_TRUE(w1.registerComponent().ok()); + auto a = w1.registerSystem(SysMoveVel_Def, + laige::Io{}); + ASSERT_TRUE(a.ok()); + laige::World w2 = std::move(w1); + EXPECT_EQ(w2.systemCount(), 1u); + auto info = w2.system(a.value()); + ASSERT_TRUE(info.ok()); + EXPECT_TRUE(info.value().declaresWrite(laige::ComponentTypeId{1})); + // The moved-from world has no registry (valid empty world): + EXPECT_EQ(w1.systemCount(), 0u); + auto r = w1.registerSystem(SysReadHealth_Def); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + auto q = w1.system(laige::SystemId{1}); + EXPECT_FALSE(q.ok()); + EXPECT_EQ(q.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(SystemRegistry, ClearLeavesRegistryUntouched) { + // clear() destroys live entities (M1-ECS-01); the system registry + // is setup state and survives (a system is not per-entity data). + laige::World world = makeWorld(2); + ASSERT_TRUE(world.registerComponent().ok()); + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), SysTestVel{0}).ok()); + auto s = world.registerSystem(SysMoveVel_Def, + laige::Io{}); + ASSERT_TRUE(s.ok()); + ASSERT_TRUE(world.clear().ok()); + EXPECT_EQ(world.entityCount(), 0u); + EXPECT_EQ(world.systemCount(), 1u); + auto info = world.system(s.value()); + ASSERT_TRUE(info.ok()); + EXPECT_TRUE(info.value().declaresWrite(laige::ComponentTypeId{1})); +} + +// --------------------------------------------------------------------------- +// Zero allocation at registration (setup path; PERF-003) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(SystemRegistry, RegistrationPerformsNoHeapAllocation) { + // The registration path (def copy + I/O sets into the fixed table) + // must allocate nothing: the record table is the world's setup-path + // allocation (system.h; the component registry precedent). + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + laige::test::resetAllocCounter(); + for (std::uint32_t i = 0; i < 32; ++i) { + auto r = world.registerSystem( + makeDef(bulkName(i), &fnA, laige::fpx16_16::fromInt32(1 + i % 4)), + laige::Io{}); + ASSERT_TRUE(r.ok()); + } + // And a full read-back of one record: + auto info = world.system(laige::SystemId{1}); + ASSERT_TRUE(info.ok()); + static_cast(info); + EXPECT_EQ(laige::test::allocCounter(), 0u); +} +#endif + +// --------------------------------------------------------------------------- +// Structured logging (FR-12.3: never silent; LOG-004 rate limiting) +// --------------------------------------------------------------------------- + +TEST(SystemRegistry, DuplicateWarnsOnceWithIdentifyingFields) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); // the burst stays in-window + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(1); + ASSERT_TRUE(world + .registerSystem(makeDef("DupWarn", &fnA, + laige::fpx16_16::fromInt32(1))) + .ok()); + + // Three duplicate attempts within the window: the first emits the + // warn, the other two are suppressed and counted (LOG-004: the + // rate_limited summary carries the count). + for (int i = 0; i < 3; ++i) { + auto r = world.registerSystem(makeDef("DupWarn", &fnB, + laige::fpx16_16::fromInt32(2))); + EXPECT_FALSE(r.ok()); + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + } + } + + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(sinkPtr->entries[0].subsystem, "system"); + EXPECT_EQ(sinkPtr->entries[0].event, "duplicate"); + // The event identifies the name and the existing id (LOG-002): + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "name"), "DupWarn"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "existing_system_id"), "1"); + + // Controlled shutdown drains the pending rate-limit summary + // (CONC-006/LOG-007/LOG-004). + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(sinkPtr->entries.size(), 2u); + EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + EXPECT_STREQ(fieldValue(sinkPtr->entries[1], "suppressed"), "2"); + + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +} + +TEST(SystemRegistry, BudgetInvalidWarnsOnceWithRawField) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(0); + auto r = world.registerSystem( + makeDef("NoBudget", &fnA, laige::fpx16_16{})); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(sinkPtr->entries[0].subsystem, "system"); + EXPECT_EQ(sinkPtr->entries[0].event, "budget_invalid"); + // The rejected budget, in Q16.16 raw units (system.h): the value is + // raw / 2^16 ms. + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "name"), "NoBudget"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "budget_raw"), "0"); + + laige::log::Logger::instance().shutdown(); + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +}