Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<T, Access>`),
`World::registerSystem`/`system`/`systemCount`, and
`SystemContext`'s delegated `each` (M1-SYS-01; `laige-sim`).
- [Result / Status / error codes](api/errors.md) — `laige::Result<T,E>`,
`laige::Status`, the stable `ErrorCode` registry (M0-CORE-01).
- [Structured logging](api/logging.md) — the `laige::log` facade, sinks,
Expand Down
166 changes: 166 additions & 0 deletions docs/api/system_registry.md
Original file line number Diff line number Diff line change
@@ -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<T, Access>`, `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<SimVel>(
[](laige::Entity e, SimVel& v) { /* integrate */ }, laige::Write{});
}

// World setup (before the loop):
auto r = world.registerSystem(Movement_Def,
laige::Io<SimVel, laige::Access::Write>{},
laige::Io<SimPos, laige::Access::Read>{});
// r is a Result<SystemId, ErrorCode>; 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<T1, T2, ...>(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<T>`:

- **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<T, Access>{}` 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>`: `T` not a Laige component | compile error | — |
| `Io<T>`: `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<T>`); 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).
Loading
Loading