From db1377081b364656296a7ceab924af48162ab947 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 20:26:47 +0200 Subject: [PATCH 1/2] [M1-ECS-02] Component registry + trait-based IDs - ComponentTypeId: stable per-world ids, dense in registration order from 1, 0 reserved (kInvalidComponentTypeId), per-world contract, operator< = registration order (ARCH-010: pure integer bookkeeping). - LAIGE_COMPONENT(Type): compile-time trait mark (data-only; replication / inspector traits land in M4). User-defined structs register through the same path (S-8 data-carrier case). - World::registerComponent: records sizeof/alignof for the M1-ECS-03 SoA layout; duplicate registration is an error (InvalidArgument + rate-limited warn ecs/component_duplicate); engine-level budget kMaxComponentTypes = 256 (BudgetExhausted); setup-phase O(n), no allocation; moved-from world has no registry. - Type identity without RTTI/unordered: per-type inline-static marker address (NFR-8.10, PERF-006). - Tests: 12 cases under the new component_registry CTest entry (duplicate error, id stability across two worlds with the same order, order-determines-ids, 256-type budget boundary, size/alignment, validation, move/clear, warn-once). - Docs: docs/api/component_registry.md (+ cross-refs), laige-api.json regenerated (421 symbols). Verify: ctest -R component_registry green; full ctest 35/35 on canonical g++ Debug, ASan (35/35), and Clang (35/35) trees; api-real-tree green; tools/laige-include-lint OK. --- docs/README.md | 6 +- docs/api/component_registry.md | 206 +++++++++ docs/api/entity.md | 5 +- laige-api.json | 77 ++-- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-sim/README.md | 13 +- src/laige-sim/entity.cpp | 26 +- src/laige-sim/include/laige/sim/component.h | 198 +++++++++ src/laige-sim/include/laige/sim/entity.h | 92 +++- tests/laige-sim/CMakeLists.txt | 20 +- tests/laige-sim/component_registry_tests.cpp | 438 +++++++++++++++++++ 12 files changed, 1039 insertions(+), 49 deletions(-) create mode 100644 docs/api/component_registry.md create mode 100644 src/laige-sim/include/laige/sim/component.h create mode 100644 tests/laige-sim/component_registry_tests.cpp diff --git a/docs/README.md b/docs/README.md index 7b0cdc6..b836db0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,8 @@ Documentation index and navigation (DOC-001). The engine is at **M1** (heartbeat): `laige-core` holds the M0 foundations, and `laige-sim` -has started (M1-ECS-01: the entity handle and world entity storage). +has started (M1-ECS-01: the entity handle and world entity storage; +M1-ECS-02: the component type 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. @@ -29,6 +30,9 @@ still to land. - [Entity handle and world entity storage](api/entity.md) — `laige::Entity` (32-bit id+generation handle) and `laige::World` entity storage (M1-ECS-01; `laige-sim`). +- [Component type registry](api/component_registry.md) — + `ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent` + (M1-ECS-02; `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/component_registry.md b/docs/api/component_registry.md new file mode 100644 index 0000000..904229f --- /dev/null +++ b/docs/api/component_registry.md @@ -0,0 +1,206 @@ +# Component type registry (`ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent`) + +The M1 component registry (M1-ECS-02; PRD §9.1 S-8, FR-1.2, AGENTS +CPP-003/007, ARCH-010, PERF-003/006). Public header: +`src/laige-sim/include/laige/sim/component.h`; the `World` methods are +declared in `src/laige-sim/include/laige/sim/entity.h` and implemented +in `src/laige-sim/entity.cpp` / `entity.h` (the template). Unit suite: +`ctest -R component_registry` (`tests/laige-sim/component_registry_tests.cpp`). + +Components are identified by **compile-time trait types** (FR-1.2): a +type becomes a component by writing `LAIGE_COMPONENT(T)` once next to +its definition, and a component exists in a world once +`world.registerComponent()` succeeds. Built-in and user-defined +components use the same path — a user-defined struct is a legal +component (S-8 data-carrier case, PRD Appendix B shape). + +M1 components are **data-only**. The replication and inspector traits +(the full `LAIGE_COMPONENT(T, .replicate(...), .inspector(...))` +surface) extend the macro in M4; the M1 trait carries no behavior. + +## The id contract (FR-1.2, ARCH-010) + +`ComponentTypeId` is a 32-bit value; `0` is reserved +(`laige::kInvalidComponentTypeId` is never assigned). + +- Ids are assigned **in registration order, densely from 1**: the first + successful `registerComponent()` on a world gets id 1, the second + gets id 2, and so on. +- The cap is `laige::kMaxComponentTypes` (**256** types per world) — + a named engine-level constant (CORE-005). A game's component + vocabulary is orders of magnitude smaller than its entity count; a + project that outgrows the cap raises the constant through an ADR, + not per world. +- **Determinism (ARCH-010):** assignment is pure integer bookkeeping — + no addresses, hashes, or platform state enter the id. Two worlds, + two process runs, or two builds that register the same types in the + same order produce bit-identical id sequences. Component ids are + therefore replay state from M1 on and safe to replicate + (M1-DET-01/02). The ordering contract is documented, not implied: + a *different* registration order produces *different* ids — games + register their component set once at world setup, in one documented + place (M1-HEAD-01 wires the loop around it). +- **Per-world ids:** a `ComponentTypeId` is meaningful only in the + world that assigned it (the same cross-world caveat as `Entity` + handles). Never compare ids across worlds. + +`operator<` orders ids by registration order; M1-ECS-03's +per-archetype component sets and M1-ECS-05's deterministic iteration +order by it. + +## The `LAIGE_COMPONENT` macro + +```cpp +struct Health { + std::int32_t current = 100; + std::int32_t max = 100; +}; +LAIGE_COMPONENT(Health); // once, at namespace scope, next to the type +``` + +- Compile-time only: it specializes the component trait for `T`. It + adds no runtime state by itself; the runtime registration (id + + size/alignment) happens in `World::registerComponent()`. +- Components must be **trivially copyable** data carriers (S-8): + `registerComponent()` `static_assert`s this, so a non-trivial + member (a string, a destructor, a vtable) is a compile error with an + actionable message before it can break the M1-ECS-03 SoA layout. +- Marking a type twice is a compile error (duplicate specialization). +- The macro must be written at namespace scope: the C++ standard + requires a full specialization to be declared in the primary + template's enclosing namespace. + +## The `World` API + +| Operation | Behavior | Complexity / allocation | +|---|---|---| +| `registerComponent()` | Setup-phase registration: assigns the next id and records `sizeof(T)`/`alignof(T)`. See the Errors table below | O(n) in the registered types; no allocation | +| `componentCount()` | Types registered so far (`0 .. kMaxComponentTypes`) | O(1) | +| `componentInfo(id)` | The recorded `ComponentInfo{size, alignment}` for a registered id | O(1), no allocation | + +`registerComponent()` is a **setup-phase operation** (world +construction, before the loop; API-004: mutation in an explicit +phase). It is not a per-tick operation and never runs on the sim hot +path. The M1-ECS-03 SoA storage reads the recorded size/alignment; +per-entity component data arrives with that step. + +### Type identity without RTTI (NFR-8.10, PERF-006) + +Duplicate detection and the type → record lookup key on each type's +compile-time identity token: the address of +`detail::ComponentTypeKey::kMarker` (one `inline static` per +template specialization; distinct types have distinct addresses, +[basic.stc]). The registry table therefore uses no `std::type_info` +(RTTI is disabled, NFR-8.10) and no hash map (unordered containers are +banned from sim paths, PERF-006). The duplicate scan is linear over +the registered records — a setup-path cost, never a hot path. + +## Errors (FR-12.1, CORE-008) + +| Operation | Failure | Code | +|---|---|---| +| `registerComponent()` | `T` not marked with `LAIGE_COMPONENT` | compile error (`static_assert`, actionable message) | +| `registerComponent()` | `T` not trivially copyable | compile error (`static_assert`, actionable message) | +| `registerComponent()` | `T` already registered in this world | `ErrorCode::InvalidArgument` (2) + one rate-limited warn | +| `registerComponent()` | `kMaxComponentTypes` reached (a new type) | `ErrorCode::BudgetExhausted` (4) | +| `registerComponent()` | moved-from world (no registry) | `ErrorCode::InvalidArgument` (2) | +| `componentInfo(id)` | reserved/unregistered id | `ErrorCode::InvalidArgument` (2) | + +Duplicate registration is an **error, not a no-op** (FR-12.3: never +silent). The world logs it through the logging facade under the stable +subsystem name `ecs`, event `component_duplicate`, with the +already-registered `component_id` plus the recorded `size` and +`alignment` fields (LOG-002); the facade's rate limit implements the +warn-once semantics (LOG-004). + +## Performance (DOC-004) + +- **Setup path only:** `registerComponent()` is one linear scan + over ≤ 256 records plus one 16-byte record write. No allocation, no + I/O, no synchronization (PERF-003, PERF-007 documents the O(n) cost; + n is bounded by `kMaxComponentTypes`). +- **Registry memory:** 256 × 16 B = 4 KiB per world (fixed at world + construction, the setup-path allocation next to the entity tables). + This is type metadata only; per-entity component bytes are the + M1-ECS-03 SoA columns' job. +- **Zero-alloc enforcement:** the standing assertion lands with + M1-ALLOC-01; registration is not a hot path, so the step is verified + by ASan + the no-allocation implementation (M1 milestone rules). +- **Traps:** + - Do not register in a per-tick system: mutation in the sim phase + invalidates iteration (M1-ECS-04's legality contract) and breaks + the replay identity (the registration order is replay state). + - `BudgetExhausted` from registration means the engine-level type + budget is full: surface it and review the component vocabulary + (raise the constant via ADR) — never add per-world capacity. + - `componentInfo(id)` with an id from another world is + meaningless (per-world ids above). + +## Threading, ownership, determinism + +- `World` has one owner thread (CONC-001; PRD §10.2: simulation is + single-threaded in M1); registration happens on that thread, in the + setup phase. `World` is move-only; the registry moves with it, and a + moved-from world has no registry (`registerComponent()` returns + `InvalidArgument`; the same "valid empty world" contract as entity + creation). +- `clear()` destroys live entities only; the type registry is setup + state and survives (M1-ECS-03's `clear()` will destroy per-entity + component data, not the type records). +- **Determinism (ARCH-010):** see the id contract — bit-identical id + sequences across runs/builds for a fixed registration order. + +## Usage (performant pattern) + +```cpp +// One documented registration site per world setup (M1-HEAD-01). +struct PlayerPos { int32_t x; int32_t y; }; // built-in or user type +LAIGE_COMPONENT(PlayerPos); + +auto w = laige::World::create(laige::World::Options{2000}); +laige::World& world = std::move(w).takeValue(); + +// Setup phase: register every component type the game uses, once. +auto pos = world.registerComponent(); +if (pos.isError()) { + // InvalidArgument -> duplicate (or moved-from world): fix the + // registration list (FR-12.3). + // BudgetExhausted -> engine-level type budget full: ADR path. +} +// `pos.value()` is the stable ComponentTypeId for this world — +// M1-SYS-01 declares system I/O by these ids, and M1-DET-02's replay +// header hashes the component schema built from them. + +// Later (M1-ECS-03): world.add_component(e, {...}) and +// world.get(e) resolve T -> id -> SoA column through this +// registry; O(1), no allocation. +``` + +## Misuse warnings + +- Registering the same type twice on one world is an error, not a + no-op: read the `Result` (FR-12.3). +- Do not compare `ComponentTypeId` values across worlds (per-world + ids). +- A moved-from world has no registry: `registerComponent()` on one + fails with `InvalidArgument`. +- Mark each type once with `LAIGE_COMPONENT`; a second mark is a + compile error. +- Component types must be trivially copyable (S-8 data carriers); the + `static_assert` in `registerComponent()` is the guard. + +## Roadmap context + +- **M1-ECS-01 (done):** the entity handle and world entity storage + this registry hangs off — see [entity.md](entity.md). +- **M1-ECS-02 (this step):** the component registry above. +- **M1-ECS-03:** archetype SoA storage consumes the recorded + size/alignment (`world.add_component`/`world.get`, O(1) column + lookup). +- **M1-ECS-05:** deterministic iteration orders the component sets by + registration order (`operator<`). +- **M1-SYS-01:** system I/O declarations reference these ids. +- **M1-DET-02:** the replay header's component-schema hash is computed + from the registered (id, size, alignment) triples in id order. +- **M4:** the macro gains the replication/inspector trait surface + (PRD Appendix B). diff --git a/docs/api/entity.md b/docs/api/entity.md index 38dac72..6e687d8 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -160,8 +160,9 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } ## Roadmap context - **M1-ECS-01 (this step):** the handle + entity storage above. -- **M1-ECS-02:** the component registry (`ComponentTypeId`) — - components get a stable compile-time id. +- **M1-ECS-02 (done):** the component registry — + `ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent`; + see [component_registry.md](component_registry.md). - **M1-ECS-03:** archetype SoA component storage on top of the same slot table; `world.get(e)` is built on `World::check(e)` and inherits the stale-handle contract. diff --git a/laige-api.json b/laige-api.json index a870276..1f5ea04 100644 --- a/laige-api.json +++ b/laige-api.json @@ -12,6 +12,7 @@ "src/laige-core/include/laige/prng.h", "src/laige-core/include/laige/result.h", "src/laige-core/include/laige/sim_math.h", + "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/entity.h" ], "symbols": [ @@ -391,36 +392,50 @@ {"name": "laige::sim::SimMath::notEquals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 396, "signature": "static bool notEquals(Vec3 a, Vec3 b) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::sim::SimMathFpx16", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 405, "signature": "using SimMathFpx16 = SimMath", "summary": "The `fpx16_16` SimMath (this step; `determinism.math` config id \"fixed_point_16_16\"). The DEFAULT backend (ADR 0002); required for lockstep (AC-10.3) and authoritative MMO. The `determinism.math` config plumbing lands with the config step (FR-1.5); until then, deterministic/lockstep engine code targets SimMathFpx16 and opt-in IEEE-float zones target SimMathFp32.", "budget": null, "experimental": false}, {"name": "laige::sim::SimMathFp32", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 410, "signature": "using SimMathFp32 = SimMath", "summary": "The `fp32_pinned` SimMath (M0-CORE-03; `determinism.math` config id \"float_pinned_32\"). Opt-in for single-player / non-lockstep zones that want IEEE float semantics (ADR 0002).", "budget": null, "experimental": false}, - {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 125, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, - {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 126, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 127, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 129, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 130, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 138, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 141, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 155, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, - {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 156, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 157, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 158, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 159, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 160, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 161, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 168, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, - {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 175, "signature": "struct Options", "summary": "The declared scene budget (G-R3), fixed at construction (API-006). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 176, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 182, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 187, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, - {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 194, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1), no allocation. The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, - {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 201, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, - {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 204, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 207, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, - {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 211, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, - {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 215, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 222, "signature": "void clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity) scan, no allocation, idempotent. (No per-slot element data exists yet; M1-ECS-03 adds the per-entity record that clear() will then destroy.)", "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 226, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 227, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 228, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 229, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 233, "signature": "~World() noexcept", "summary": "Destroys nothing per element yet (no per-slot element data); releases the backing storage. Idempotent with clear().", "budget": null, "experimental": false} + {"name": "laige::ComponentTypeId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/component.h", "line": 119, "signature": "struct ComponentTypeId", "summary": "The stable per-world component type id (FR-1.2). See the header preamble for the assignment and determinism contracts.", "budget": null, "experimental": false}, + {"name": "laige::ComponentTypeId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 120, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kInvalidComponentTypeId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 125, "signature": "inline constexpr ComponentTypeId kInvalidComponentTypeId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/component.h", "line": 127, "signature": "inline bool operator==(ComponentTypeId a, ComponentTypeId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/component.h", "line": 130, "signature": "inline bool operator!=(ComponentTypeId a, ComponentTypeId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator<", "kind": "function", "header": "src/laige-sim/include/laige/sim/component.h", "line": 135, "signature": "inline bool operator<(ComponentTypeId a, ComponentTypeId b) noexcept", "summary": "Ordering is registration order (M1-ECS-03's per-archetype component sets and M1-ECS-05's deterministic iteration order by this).", "budget": null, "experimental": false}, + {"name": "laige::ComponentInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/component.h", "line": 142, "signature": "struct ComponentInfo", "summary": "The size/alignment recorded for a registered component type. The M1-ECS-03 SoA layout reads these: one column is a contiguous T[] of `size` bytes aligned to `alignment`.", "budget": null, "experimental": false}, + {"name": "laige::ComponentInfo::size", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 143, "signature": "std::uint32_t size{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ComponentInfo::alignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 144, "signature": "std::uint32_t alignment{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMaxComponentTypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 149, "signature": "inline constexpr std::uint32_t kMaxComponentTypes = 256", "summary": "The engine-level cap on component types per world (CORE-005). See the preamble for the rationale and the ADR path to raise it.", "budget": null, "experimental": false}, + {"name": "LAIGE_COMPONENT", "kind": "macro", "header": "src/laige-sim/include/laige/sim/component.h", "line": 196, "signature": "#define LAIGE_COMPONENT(Type)", "summary": "Mark T as a Laige component (FR-1.2; S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 130, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, + {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 131, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 132, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 134, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 135, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 143, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 146, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 160, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, + {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 161, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 162, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 163, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 164, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 165, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 166, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 173, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, + {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 180, "signature": "struct Options", "summary": "The declared scene budget (G-R3), fixed at construction (API-006). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 181, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 187, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 192, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, + {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 199, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1), no allocation. The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, + {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 206, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, + {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 209, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 212, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, + {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 216, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, + {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 220, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 241, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 246, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 252, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 259, "signature": "void clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity) scan, no allocation, idempotent. (No per-slot element data exists yet; M1-ECS-03 adds the per-entity record that clear() will then destroy.)", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 263, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 264, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 265, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 266, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 270, "signature": "~World() noexcept", "summary": "Destroys nothing per element yet (no per-slot element data); releases the backing storage. Idempotent with clear().", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 54dbd20..cad1c1e 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -27,7 +27,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R entity` green under ASan; debug stale-handle test asserts. - **Size:** ~200 lines + tests -- [ ] **M1-ECS-02 · Component registry + trait-based IDs** +- [x] **M1-ECS-02 · Component registry + trait-based IDs** - **Refs:** FR-1.2 (compile-time trait types); PRD §9.1 S-8 - **Depends:** M1-ECS-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 8ad206e..0e68c62 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 | 1 | 🚧 in progress (M1-ECS-01) | +| M1 | 25 | 2 | 🚧 in progress (M1-ECS-02) | | 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** | **23** | | +| **Total** | **193** | **24** | | --- @@ -196,6 +196,7 @@ One line per completed (or split/renumbered) step. | 2026-09-13 | M0-DOC-01 | `972090d` | Docs only: full AGENTS §13 `docs/` tree — `docs/README.md` rewritten as the single index (every section linked + honest "not yet written" list, DOC-001); new section indexes `concepts/` (planned docs + interim homes incl. the ARCH-008 coordinates topic), `guides/`, `debugging/` (usable today; AGENTS §15 debug-mode status), `compatibility/` (P0 platform/compiler table per PRD §6 + CI, current formats `budgets.json`/`laige-api.json`/`deps.lock`, no migration guides yet); `docs/benchmarks/` with `methodology.md` (normative AGENTS §12 report fields, `budgets.json` field→report mapping, immutable baseline-file convention, PRD §8.1 regression policy: >10% band, hard-zero budgets, loud `NO_SAMPLES`) and empty `baselines/` (first baseline `m0-synthetic.md` lands with M0-EXIT-01); `decisions/` index updated (stale M0-DOC-01 note replaced; ADRs 0001–0004 indexed); stale references fixed in the same change (root README docs pointer + ADR 0004; old index's non-existent "M0-DOC-02" reference dropped); dead-link check over all 49 repo `*.md` files (104 internal links, 0 dead — manual per the step's Verify clause; docs-only scope, no CI job added); `ctest` 32/32 green on the existing `build` tree; CI run 34749554015: 7/7 executed jobs green (macOS/Windows skipped, label-gated) | | 2026-09-13 | M0-EXIT-01 | `636257c` | M0 exit gate: all 21 prior M0 steps re-verified against their Scope/Verify clauses on commit `829026f` — fresh canonical g++ tree zero-warning, `ctest` 32/32; `build-shared`, `build-asan` (ASan+UBSan), `build-tsan`, and a fresh Clang 22.1.8 tree all 32/32; `laige-fuzz json_parse --runs=1000` clean; `laige-detcheck --scenario=synthetic` OK; `laige-api-scanner --check` up to date (376 symbols); `tools/laige-include-lint` OK (1/10 deps); vendored-tree lock re-proven live (tampered `deps/googletest` file fails the configure with expected-vs-actual hashes, restored tree passes); gate evidence: CI merge-lane run 34749756361 on `829026f` — all 10 jobs `success`, each under 1.2 min, `ctest` 32/32 in every P0 OS job (linux-gcc/clang/asan/tsan, windows-msvc, macos-arm64/intel); first baseline `docs/benchmarks/baselines/m0-synthetic.md` written (synthetic harness workload, full AGENTS §12 metadata, verbatim runs, commit `829026f`; no `budgets.json` `measured` updated — the stand-in measures no real budget); Progress Board 22/22, milestone marked complete | | 2026-09-13 | M1-ECS-01 | `f173682` | `laige-sim` becomes the first module beyond `laige-core` (M1 milestone rules): 32-bit `laige::Entity` handle (16-bit id + 16-bit generation, `Entity::kMaxEntities` = 65536 slots, generation 0 reserved, the documented 2^16 wrap collision pinned by test) + `laige::World` entity storage (create/destroy/check/isValid/clear/stats over a pool-backed slot table — generation table + alive flags + pre-allocated LIFO free stack; setup-only allocation, O(1) no-allocation operations; G-R3 capacity config: `BudgetExhausted` on overflow, `InvalidArgument` for a >65536 budget at construction, API-008; stale-handle contract: debug assert / release `Status(InvalidArgument)` + warn-once through the logging facade, events `ecs/stale_entity_access` + `ecs/stale_entity_destroy`; move-only, single owner thread, ARCH-010 bit-identical handle sequences); public API in `src/laige-sim/include/laige/sim/entity.h` (+ `entity.cpp`), new CMake target (static/shared, engine + SimMath policy, single PUBLIC link to laige-core); `entity` CTest entry (17 cases: LIFO slot assignment, generation bump on reuse, recycling order, clear/move semantics, capacity limit incl. 0 and the 65536 boundary, stale-detection matrix, `check()` Status path in every build, release `destroy` Status path, forked-SIGABRT stale `destroy` in debug, pinned 2^16 generation wrap, stats counts/peak/churn/bytes, warn-once rate-limit summary via a memory sink); API contract in `docs/api/entity.md`; `laige-api.json` regenerated (api-real-tree green); local Verify: fresh canonical g++ tree zero-warning, full `ctest` suite green incl. the new module (34 tests), `ctest -R entity` and the full suite green on a fresh ASan tree (the step's Verify clause); `tools/laige-include-lint` OK; `api-real-tree` green after the manifest regeneration | +| 2026-09-13 | M1-ECS-02 | — | Component type registry (M1-ECS-02 scope, nothing else): `ComponentTypeId` (32-bit, dense ids assigned in registration order from 1, 0 reserved as `kInvalidComponentTypeId`, per-world, `operator<` = registration order) + `LAIGE_COMPONENT(Type)` macro (compile-time trait mark, data-only — replication/inspector traits land in M4) + `World::registerComponent()` recording `sizeof(T)`/`alignof(T)` for the M1-ECS-03 SoA layout; user-defined structs register through the same path (S-8 data-carrier case); type identity without RTTI/unordered (per-type `inline static` marker address, NFR-8.10/PERF-006); engine-level budget `kMaxComponentTypes` = 256 (`BudgetExhausted` beyond it); duplicate registration → `InvalidArgument` + rate-limited warn `ecs/component_duplicate`; moved-from world → no registry (`InvalidArgument`); `static_assert` guards: `LAIGE_COMPONENT` mark + trivially-copyable (actionable compile errors); setup-phase O(n) no-allocation operation; registry moves with `World`, `clear()` leaves it untouched; new public header `src/laige-sim/include/laige/sim/component.h`; `component_registry` CTest entry (12 cases: id order, size/alignment incl. 8-byte alignment, duplicate error + unchanged registry, id stability across two worlds with the same registration order, order-determines-ids, 256-type budget boundary, `componentInfo` validation, move, clear, warn-once rate-limit summary via a memory sink); API contract in `docs/api/component_registry.md` (+ cross-refs in docs/README, entity.md, sim README); `laige-api.json` regenerated (421 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (35 tests) | --- diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 23d1cbe..70dd197 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -7,7 +7,12 @@ Status: M1 in progress. M1-ECS-01 landed the ECS foundation — the 32-bit `laige::Entity` handle and the `laige::World` entity storage (`include/laige/sim/entity.h`, `entity.cpp`; API contract in [docs/api/entity.md](../docs/api/entity.md), tests under -[tests/laige-sim](../tests/laige-sim), CTest entry `entity`). The -component registry, archetype storage, iteration, and the game loop -land in the remaining M1-ECS / M1-SYS / M1-LOOP steps; physics, input, -and animation in M3. +[tests/laige-sim](../tests/laige-sim), CTest entry `entity`). +M1-ECS-02 landed the component type registry — `ComponentTypeId`, the +`LAIGE_COMPONENT` macro, and `World::registerComponent()` +(`include/laige/sim/component.h`; API contract in +[docs/api/component_registry.md](../docs/api/component_registry.md), +tests under [tests/laige-sim](../tests/laige-sim), CTest entry +`component_registry`). The archetype storage, iteration, and the game +loop land in the remaining M1-ECS / M1-SYS / M1-LOOP steps; physics, +input, and animation in M3. diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index d3c2da8..b042efb 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -38,12 +38,15 @@ World::World(World&& other) noexcept alive_(std::move(other.alive_)), freeStack_(std::move(other.freeStack_)), freeCount_(other.freeCount_), inUse_(other.inUse_), - peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_) { + peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_), + components_(std::move(other.components_)), + componentCount_(other.componentCount_) { other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; other.peakInUse_ = 0; other.totalCreated_ = 0; + other.componentCount_ = 0; } World& World::operator=(World&& other) noexcept { @@ -57,11 +60,14 @@ World& World::operator=(World&& other) noexcept { inUse_ = other.inUse_; peakInUse_ = other.peakInUse_; totalCreated_ = other.totalCreated_; + components_ = std::move(other.components_); + componentCount_ = other.componentCount_; other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; other.peakInUse_ = 0; other.totalCreated_ = 0; + other.componentCount_ = 0; return *this; } @@ -73,6 +79,10 @@ Result World::create(Options options) noexcept { } World w; w.capacity_ = options.capacity; + // Component registry table (M1-ECS-02): the fixed engine-level + // budget (kMaxComponentTypes), a setup-path allocation like the + // entity tables below. + w.components_ = std::make_unique(kMaxComponentTypes); if (w.capacity_ > 0) { // Backing allocations for the whole storage (setup path, // PERF-002): the per-slot generation table, the per-slot alive @@ -155,6 +165,20 @@ std::uint32_t World::capacity() const noexcept { return capacity_; } std::uint32_t World::entityCount() const noexcept { return inUse_; } +std::uint32_t World::componentCount() const noexcept { + return componentCount_; +} + +Result World::componentInfo(ComponentTypeId id) const noexcept { + // Ids are dense from 1, so a valid registered id is exactly the + // range [1, componentCount_] (M1-ECS-02; component.h contract). + if (id.value == 0 || id.value > componentCount_ || components_ == nullptr) { + return ErrorCode::InvalidArgument; + } + const detail::ComponentRecord& rec = components_[id.value - 1]; + return ComponentInfo{rec.size, rec.alignment}; +} + EntityStats World::stats() const noexcept { return EntityStats{ capacity_, inUse_, peakInUse_, totalCreated_, diff --git a/src/laige-sim/include/laige/sim/component.h b/src/laige-sim/include/laige/sim/component.h new file mode 100644 index 0000000..a653ac1 --- /dev/null +++ b/src/laige-sim/include/laige/sim/component.h @@ -0,0 +1,198 @@ +// laige-sim component type registry (M1-ECS-02). +// +// PRD §9.1 S-8 (component traits are the customization surface) and +// FR-1.2 (components identified by compile-time trait types): this +// header ships the M1 data-only half of the component contract: +// +// ComponentTypeId The stable per-world component type id: a 32-bit +// dense value assigned in registration order (0 is +// reserved and never assigned). +// LAIGE_COMPONENT The compile-time registration mark: it +// specializes the component trait so +// World::registerComponent() accepts T. +// Replication/inspector traits (the PRD Appendix B +// shape) extend this macro in M4; M1 components +// are data carriers only (S-8). +// World::registerComponent() +// The per-world runtime registration (declared in +// entity.h, the World home): records sizeof(T)/ +// alignof(T) for the M1-ECS-03 SoA layout and +// assigns the next id. Built-in and user-defined +// components register through the same path — a +// user struct is legal (S-8 data-carrier case). +// +// --------------------------------------------------------------------------- +// The id contract (FR-1.2, ARCH-010) +// --------------------------------------------------------------------------- +// +// Ids are assigned in the order successful registerComponent() calls +// happen on a world, densely from 1: +// +// first registered type -> 1 +// second -> 2 +// ... +// +// kMaxComponentTypes (256) is the engine-level cap on the number of +// component types per world (CORE-005: a named engine constant — a +// game's component vocabulary is orders of magnitude smaller than +// its entity count; a project that outgrows it raises the constant +// through an ADR, not a per-world knob). +// +// Determinism (ARCH-010): assignment is pure integer bookkeeping — no +// addresses, hashes, or platform state enter the id. Two worlds, two +// process runs, or two builds that register the same types in the +// same order produce bit-identical id sequences, so ids are replay +// state from M1 on and are safe to replicate (M1-DET-01/02). The +// ordering contract is documented, not implied: a different +// registration order produces different ids — games register their +// component set once at world setup, in one documented place +// (M1-HEAD-01 wires the loop around it). +// +// Ids are per-world: a ComponentTypeId is meaningless in another +// world (the same cross-world caveat as Entity handles, entity.h). +// +// --------------------------------------------------------------------------- +// Type identity without RTTI (NFR-8.10, PERF-006) +// --------------------------------------------------------------------------- +// +// Duplicate detection and the type -> record lookup key on each +// component type's compile-time identity token: the address of +// ComponentTypeKey::kMarker (one inline static per template +// specialization; distinct types have distinct addresses, +// [basic.stc]). The registry table therefore needs no +// std::type_info (RTTI is disabled, NFR-8.10) and no hash map +// (unordered containers are banned from sim paths, PERF-006; +// M1-ECS-05 iteration contract). The duplicate scan is linear over +// the registered records — a setup-path cost, never a hot path +// (PERF-007). +// +// --------------------------------------------------------------------------- +// Allocation, threading, failure +// --------------------------------------------------------------------------- +// +// registerComponent() is a setup-phase operation (world construction, +// before the loop): one linear scan plus one record write, no +// allocation, O(n) in the number of registered types (PERF-007). It +// runs on the world's single owner thread (CONC-001) and is not a +// per-tick operation (API-004: mutation in an explicit phase). +// +// Failures are Result values (FR-12.1, CORE-008 — never silent): +// +// T already registered in this world -> ErrorCode::InvalidArgument +// (+ one rate-limited +// structured warn, event +// ecs/component_duplicate) +// more than kMaxComponentTypes -> ErrorCode::BudgetExhausted +// invalid/unregistered id in +// componentInfo() -> ErrorCode::InvalidArgument +// +// Component types must be trivially copyable data carriers (S-8): +// registerComponent() static-asserts this, so a non-trivial member +// (a string, a destructor, a vtable) is a compile error with an +// actionable message before it can break the M1-ECS-03 SoA layout. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Marking a type twice (two full specializations of the trait) is +// a compile error — the C++ standard rejects duplicate +// specializations; mark each type once. +// - Registering the same type twice on one world is an error, not a +// no-op: read the Result (FR-12.3). +// - Do not compare ComponentTypeId values across worlds (per-world +// ids, above). +// - A moved-from world has no registry: registerComponent() on one +// returns InvalidArgument (the same "valid empty world" contract +// as entity creation). + +#pragma once + +#include +#include +#include + +namespace laige { + +// The stable per-world component type id (FR-1.2). See the header +// preamble for the assignment and determinism contracts. +struct ComponentTypeId { + 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 ComponentTypeId kInvalidComponentTypeId{0}; + +inline bool operator==(ComponentTypeId a, ComponentTypeId b) noexcept { + return a.value == b.value; +} +inline bool operator!=(ComponentTypeId a, ComponentTypeId b) noexcept { + return !(a == b); +} +// Ordering is registration order (M1-ECS-03's per-archetype component +// sets and M1-ECS-05's deterministic iteration order by this). +inline bool operator<(ComponentTypeId a, ComponentTypeId b) noexcept { + return a.value < b.value; +} + +// The size/alignment recorded for a registered component type. The +// M1-ECS-03 SoA layout reads these: one column is a contiguous T[] of +// `size` bytes aligned to `alignment`. +struct ComponentInfo { + std::uint32_t size{}; // sizeof(T) + std::uint32_t alignment{}; // alignof(T) +}; + +// The engine-level cap on component types per world (CORE-005). See +// the preamble for the rationale and the ADR path to raise it. +inline constexpr std::uint32_t kMaxComponentTypes = 256; + +namespace detail { + +// Compile-time component mark: the primary template says "not a +// component"; LAIGE_COMPONENT(T) (below) specializes it per type. +// detail: engine implementation — games use the macro, not this +// template (CPP-016). +template +struct ComponentTraits { + static constexpr bool isComponent = false; +}; + +// Compile-time identity token, one per component type (see the +// preamble "Type identity without RTTI"). The marker's VALUE is never +// read; only its address is used, so it stays zero-initialized. +template +struct ComponentTypeKey { + inline static const char kMarker{}; +}; + +// One registry record per registered component type. World stores a +// dense array of these indexed by (id - 1) — ids are dense by +// construction, so no per-record id storage is needed. +struct ComponentRecord { + const void* typeKey{}; // &ComponentTypeKey::kMarker (identity) + std::uint32_t size{}; // sizeof(T) + std::uint32_t alignment{}; // alignof(T) +}; + +} // namespace detail + +// Mark T as a Laige component (FR-1.2; S-8 data-carrier case). +// +// Compile-time: this specializes the component trait for T, which is +// what World::registerComponent() checks. It adds no runtime state +// by itself; the runtime registration (id + size/alignment) happens +// in World::registerComponent() — the same path for built-in and +// user-defined types. Replication and inspector traits (the PRD +// Appendix B shape) extend this macro in M4. +// +// Write it once per type, at namespace scope, next to the type +// definition. +#define LAIGE_COMPONENT(Type) \ + template <> \ + struct laige::detail::ComponentTraits { \ + static constexpr bool isComponent = true; \ + }; + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index bbd3a67..d005c84 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -13,8 +13,10 @@ // World The entity storage a handle indexes into: create/destroy // over a budgeted, accounted, pool-backed slot table with // no per-operation heap (PERF-002/003, S-2; M0-CORE-05 -// Pool precedent). M1-ECS-03 adds the per-entity component -// storage on top of this slot table. +// Pool precedent). M1-ECS-02 adds the component type +// registry (registerComponent, component.h); M1-ECS-03 +// adds the per-entity component storage on top of this +// slot table. // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -115,8 +117,11 @@ #include #include "laige/errors.h" +#include "laige/logging.h" #include "laige/result.h" +#include "laige/sim/component.h" + namespace laige { // The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit @@ -214,6 +219,38 @@ class World { // G-R3 guardrail (M1-ECS-06). O(1), no allocation. [[nodiscard]] EntityStats stats() const noexcept; + // ----------------------------------------------------------------- + // Component registry (M1-ECS-02; full contract in component.h) + // ----------------------------------------------------------------- + + // 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). + // + // T not marked with LAIGE_COMPONENT -> compile error (static_assert) + // T not trivially copyable -> compile error (static_assert) + // T already registered in this world + // -> ErrorCode::InvalidArgument + // (+ one rate-limited warn, + // ecs/component_duplicate) + // more than kMaxComponentTypes -> ErrorCode::BudgetExhausted + // moved-from world (no registry) -> ErrorCode::InvalidArgument + template + [[nodiscard]] Result registerComponent() noexcept; + + // The number of component types registered so far + // (0 .. kMaxComponentTypes). O(1), no side effects. + [[nodiscard]] std::uint32_t componentCount() const noexcept; + + // 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. + [[nodiscard]] Result componentInfo(ComponentTypeId 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) scan, no allocation, idempotent. @@ -251,6 +288,57 @@ class World { std::uint32_t inUse_{0}; std::uint32_t peakInUse_{0}; std::uint64_t totalCreated_{0}; + // Component registry (M1-ECS-02): dense records indexed by + // (id - 1) — ids are dense by construction. Setup-only state: + // clear() does not touch it (M1-ECS-03's clear destroys per-entity + // component data, not the type registry). + std::unique_ptr components_; + std::uint32_t componentCount_{0}; }; +// Component registration (M1-ECS-02). Header-defined: it is a template, +// so it must be visible to every translation unit that registers a +// component. See component.h for the id contract, the type-identity +// mechanism, and the failure table. +template +Result World::registerComponent() noexcept { + static_assert(detail::ComponentTraits::isComponent, + "T is not a Laige component type: write LAIGE_COMPONENT(T) " + "once at namespace scope next to the type definition " + "(FR-1.2, S-8)"); + static_assert(std::is_trivially_copyable_v, + "Laige components must be trivially copyable data " + "carriers (S-8): a non-trivial member (a string, a " + "destructor, a vtable) is not supported by the " + "M1-ECS-03 SoA storage"); + constexpr const void* key = &detail::ComponentTypeKey::kMarker; + if (components_ == nullptr) { + // Moved-from world: no registry (see the preamble, entity.h). + return ErrorCode::InvalidArgument; + } + for (std::uint32_t i = 0; i < componentCount_; ++i) { + if (components_[i].typeKey == key) { + // Duplicate registration is an error (M1-ECS-02 scope); the + // facade rate-limits the warn per event (LOG-004). + LAIGE_LOG_WARN("ecs", "component_duplicate", + "Component type is already registered in this world; " + "duplicate registration is an error", + laige::log::field("component_id", i + 1u), + laige::log::field("size", components_[i].size), + laige::log::field("alignment", components_[i].alignment)); + return ErrorCode::InvalidArgument; + } + } + if (componentCount_ >= kMaxComponentTypes) { + // The engine-level component budget (component.h preamble). + return ErrorCode::BudgetExhausted; + } + detail::ComponentRecord& rec = components_[componentCount_]; + rec.typeKey = key; + rec.size = static_cast(sizeof(T)); + rec.alignment = static_cast(alignof(T)); + ++componentCount_; + return ComponentTypeId{componentCount_}; // ids are dense, from 1 +} + } // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index d2086d4..38964b2 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,12 +1,14 @@ -# laige-sim tests (M1-ECS-01): entity handle + World entity storage. +# laige-sim tests (M1-ECS-01/02): entity handle + World entity storage, +# component type registry. # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus -# gtest_main. The unfiltered entry runs the whole module; the -# `entity` entry is M1-ECS-01's Verify command (`ctest -R entity`), +# gtest_main. The unfiltered entry runs the whole module; the `entity` +# and `component_registry` entries are the M1-ECS-01 and M1-ECS-02 +# Verify commands (`ctest -R entity`, `ctest -R component_registry`), # selecting exactly the suites below from the shared executable. -set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp) +set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp) add_executable(laige-sim_tests ${LAIGE_SIM_TEST_SOURCES}) laige_apply_engine_policy(laige-sim_tests) @@ -31,9 +33,17 @@ add_test(NAME entity COMMAND laige-sim_tests --gtest_filter=EntityHandle.*:WorldBasics.*:WorldCapacity.*:WorldLogging.*:WorldStale.*:WorldStats.*) +# M1-ECS-02: component type registry. The step's Verify command is +# `ctest -R component_registry`; this entry selects exactly the +# ComponentTypeId/ComponentRegistry suites from the shared +# laige-sim_tests executable. +add_test(NAME component_registry + COMMAND laige-sim_tests + --gtest_filter=ComponentTypeId.*:ComponentRegistry.*) + 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 + set_tests_properties(laige-sim_tests entity component_registry PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/component_registry_tests.cpp b/tests/laige-sim/component_registry_tests.cpp new file mode 100644 index 0000000..1b957df --- /dev/null +++ b/tests/laige-sim/component_registry_tests.cpp @@ -0,0 +1,438 @@ +// laige-sim component type registry suite (M1-ECS-02). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - duplicate registration is an error (InvalidArgument + one +// rate-limited structured warn, ecs/component_duplicate) +// - type ids stable across two runs with the same registration +// order (two worlds stand in for the two runs: the id assignment +// is pure integer bookkeeping — ARCH-010, component.h preamble) +// - user-defined structs are legal components (S-8 data-carrier +// case) and register through the same path as built-ins +// - size/alignment are recorded for the M1-ECS-03 SoA layout +// - the engine-level component budget (kMaxComponentTypes) is +// honored with BudgetExhausted +// - registry state moves with the World; clear() leaves it untouched +// +// Runs as CTest `component_registry` (the step's Verify command: +// `ctest -R component_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/component.h" +#include "laige/sim/entity.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "component_registry_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "component_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, + "component_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 COMPONENT_REGISTRY_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define COMPONENT_REGISTRY_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if COMPONENT_REGISTRY_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "component_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. +// --------------------------------------------------------------------------- + +// Engine-style built-in test components (the macro path). +struct SimTestPos { + std::int32_t x; + std::int32_t y; +}; +LAIGE_COMPONENT(SimTestPos); + +struct SimTestVel { + std::int64_t vx; // 8-byte alignment: the SoA column alignment case +}; +LAIGE_COMPONENT(SimTestVel); + +// A user-defined component (S-8 data-carrier case, PRD Appendix B +// shape): a plain struct marked with the same macro — the same +// registration path, no built-in privilege. +struct SimPlayerHealth { + std::int32_t current; + std::int32_t max; +}; +LAIGE_COMPONENT(SimPlayerHealth); + +// A family of distinct types for the kMaxComponentTypes budget test. +// The partial specialization marks the whole family at once (the +// macro is per-type sugar over the same trait; see component.h). +template +struct SimBulkComp { + std::int32_t v; +}; + +namespace laige::detail { +template +struct ComponentTraits> { + static constexpr bool isComponent = true; +}; +} // namespace laige::detail + +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(); +} + +// Register SimBulkComp .. SimBulkComp; false on the first +// failure. Compile-time recursion over the non-type parameter (test +// setup code, not a hot path). +template +bool registerRange(laige::World& world) { + if constexpr (Lo < Hi) { + auto r = world.registerComponent>(); + if (!r.ok()) return false; + return registerRange(world); + } + return true; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The id type (FR-1.2) +// --------------------------------------------------------------------------- + +TEST(ComponentTypeId, ZeroIsReservedAndNeverAssigned) { + EXPECT_EQ(laige::kInvalidComponentTypeId.value, 0u); + EXPECT_NE(laige::ComponentTypeId{1}, laige::kInvalidComponentTypeId); +} + +TEST(ComponentTypeId, ComparisonOperators) { + const laige::ComponentTypeId a{1}, b{1}, c{2}; + EXPECT_EQ(a, b); + EXPECT_NE(a, c); + EXPECT_LT(a, c); + EXPECT_FALSE(c < a); + EXPECT_FALSE(a < a); +} + +// --------------------------------------------------------------------------- +// World::registerComponent: ids, size/alignment, errors +// --------------------------------------------------------------------------- + +TEST(ComponentRegistry, IdsAssignedInRegistrationOrder) { + laige::World world = makeWorld(8); + auto a = world.registerComponent(); + ASSERT_TRUE(a.ok()); + auto b = world.registerComponent(); + ASSERT_TRUE(b.ok()); + auto c = world.registerComponent(); + ASSERT_TRUE(c.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(c.value().value, 3u); + EXPECT_EQ(world.componentCount(), 3u); + // 0 is never assigned: + EXPECT_NE(a.value(), laige::kInvalidComponentTypeId); +} + +TEST(ComponentRegistry, SizeAndAlignmentRecorded) { + laige::World world = makeWorld(0); + auto a = world.registerComponent(); + ASSERT_TRUE(a.ok()); + auto infoA = world.componentInfo(a.value()); + ASSERT_TRUE(infoA.ok()); + EXPECT_EQ(infoA.value().size, sizeof(SimTestPos)); + EXPECT_EQ(infoA.value().alignment, alignof(SimTestPos)); + + // The 8-byte-aligned case: the SoA column layout (M1-ECS-03) + // depends on the recorded alignment, not a hand-computed one. + auto b = world.registerComponent(); + ASSERT_TRUE(b.ok()); + auto infoB = world.componentInfo(b.value()); + ASSERT_TRUE(infoB.ok()); + EXPECT_EQ(infoB.value().size, sizeof(SimTestVel)); + EXPECT_EQ(infoB.value().alignment, 8u); +} + +TEST(ComponentRegistry, DuplicateRegistrationIsAnError) { + // The roadmap's named property: duplicate registration is an + // error, not a no-op (FR-12.3: never silent). + laige::World world = makeWorld(1); + auto first = world.registerComponent(); + ASSERT_TRUE(first.ok()); + auto second = world.registerComponent(); + EXPECT_FALSE(second.ok()); + EXPECT_EQ(second.error(), laige::ErrorCode::InvalidArgument); + // The registry is unchanged: same count, the first id still + // queryable with the original size/alignment. + EXPECT_EQ(world.componentCount(), 1u); + auto info = world.componentInfo(first.value()); + ASSERT_TRUE(info.ok()); + EXPECT_EQ(info.value().size, sizeof(SimTestPos)); +} + +// --------------------------------------------------------------------------- +// Determinism of the id assignment (ARCH-010; component.h contract) +// --------------------------------------------------------------------------- + +TEST(ComponentRegistry, TypeIdsStableAcrossWorldsWithSameOrder) { + // The roadmap's named property: type ids stable across two runs + // with the same registration order. Two worlds stand in for the two + // runs: the id assignment is pure integer bookkeeping (component.h + // preamble, ARCH-010), so the property also holds across process + // runs and builds. + laige::World w1 = makeWorld(0); + laige::World w2 = makeWorld(0); + auto a1 = w1.registerComponent(); + auto b1 = w1.registerComponent(); + auto c1 = w1.registerComponent(); + auto a2 = w2.registerComponent(); + auto b2 = w2.registerComponent(); + auto c2 = w2.registerComponent(); + ASSERT_TRUE(a1.ok() && b1.ok() && c1.ok()); + ASSERT_TRUE(a2.ok() && b2.ok() && c2.ok()); + EXPECT_EQ(a1.value(), a2.value()); + EXPECT_EQ(b1.value(), b2.value()); + EXPECT_EQ(c1.value(), c2.value()); +} + +TEST(ComponentRegistry, RegistrationOrderDeterminesIds) { + // The documented contract (component.h): ids follow registration + // order, so a different order produces different ids. Replay and + // replication rely on the order being fixed once at setup + // (M1-HEAD-01 wires the loop around it). + laige::World w1 = makeWorld(0); + laige::World w2 = makeWorld(0); + auto a1 = w1.registerComponent(); // id 1 in w1 + auto b1 = w1.registerComponent(); // id 2 in w1 + auto a2 = w2.registerComponent(); // id 1 in w2 + auto b2 = w2.registerComponent(); // 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 type in different worlds/orders: different ids — + // ComponentTypeIds are per-world and never compared across worlds. + EXPECT_NE(a1.value(), b2.value()); + EXPECT_NE(b1.value(), a2.value()); +} + +// --------------------------------------------------------------------------- +// The engine-level component budget (kMaxComponentTypes) +// --------------------------------------------------------------------------- + +TEST(ComponentRegistry, EngineBudgetHonored) { + // kMaxComponentTypes is the engine-level component budget + // (component.h): reaching it turns further NEW-type registration + // into BudgetExhausted (the world never grows silently, S-2/G-R1). + laige::World world = makeWorld(0); + EXPECT_TRUE((registerRange<0, laige::kMaxComponentTypes>(world))); + EXPECT_EQ(world.componentCount(), laige::kMaxComponentTypes); + // A distinct 257th type: this is the budget path, not the + // duplicate path. + auto over = world.registerComponent>(); + EXPECT_FALSE(over.ok()); + EXPECT_EQ(over.error(), laige::ErrorCode::BudgetExhausted); +} + +// --------------------------------------------------------------------------- +// componentInfo validation +// --------------------------------------------------------------------------- + +TEST(ComponentRegistry, InfoQueriesValidateTheId) { + laige::World world = makeWorld(0); + // The reserved id: + auto invalid = world.componentInfo(laige::kInvalidComponentTypeId); + EXPECT_FALSE(invalid.ok()); + EXPECT_EQ(invalid.error(), laige::ErrorCode::InvalidArgument); + // Nothing registered yet: + auto empty = world.componentInfo(laige::ComponentTypeId{1}); + EXPECT_FALSE(empty.ok()); + EXPECT_EQ(empty.error(), laige::ErrorCode::InvalidArgument); + // Register one; id 2 is still unregistered: + auto a = world.registerComponent(); + ASSERT_TRUE(a.ok()); + auto missing = world.componentInfo(laige::ComponentTypeId{2}); + EXPECT_FALSE(missing.ok()); + EXPECT_EQ(missing.error(), laige::ErrorCode::InvalidArgument); + // The registered id resolves: + auto ok = world.componentInfo(laige::ComponentTypeId{1}); + EXPECT_TRUE(ok.ok()); + EXPECT_EQ(ok.value().size, sizeof(SimTestPos)); +} + +// --------------------------------------------------------------------------- +// World lifetime: move and clear +// --------------------------------------------------------------------------- + +TEST(ComponentRegistry, MovedWorldCarriesRegistry) { + laige::World w1 = makeWorld(2); + auto a = w1.registerComponent(); + ASSERT_TRUE(a.ok()); + auto b = w1.registerComponent(); + ASSERT_TRUE(b.ok()); + laige::World w2 = std::move(w1); + EXPECT_EQ(w2.componentCount(), 2u); + auto info = w2.componentInfo(a.value()); + ASSERT_TRUE(info.ok()); + EXPECT_EQ(info.value().size, sizeof(SimTestPos)); + auto info2 = w2.componentInfo(b.value()); + ASSERT_TRUE(info2.ok()); + EXPECT_EQ(info2.value().size, sizeof(SimTestVel)); + // The moved-from world has no registry (valid empty world): + EXPECT_EQ(w1.componentCount(), 0u); + auto r = w1.registerComponent(); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(ComponentRegistry, ClearLeavesRegistryUntouched) { + // clear() destroys live entities (M1-ECS-01); the type registry is + // setup state and survives (M1-ECS-03 will destroy per-entity + // component data, not the type records). + laige::World world = makeWorld(2); + auto e = world.create(); + ASSERT_TRUE(e.ok()); + auto a = world.registerComponent(); + ASSERT_TRUE(a.ok()); + ASSERT_TRUE(world.destroy(e.value()).ok()); + world.clear(); + EXPECT_EQ(world.entityCount(), 0u); + EXPECT_EQ(world.componentCount(), 1u); + auto info = world.componentInfo(a.value()); + ASSERT_TRUE(info.ok()); + EXPECT_EQ(info.value().size, sizeof(SimTestPos)); +} + +// --------------------------------------------------------------------------- +// Duplicate-registration logging: warn-once (LOG-004) through the facade +// --------------------------------------------------------------------------- + +namespace { + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; this test owns its window and +// restores the default console sink at the end). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +} // namespace + +TEST(ComponentRegistry, 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.registerComponent().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.registerComponent(); + 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, "ecs"); + EXPECT_EQ(sinkPtr->entries[0].event, "component_duplicate"); + // The event identifies the already-registered id (LOG-002): + bool foundId = false; + for (const auto& [key, value] : sinkPtr->entries[0].fields) { + if (key == "component_id" && value == "1") foundId = true; + } + EXPECT_TRUE(foundId); + + // Controlled shutdown drains the pending rate-limit summary + // (CONC-006/LOG-007/LOG-004). + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(sinkPtr->entries.size(), 2u); + EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + bool foundSuppressed = false; + for (const auto& [key, value] : sinkPtr->entries[1].fields) { + if (key == "suppressed" && value == "2") foundSuppressed = true; + } + EXPECT_TRUE(foundSuppressed); + + // Restore the default console sink for the remaining tests. + laige::log::LoggerOptions defaults; + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(defaults)).ok()); +} From f84910110cf62e9fdd68c80d15645731bbb2b0ef Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 20:26:52 +0200 Subject: [PATCH 2/2] [M1-ECS-02] Fill in change-log commit hash (db13770) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 0e68c62..76a5c76 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -196,7 +196,7 @@ One line per completed (or split/renumbered) step. | 2026-09-13 | M0-DOC-01 | `972090d` | Docs only: full AGENTS §13 `docs/` tree — `docs/README.md` rewritten as the single index (every section linked + honest "not yet written" list, DOC-001); new section indexes `concepts/` (planned docs + interim homes incl. the ARCH-008 coordinates topic), `guides/`, `debugging/` (usable today; AGENTS §15 debug-mode status), `compatibility/` (P0 platform/compiler table per PRD §6 + CI, current formats `budgets.json`/`laige-api.json`/`deps.lock`, no migration guides yet); `docs/benchmarks/` with `methodology.md` (normative AGENTS §12 report fields, `budgets.json` field→report mapping, immutable baseline-file convention, PRD §8.1 regression policy: >10% band, hard-zero budgets, loud `NO_SAMPLES`) and empty `baselines/` (first baseline `m0-synthetic.md` lands with M0-EXIT-01); `decisions/` index updated (stale M0-DOC-01 note replaced; ADRs 0001–0004 indexed); stale references fixed in the same change (root README docs pointer + ADR 0004; old index's non-existent "M0-DOC-02" reference dropped); dead-link check over all 49 repo `*.md` files (104 internal links, 0 dead — manual per the step's Verify clause; docs-only scope, no CI job added); `ctest` 32/32 green on the existing `build` tree; CI run 34749554015: 7/7 executed jobs green (macOS/Windows skipped, label-gated) | | 2026-09-13 | M0-EXIT-01 | `636257c` | M0 exit gate: all 21 prior M0 steps re-verified against their Scope/Verify clauses on commit `829026f` — fresh canonical g++ tree zero-warning, `ctest` 32/32; `build-shared`, `build-asan` (ASan+UBSan), `build-tsan`, and a fresh Clang 22.1.8 tree all 32/32; `laige-fuzz json_parse --runs=1000` clean; `laige-detcheck --scenario=synthetic` OK; `laige-api-scanner --check` up to date (376 symbols); `tools/laige-include-lint` OK (1/10 deps); vendored-tree lock re-proven live (tampered `deps/googletest` file fails the configure with expected-vs-actual hashes, restored tree passes); gate evidence: CI merge-lane run 34749756361 on `829026f` — all 10 jobs `success`, each under 1.2 min, `ctest` 32/32 in every P0 OS job (linux-gcc/clang/asan/tsan, windows-msvc, macos-arm64/intel); first baseline `docs/benchmarks/baselines/m0-synthetic.md` written (synthetic harness workload, full AGENTS §12 metadata, verbatim runs, commit `829026f`; no `budgets.json` `measured` updated — the stand-in measures no real budget); Progress Board 22/22, milestone marked complete | | 2026-09-13 | M1-ECS-01 | `f173682` | `laige-sim` becomes the first module beyond `laige-core` (M1 milestone rules): 32-bit `laige::Entity` handle (16-bit id + 16-bit generation, `Entity::kMaxEntities` = 65536 slots, generation 0 reserved, the documented 2^16 wrap collision pinned by test) + `laige::World` entity storage (create/destroy/check/isValid/clear/stats over a pool-backed slot table — generation table + alive flags + pre-allocated LIFO free stack; setup-only allocation, O(1) no-allocation operations; G-R3 capacity config: `BudgetExhausted` on overflow, `InvalidArgument` for a >65536 budget at construction, API-008; stale-handle contract: debug assert / release `Status(InvalidArgument)` + warn-once through the logging facade, events `ecs/stale_entity_access` + `ecs/stale_entity_destroy`; move-only, single owner thread, ARCH-010 bit-identical handle sequences); public API in `src/laige-sim/include/laige/sim/entity.h` (+ `entity.cpp`), new CMake target (static/shared, engine + SimMath policy, single PUBLIC link to laige-core); `entity` CTest entry (17 cases: LIFO slot assignment, generation bump on reuse, recycling order, clear/move semantics, capacity limit incl. 0 and the 65536 boundary, stale-detection matrix, `check()` Status path in every build, release `destroy` Status path, forked-SIGABRT stale `destroy` in debug, pinned 2^16 generation wrap, stats counts/peak/churn/bytes, warn-once rate-limit summary via a memory sink); API contract in `docs/api/entity.md`; `laige-api.json` regenerated (api-real-tree green); local Verify: fresh canonical g++ tree zero-warning, full `ctest` suite green incl. the new module (34 tests), `ctest -R entity` and the full suite green on a fresh ASan tree (the step's Verify clause); `tools/laige-include-lint` OK; `api-real-tree` green after the manifest regeneration | -| 2026-09-13 | M1-ECS-02 | — | Component type registry (M1-ECS-02 scope, nothing else): `ComponentTypeId` (32-bit, dense ids assigned in registration order from 1, 0 reserved as `kInvalidComponentTypeId`, per-world, `operator<` = registration order) + `LAIGE_COMPONENT(Type)` macro (compile-time trait mark, data-only — replication/inspector traits land in M4) + `World::registerComponent()` recording `sizeof(T)`/`alignof(T)` for the M1-ECS-03 SoA layout; user-defined structs register through the same path (S-8 data-carrier case); type identity without RTTI/unordered (per-type `inline static` marker address, NFR-8.10/PERF-006); engine-level budget `kMaxComponentTypes` = 256 (`BudgetExhausted` beyond it); duplicate registration → `InvalidArgument` + rate-limited warn `ecs/component_duplicate`; moved-from world → no registry (`InvalidArgument`); `static_assert` guards: `LAIGE_COMPONENT` mark + trivially-copyable (actionable compile errors); setup-phase O(n) no-allocation operation; registry moves with `World`, `clear()` leaves it untouched; new public header `src/laige-sim/include/laige/sim/component.h`; `component_registry` CTest entry (12 cases: id order, size/alignment incl. 8-byte alignment, duplicate error + unchanged registry, id stability across two worlds with the same registration order, order-determines-ids, 256-type budget boundary, `componentInfo` validation, move, clear, warn-once rate-limit summary via a memory sink); API contract in `docs/api/component_registry.md` (+ cross-refs in docs/README, entity.md, sim README); `laige-api.json` regenerated (421 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (35 tests) | +| 2026-09-13 | M1-ECS-02 | `db13770` | Component type registry (M1-ECS-02 scope, nothing else): `ComponentTypeId` (32-bit, dense ids assigned in registration order from 1, 0 reserved as `kInvalidComponentTypeId`, per-world, `operator<` = registration order) + `LAIGE_COMPONENT(Type)` macro (compile-time trait mark, data-only — replication/inspector traits land in M4) + `World::registerComponent()` recording `sizeof(T)`/`alignof(T)` for the M1-ECS-03 SoA layout; user-defined structs register through the same path (S-8 data-carrier case); type identity without RTTI/unordered (per-type `inline static` marker address, NFR-8.10/PERF-006); engine-level budget `kMaxComponentTypes` = 256 (`BudgetExhausted` beyond it); duplicate registration → `InvalidArgument` + rate-limited warn `ecs/component_duplicate`; moved-from world → no registry (`InvalidArgument`); `static_assert` guards: `LAIGE_COMPONENT` mark + trivially-copyable (actionable compile errors); setup-phase O(n) no-allocation operation; registry moves with `World`, `clear()` leaves it untouched; new public header `src/laige-sim/include/laige/sim/component.h`; `component_registry` CTest entry (12 cases: id order, size/alignment incl. 8-byte alignment, duplicate error + unchanged registry, id stability across two worlds with the same registration order, order-determines-ids, 256-type budget boundary, `componentInfo` validation, move, clear, warn-once rate-limit summary via a memory sink); API contract in `docs/api/component_registry.md` (+ cross-refs in docs/README, entity.md, sim README); `laige-api.json` regenerated (421 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (35 tests) | ---