Merge pull request #52 from Heretek-AI/feat/illusion-layer-abi-4.2

[Illusion] ABI 4.2 end-to-end (M0-M11)
This commit is contained in:
BillyOutlast authored and GitHub committed 2026-10-30 16:39:55 -04:00
commit eabfb74e6a
21 files changed
+1781 -238

No files matched your search

+2 -2
View File
@@ -1,9 +1,9 @@
# AGENTS.md — game-engine
## Invariants
- Native owns all frame state (`native/`). Managed (`managed/`) holds no gameplay state; zero-copy only via `EcsChunkView*` + `[UnmanagedCallersOnly]` + `SuppressGCTransition`.
- Native owns all frame state (`native/`). Managed (`managed/`) holds no gameplay state; zero-copy only via `EcsChunkView*` (fixed archetype, `SuppressGCTransition`) or `EngineChunkColumns*` (ABI 4.2 generic systems, plain Cdecl because `systems_run` calls back into managed) + `[UnmanagedCallersOnly]`. Spec: `docs/specs/ILLUSION_LAYER_ABI_4_2.md`.
- Every change to `native/bridge/engine_abi.h` must regenerate `managed/Engine.Bindings/NativeApi.g.cs` via `tools/abi_codegen` and run layout assertions.
- No `malloc`/`new` on frame path: `LinearFrameAllocator` (reset/frame) + `PageArenaAllocator` (virtual memory). Flecs columns `alignas(64)`.
- No `malloc`/`new` on frame path: `LinearFrameAllocator` (reset/frame) + `PageArenaAllocator` (virtual memory). Flecs columns `alignas(64)` for built-ins; user-component columns are NOT 64-byte aligned (only to the component's own alignment), so never assume it.
- `IComponent` structs are blittable only (no `string`/`class`/`interface`/arrays/LINQ/closures in hot path). Analyzers ENG0001–ENG0003 enforce.
- Hot-reload is stateless: component data stays in Flecs; `GameScripts.dll` loads from memory stream via collectible ALC; schema evolution migrates tables.
+1
View File
@@ -12,6 +12,7 @@
<Project Path="samples/Benchmark1M/Benchmark1M.csproj" />
<Project Path="samples/HotReloadBench/HotReloadBench.csproj" />
<Project Path="samples/ExportSample/ExportSample.csproj" />
<Project Path="samples/IllusionSample/IllusionSample.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/abi_codegen/abi_codegen.csproj" />
+134
View File
@@ -0,0 +1,134 @@
# Illusion Layer implementation notes
## Baseline
- Date: 2026-10-30 (ILLU-M0).
- `origin/main` commit: `3112291aa06f476b068b37502cfcee7d198423c0` (Merge PR #51).
- Main-drift note (IMPLEMENTATION_PROMPT §3): spike was done on `70846a5`; current `origin/main` is `3112291`, recorded here as required. No `Find`-text edits were needed in M0 (no code changes).
- Branch: `feat/illusion-layer-abi-4.2`, created from `origin/main` in this task.
- Tool versions: `dotnet 10.0.403`, `cmake 3.31.6`, `ninja 1.12.1`, `g++ (Debian 14.2.0-19) 14.2.0`.
- CTest: `100% tests passed, 0 tests failed out of 40` (see `build/logs/m0-ctest.log`). Failing tests: none.
- Managed tests: `grep -c 'all checks passed' build/logs/m0-tests.log` = 9 (>= 9), `grep -c FAIL` = 0 (see `build/logs/m0-tests.log`).
- `dotnet build Engine.slnx -c Release` exit 0 (see `build/logs/m0-dotnet.log`); `cmake --preset ci-linux` and `cmake --build` exit 0.
### Discoveries
- 2026-10-30: all 40 CTests pass on main, including the 8 export tests listed in §4.7 (`engine_export_publish_aot`, `engine_export_stage*`, `engine_export_coldboot`). The doc expects them to fail on machines whose .NET SDK lacks the NativeAOT ILCompiler package; this machine's SDK (10.0.403) evidently has it, so the M0 baseline is fully green. Later milestones compare against this all-pass baseline.
- 2026-10-30: a stray `.hermes/` scratch dir (Hermes runner cache) was present at repo root and showed in `git status`; removed it (`rm -rf .hermes`) since it is runner scratch, not a repo artifact. `build/` is git-ignored, so `build/logs/m0-*.log` are not committed.
## M1 spikes
- Date: 2026-10-30 (ILLU-M1). Spike sources created verbatim from `docs/IMPLEMENTATION_PROMPT.md:309-418` under `build/` (git-ignored, not committed): `build/spikes/flecs_spike.cpp`, `build/spikes/cs1503/cs1503.csproj`, `build/spikes/cs1503/Program.cs`.
- Flecs spike build/run (`c++ -std=c++20 -O2 -I../../deps/flecs/include -I../../deps/flecs/distr flecs_spike.cpp -L../ci-linux/deps/flecs -lflecs ...`):
```text
mover=456 mover2=456 health=457
lookup(sep="")=456 lookup(default)=0 has_parent_Spike=0
type_info size=12 align=4
query=0x6179464df9e0 field_count=2
table count=3 mover_col=0x617946550b90 mod64=16 health_col=0x617946550ad0 entities=0x617946550b60
tables=1 rows=3
e0 speed=0 x=100
e1 speed=1 x=0
e2 speed=2 x=100
e3 speed=3 x=0
e4 speed=4 x=100
fatal: entity.c: 1522: abort(): Spike\.Mover (INVALID_COMPONENT_SIZE)
exit=134
```
- CS1503 repro build (`dotnet build build/spikes/cs1503`):
```text
Chunk_Spike_Mover.g.cs(37,65): error CS1503: Argument 3: cannot convert from 'delegate* unmanaged[Cdecl]<Engine.Bindings.NativeApi.EcsChunkView*, void>' to 'delegate* unmanaged[Cdecl]<Engine.Bindings.NativeApi.EcsChunkView*, float, void>' [cs1503.csproj]
0 Warning(s)
1 Error(s)
```
### Discoveries
- 2026-10-30: spike confirms §4.1 facts on this machine — same id on same-size re-register (`mover == mover2 == 456`), abort (exit 134, SIGABRT) on different-size re-register, `lookup(default)=0` so callers must use `ecs_lookup_path_w_sep` with empty seps, `size=12 align=4`, only 3 of 5 rows matched (Health-gated). Column `mod64=16` here vs `mod64=32` in the doc's capture — addresses vary per run, but the point stands: user columns are NOT 64-byte aligned.
- 2026-10-30: CS1503 repro error carries a `to '...EcsChunkView*, float, void>'` target suffix (doc acceptance cites only the `from '...EcsChunkView*, void>'` prefix), i.e. the generated chunk thunk drops the `float dt` parameter the `Tick` signature requires. M2's ABI work must reconcile the native `EcsChunkView*`-only callback shape with the managed `Tick(..., float dt)` signature.
## M2 ABI 4.2 header
- 2026-10-30: applied the 3 doc-ordered edits to `native/bridge/include/engine/bridge/engine_abi.h` (IMPLEMENTATION_PROMPT.md:420-610): edit 1 bumped `ENGINE_ABI_VERSION_MINOR` 1 -> 2 with the 4.2 comment tail; edit 2 added the generic tail (`ENGINE_ABI_MAX_TERMS 8`, ACCESS IN/INOUT, ERR codes -1..-6, `EngineChunkColumns`, `EngineSystemCallback`, 6 fn typedefs); edit 3 appended the 6 table fields ending with `systems_run` as last field before `} EngineApiTable;`. Each Find text matched exactly once.
- 2026-10-30: `grep -c 'ENGINE_ABI_VERSION_MINOR 2'` -> `1`; `grep -nE 'EngineSystemsRunFn systems_run;'` -> one line, last field before `} EngineApiTable;`; `grep -c 'typedef struct EngineChunkColumns'` -> `1`.
- 2026-10-30: `cmake --preset ci-linux && cmake --build build/ci-linux -j4` fails as the source doc predicts (M2 acceptance block: "expected to fail until M3"): `engine_abi.cpp:1013: error: missing field 'component_register' initializer [-Werror,-Wmissing-field-initializers]`. Header is 4.2, `kApiTable` has no new initializers yet — M3 fills them in. No drift: all 3 Find texts matched exactly once.
## M3A GenericSystems registration
- 2026-10-30: created `native/bridge/src/generic_systems.hpp` verbatim from `docs/IMPLEMENTATION_PROMPT.md` `<!-- file: native/bridge/src/generic_systems.hpp -->` block (programmatic fence diff: match=True, 2022 bytes).
- 2026-10-30: created `native/bridge/src/generic_systems.cpp` part 1 (through `get()`): `register_component` with `ecs_get_type_info` pre-check returning 0 on size/alignment mismatch (never calls `ecs_component_init` on mismatch — §4.1 abort rule), plus `create_entity`/`set`/`get` with NULL-arg/in-run guards. `register_system`/`run` intentionally deferred to ILLU-M3B (child card `t_e07a5570` owns them plus all `engine_abi.cpp` wiring); fence diff vs doc block is exactly the split-note header + missing register/run tail.
- 2026-10-30: TDD temporary spike `build/spikes/m3a_reregister_spike.cpp` (git-ignored, DELETED after run): `a=470 b=470 same=1`, `mismatch=0 (expect 0)`, set/get round-trip `speed=1.5 x=2.5`, `SPIKE PASS`, exit 0. Spike compiled `generic_systems.cpp` with `-Wall -Wextra -Werror` clean (flecs.c compiled as C; `engine_core_export.hpp` stubbed in git-ignored spike dir only — header includes it under `#ifdef __cplusplus` only so no repo change needed).
- 2026-10-30: split note — full `cmake --build build/ci-linux` still fails at `kApiTable` missing 6 initializers (M2-predicted state); M3B adds the wrappers + table entries that fix it. No `engine_abi.cpp` change in this commit by design (avoids colliding with M3B's 6 doc-ordered edits).
## M3B systems run + shutdown (ILLU-M3B)
- `native/bridge/src/generic_systems.cpp` completed verbatim from the M3 doc fence (register_system + serial run() appended; file now byte-matches the doc fence minus the M3A-split NOTE header comment). `grep -c ENGINE_ABI_ERR_IN_RUN` = 3 (set, register_system, run in-run refusal paths).
- `native/bridge/src/engine_abi.cpp`: applied the 6 doc-ordered edits (each Find matched exactly 1x): generic_systems.hpp include, EngineChunkColumns static asserts (sizeof 120, offsets 64/96/104/108/112), GenericSystems member on EngineWorldImpl, generic.shutdown() before ecs fini in ApiWorldDestroy, 6 Api* wrappers (component_register/entity_create/entity_set+plan-invalidate/entity_get/system_register/systems_run with NULL-dispatch dt=0), 6 kApiTable tail entries.
- `native/tests/test_abi_layout.cpp`: 312→360B table-size edit applied.
- Build: `cmake --build build/ci-linux -j4` exit 0. `ctest -R engine_abi_layout -V` passes, shows `abi: EngineApiTable 360B (ABI 4.2 generic tail)`.
- `engine_compositor_live` fails post-M3B with `ABI version mismatch: native 4.2.0` — expected: managed `NativeApi.g.cs` still pins AbiVersionMinor=1; regeneration is owned by ILLU-M5 (abi_codegen). Not a native regression: native-side `engine_abi_layout` is green.
- Card said "7 engine_abi.cpp edits" but the doc contains exactly 6 engine_abi.cpp edit blocks + 1 test_abi_layout.cpp edit (step 3); all 7 applied.
## M4 native test of the generic tail (ILLU-M4)
- `native/tests/test_generic_systems.cpp` created verbatim from the M4 doc fence (programmatic fence diff: BYTE-MATCH, 149 lines).
- `native/tests/CMakeLists.txt`: applied M4 edit #1 verbatim (engine_generic_systems add_executable/link/flags/add_test, TIMEOUT 120). Edit #2 deferred to M9 per doc.
- 312→360B table-size edit was already applied in M3B (test_abi_layout.cpp:218) — verified present, not re-applied.
- Build: `cmake --build build/ci-linux --target engine_generic_systems engine_abi_layout` exit 0. `ctest -R engine_abi_layout|engine_generic_systems`: 100% passed (2/2). Verbose tail shows `generic_systems: all checks passed` plus the doc acceptance lines `[PASS] systems_run with NULL dispatch` and `[PASS] row 4: speed=4.0 x=25.0 (want speed=4 x=25.0)`.
- No fix-forward needed: first-run green, `generic_systems.cpp` untouched.
## M5A codegen parser rejects unknown 4.2 types (ILLU-M5A)
- Applied doc edits #6 (`ValidateCType`) + #7 (`CsType`) ONLY to `tools/abi_codegen/Program.cs`: added `const void*`, `const uint64_t*`, `const uint32_t*`, `const EngineChunkColumns*`, `EngineSystemCallback` (plain Cdecl callback shape, no SuppressGCTransition).
- Pre-change baseline: `dotnet run --project tools/abi_codegen -c Release -- .` exited 1 with `unsupported ABI type 'const EngineChunkColumns*'` at engine_abi.h:492 (fail-closed confirmed).
- Post-change: still exits 1, now at `Fn typedef(s) not used by EngineApiTable: EngineSystemCallback` — the required-entry + unused-typedef checks (edits #5/#12) and emission belong to ILLU-M5B. No `.g.cs` touched.
## M5B 4.2 bindings emitted (ILLU-M5B)
- Applied doc edits #1-5 + #8-13 to `tools/abi_codegen/Program.cs` (M5A already owned #6+#7): GenericDefineRe + ParseGenericDefines (9 required consts, MAX_TERMS==8 gate), EngineChunkColumns required struct, 6 required fn entries, Emit signature carries `generic` dict, MaxTerms..ErrInternal consts emission, EmitChunkColumns with fail-closed 120-byte field check, EngineSystemCallback unused-typedef removal, loader drift guard extended with EngineChunkColumns 120B.
- `dotnet run --project tools/abi_codegen -c Release -- .` prints `abi_codegen: 44 entries`, exit 0. Second run: `git diff` on NativeApi.g.cs unchanged (idempotent).
- TDD red: pre-test-edit `dotnet run --project managed/Engine.Tests` showed `AbiLayoutTests: 2 FAILED` (minor==1, ApiTable 312B) against the new 360B table. Applied 4 AbiLayoutTests edits (minor 1->2, ApiTable 312->360B + component_register @312 + systems_run @352 + ChunkColumns 120B, columns offsets 96/104/108/112, system_register Cdecl assertion). Post-edit: `AbiLayoutTests: all checks passed`, `grep -c FAIL` = 0.
- `grep -c SuppressGCTransition NativeApi.g.cs` = 3 (unchanged); systems_run entry is plain Cdecl; `Size = 120` appears once.
## M6 Ecs helpers (ILLU-M6)
- `managed/Engine.Runtime/Ecs.cs` created verbatim from `docs/IMPLEMENTATION_PROMPT.md:1919-2067` doc fence (programmatic fence diff: VERBATIM, 5043 bytes).
- `dotnet build managed/Engine.Runtime -c Release`: `Build succeeded. 0 Warning(s) 0 Error(s)`.
- TDD spike `build/spikes/ecs6/` (git-ignored, DELETED after run): called `Ecs.ComponentId<Mover>` twice on a live `EngineWorldCreate` world — `GREEN` (same non-zero id). Note: `ApiLoader.Load()` resolves `engine_core` by SONAME only, so the spike preloaded `/home/hermes/game-engine/build/ci-linux/native/libengine_core.so` via `NativeLibrary.Load(absolute)` and bound `engine_get_api_table` by export instead of using `ApiLoader.Load()`.
- `dotnet build Engine.slnx -c Release --nologo`: `Build succeeded. 0 Warning(s) 0 Error(s)`.
## M7A generator Register emission (ILLU-M7A)
- Replaced `managed/Engine.Analyzers/ComponentSystemGenerator.cs` per `docs/IMPLEMENTATION_PROMPT.md:2068-2821` up to and including `Register` emission: doc-verbatim header/pipeline/discovery/resolution/`EmitOne`/`EmitSizeRegistration`/`SystemName`, plus doc-verbatim `ComponentParams`/`RowArgs`/`IsRunnable`/`IsFloatScalar` helpers. `EmitChunkRunner` is an inert M7A stub with the stable ABI 4.2 signature (`RunChunk(EngineChunkColumns*, float dt)`); the span-unpacking/row-loop/try-catch body is owned by ILLU-M7B (child card `t_d8676106`).
- `Ecs.cs` note: ILLU-M6's commit (`1078062`) exists only on its dispatch branch, not on `feat/illusion-layer-abi-4.2` in this worktree, so the M7A spike build borrowed that file uncommitted for compilation only. It is NOT staged in the M7A commit — M6's merge will supply it. (Flagging for the orchestrator: M6 shows complete but its file is unreachable from the feat branch.)
- Acceptance: `/tmp/m7a_spike` (cs1503 sources retargeted at this worktree's `managed/` projects): `Build succeeded. 0 Warning(s) 0 Error(s)` — CS1503 gone. The main-repo `build/spikes/cs1503` still fails because it references main-repo `managed/` (old generator + missing 4.2 bindings there); that is a spike-path artifact, not a generator defect.
- Emitted `Chunk_Spike_Mover.g.cs`: `ComponentSize`, `Faulted`, and `Register` blocks byte-match the §4.6 reference modulo whitespace (incl. `table->EngineSystemRegister(world, name, ids, access, 2u, fn, userData)` and `Spike.Mover\0` name); `RunChunk` signature matches, body inert by design (`_ = view; _ = dt;`) — full body is M7B's problem.
## M7B RunChunk emission (ILLU-M7B)
- Replaced the M7A inert `EmitChunkRunner` stub with the doc body (`IMPLEMENTATION_PROMPT.md:2679-2732`): unrunnable guard keeps `_ = view; _ = dt;`, runnable path emits `count`/`TermCount`/`s_faulted` guard, per-term `Span`/`ReadOnlySpan` unpacking (`col0` self type via `selfFqn`), `try`/`for` row loop `col0[i].Tick(...)` with `RowArgs`, `catch (Exception)` latching `Volatile.Write(ref s_faulted, 1)`.
- `managed/Engine.Analyzers/ComponentSystemGenerator.cs` is byte-identical to the doc fence (`2076..2809`); `dotnet build Engine.slnx -c Release`: `Build succeeded. 0 Warning(s) 0 Error(s)` (CS0649 gone — latch now written).
- `dotnet build build/spikes/cs1503` (copied into this worktree so refs resolve): `Build succeeded. 0 Warning(s) 0 Error(s)`. Emitted `Chunk_Spike_Mover.g.cs` is byte-identical to the §4.6 reference (`:154-235`) modulo the Roslyn BOM; contains `table->EngineSystemRegister(world, name, ids, access, 2u, fn, userData)` and `col0[i].Tick(ref col0[i], in col1[i], dt);`.
## M8 driver test (ILLU-M8)
- Created `managed/Engine.Tests/GeneratorDriverTests.cs` verbatim from `docs/IMPLEMENTATION_PROMPT.md:2830-3081` doc fence (10077 bytes); registered in `managed/Engine.Tests/Program.cs` per the `:3086-3108` edit block (`GeneratorDriverTests.Run()` after `GeneratorTests.Run()`).
- Driver passes the same `CSharpParseOptions(LanguageVersion.Preview)` to both the input tree and `CSharpGeneratorDriver.Create` (Facts :133 pitfall — avoids `Inconsistent language versions`).
- `dotnet build Engine.slnx -c Release`: `Build succeeded. 0 Warning(s) 0 Error(s)`. `dotnet run --project managed/Engine.Tests -c Release --no-build`: `GeneratorDriverTests: all checks passed`, no FAIL.
- Sabotage check (`? "in dt" : "dt"` → `? "in dt" : "0f"`, rebuild, rerun): exact doc-expected 4 lines — `[FAIL] Mover row call forwards in Health + dt`, `[FAIL] Mover RunChunk: X += Health*dt, Y += Speed*dt for 4 rows`, `[FAIL] Mover RunChunk ignores a view with the wrong term count`, `GeneratorDriverTests: 3 FAILED`. Reverted + rebuilt green.
- Worktree note: this worktree's branch was stale at M5B (M6/M7A/M7B lived on sibling dispatch branches); fast-forwarded with `git merge --ff-only efd55cb` (merge-base confirmed HEAD was ancestor) per the reuse-`feat/illusion-layer-abi-4.2` convention before writing the test.
## M9 sample + CTest gate (ILLU-M9)
- Created `samples/IllusionSample/` verbatim from `docs/IMPLEMENTATION_PROMPT.md:3139-3443` fences (csproj 1442B, Components.cs 1887B, Program.cs 7836B); applied both doc edits (`Engine.slnx` IllusionSample line, `native/tests/CMakeLists.txt` engine_illusion_sample gate with `PASS_REGULAR_EXPRESSION "ILLUSION-SAMPLE-OK"`).
- Worktree note: this worktree's branch was stale at M5B; merged M6/M7A/M7B/M8 branch (`game-engine/t_4cce9280-illu-m8-generator-driver-test`, HEAD 61e99f6) into this worktree before writing files, per the reuse-`feat/illusion-layer-abi-4.2` convention.
- `dotnet build Engine.slnx -c Release`: `Build succeeded. 0 Warning(s) 0 Error(s)`. Direct run `dotnet samples/IllusionSample/bin/Release/net10.0/IllusionSample.dll <libengine_core.so>`: all 21 checks `[PASS]`, `0 managed bytes over 100 frames x 100k rows (got 0)`, `[info] 100k Mover rows: 0.176 ms/frame`, `ILLUSION-SAMPLE-OK`, exit 0.
- `ctest --preset ci-linux -R engine_illusion_sample --output-on-failure -V`: `ILLUSION-SAMPLE-OK`, `100% tests passed, 0 tests failed out of 2`. Full `ctest --preset ci-linux`: `100% tests passed, 0 tests failed out of 42`.
## M10/M11 repair verification (orchestrator, feat tip 7f6ac55)
- Repaired 2026-10-30: archived 3 malformed auto-decomposed cards (no workspace_path, spawn-fail loops); cherry-picked the M6-M9 line onto feat (6a265a3 d70a3c6 ac6a04e f09dd63 7f6ac55); origin/main restored to 3112291 after an accidental push of ILLU commits (all work preserved on feat branch, pushed).
- M10 AGENTS.md edits NOT applied — approval-protected path, needs operator (see M11b report below for exact text).
- M11b full matrix from clean tree (logs build/logs/m11b-*.log), feat tip 7f6ac55:
- cmake cfg=0, build=0, 0 warning/error lines; ctest 42/42 passed.
- dotnet format --verify-no-changes: format=0; dotnet build slnx: 0; Engine.Tests exit 0, 0 FAIL lines, 10 suites `all checks passed`.
- codegen 44 entries, re-run diff clean (codegen-clean=0).
- Matches M0 baseline (no new failures; SDK 10.0.403 ships NativeAOT ILCompiler so export tests pass).
- Full M11 evidence (decisions table, spec check) lives in the t_56fafa04 worktree note (de148b8); this entry confirms the feat tip reproduces it.
## PR (ILLU-PR)
- End-to-end PR: https://github.com/Heretek-AI/game-engine/pull/52 — `[Illusion] ABI 4.2 end-to-end (M0-M11)`, base main, head feat/illusion-layer-abi-4.2 (15 commits M0-M11 + M10 AGENTS.md fix).
- Body cites spec Verification + Decisions log, states all 10 decisions followed. Gates pending at open (CI running); do NOT merge — to qa-gate final review.
@@ -1,21 +1,22 @@
// ComponentSystemGenerator.cs — M2-02: IComponent.Tick -> zero-copy native chunk job.
// ComponentSystemGenerator.cs — M2-02 / ABI 4.2: IComponent.Tick -> native chunk job.
//
// An IIncrementalGenerator that intercepts every `partial struct : IComponent`
// carrying a `Tick(...)` method and emits a sibling partial with:
// * an [UnmanagedCallersOnly] static chunk runner that unpacks
// `EcsChunkView*` into per-row component refs and invokes the user's Tick
// in a tight, bounds-check-eliminated loop, and
// * a registration helper reporting the component size/alignment
// (sizeof(T)) plus the Tick signature's component dependencies.
// * an [UnmanagedCallersOnly] static `RunChunk(EngineChunkColumns*, float dt)`
// that wraps each term column in a Span/ReadOnlySpan and invokes the
// user's Tick per row, catching any exception (see `Faulted`), and
// * `Register(ApiTable*, EngineWorld*, void*)`, which registers every
// component type of the Tick by name (Engine.Runtime.Ecs.ComponentId<T>)
// and binds RunChunk through system_register (ABI 4.2).
//
// Calling convention (matches the M2-01 Tick contract `Tick(ref Self self,
// ...)`): instance Tick is driven through the column element itself —
// `col0[i].Tick(ref col0[i], ...)` — where the element is BOTH the receiver
// AND the explicit ref/in self argument. This is NOT redundant: an instance
// method's hidden `this` and an explicit `ref Self` parameter are different
// slots, and omitting the explicit argument is CS7036. Extra component
// params ride the second ABI channel (`in`/`ref col1[i]`); scalars pin to
// default(T) until the M2-04 registration shim publishes the frame delta.
// slots, and omitting the explicit argument is CS7036. Component k of the
// Tick is native term k (`ref` = read-write, `in` = read-only); every
// `float` parameter receives the frame delta dt.
//
// Pipeline (value-equatable throughout; no symbol/syntax held past its stage):
// syntax: every partial struct declaring a method named Tick.
@@ -241,7 +242,8 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
pars.Add(new TickParameterSnapshot(
kind,
p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
p.Name));
p.Name,
kind != TickRefKind.None && IsUnmanagedComponent(p.Type, iComponent)));
}
ticks.Add(new TickSignatureSnapshot(
@@ -274,13 +276,13 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
private static TickRefKind SelfRefKind(ImmutableArray<TickParameterSnapshot>.Builder pars)
{
// The column element doubles as receiver AND self arg: its ref-ness
// comes from the first ref/in COMPONENT param. Scalars (e.g. an
// `in float dt` leading the signature) are skipped — they pin to
// default(T) at the call site and never select the column kind.
// comes from the first ref/in COMPONENT param. float scalars (e.g. an
// `in float dt` leading the signature) are skipped — they receive the
// frame delta at the call site and never select the column kind.
// TickRefKind.None when the Tick takes scalars only (unrunnable).
foreach (var p in pars)
{
if (IsScalarTickArg(p.TypeName))
if (IsFloatScalar(p.TypeName))
{
continue;
}
@@ -377,6 +379,16 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
return false;
}
// A Tick component parameter must be usable as a native column: an
// unmanaged value type implementing IComponent. Checked with the
// existing Implements helper; anything else is not a component.
private static bool IsUnmanagedComponent(ITypeSymbol type, INamedTypeSymbol iComponent)
{
return type.IsUnmanagedType
&& type is INamedTypeSymbol named
&& Implements(named, iComponent);
}
private static ImmutableArray<string> ReadPhases(ISymbol sym)
{
var builder = ImmutableArray.CreateBuilder<string>();
@@ -429,8 +441,8 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("// Engine.Analyzers (M2-02): IComponent chunk runner.");
sb.AppendLine("// Matches NativeApi.EcsChunkView; thread-safe, no allocation, no throw.");
sb.AppendLine("// Engine.Analyzers (ABI 4.2): IComponent chunk runner.");
sb.AppendLine("// Matches NativeApi.EngineChunkColumns; no allocation, Tick exceptions are caught.");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine("using System.Runtime.CompilerServices;");
@@ -508,28 +520,43 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
}
}
// ABI 4.2 contract for a RUNNABLE Tick (see IsRunnable):
// * every component parameter is `ref` (read-write term) or `in`
// (read-only term) and names a distinct type;
// * the FIRST component parameter is the system struct itself (its
// column element is both the receiver and the explicit self arg);
// * at most NativeApi.MaxTerms (8) component parameters;
// * every other parameter is a `float` (by value or `in`) and receives
// the frame delta dt passed to systems_run.
// Anything else (by-value structs, double, out, >8 components, a
// duplicate component type) is unrunnable: Register returns -2 without
// touching native code and no Tick call is emitted.
private const int MaxTerms = 8;
private static void EmitSizeRegistration(StringBuilder sb, SystemSpec spec, TickSignatureSnapshot tick, string selfFqn)
{
// Reports struct size/alignment (sizeof(T)) for the Flecs component
// registration path. The loader (M2-04 ScriptHostManager) reads these
// at register time; dependencies derive from the Tick signature below.
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Byte size of this component (Flecs registration).");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static unsafe int ComponentSize => unchecked((int)sizeof({spec.StructName}));");
sb.AppendLine();
sb.AppendLine(" private static int s_faulted;");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// True after Tick threw once. The runner then skips every later");
sb.AppendLine(" /// chunk (fault isolation: the exception never crosses the ABI).");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static bool Faulted => global::System.Threading.Volatile.Read(ref s_faulted) != 0;");
sb.AppendLine();
if (!IsRunnable(tick, selfFqn))
{
// Unrunnable shape (scalars-only Tick, >2 component params, or a
// non-self first param): Register refuses WITHOUT touching the
// native world, so a bad shape fails at registration time — never
// as a throw escaping [UnmanagedCallersOnly] on the frame path.
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registration is unavailable: this Tick shape cannot map onto");
sb.AppendLine(" /// EcsChunkView. Returns -2 without calling into native code.");
sb.AppendLine(" /// EngineChunkColumns. Returns -2 without calling into native code.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static unsafe int Register(NativeApi.EngineWorld* world, void* userData)");
sb.AppendLine(" public static unsafe int Register(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, void* userData)");
sb.AppendLine(" {");
sb.AppendLine(" _ = table;");
sb.AppendLine(" _ = world;");
sb.AppendLine(" _ = userData;");
sb.AppendLine(" return -2;");
@@ -538,30 +565,53 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
return;
}
var components = ComponentParams(tick);
var systemName = SystemName(spec);
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Managed component-system registration descriptor. Hands the");
sb.AppendLine(" /// chunk runner to the native world: name, component size, and the");
sb.AppendLine(" /// runner function pointer. The native register_system entry");
sb.AppendLine(" /// (EngineRegisterSystem) binds the callback to every matching");
sb.AppendLine(" /// table chunk at run_systems time.");
sb.AppendLine(" /// Registers every component of this Tick with the native world and");
sb.AppendLine(" /// binds RunChunk as a generic system. Returns the native system");
sb.AppendLine(" /// index (>= 0), -1 for a null table/world, or a negative");
sb.AppendLine(" /// NativeApi.Err* code from system_register.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static unsafe int Register(NativeApi.EngineWorld* world, void* userData)");
sb.AppendLine(" public static unsafe int Register(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, void* userData)");
sb.AppendLine(" {");
sb.AppendLine(" if (world == null)");
sb.AppendLine(" if (table == null || world == null)");
sb.AppendLine(" {");
sb.AppendLine(" return -1;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var table = NativeApi.ApiLoader.Table;");
sb.AppendLine(" delegate* unmanaged[Cdecl]<NativeApi.EcsChunkView*, void> fn = &RunChunk;");
sb.AppendLine($" fixed (byte* name = \"{spec.StructName}\"u8)");
sb.AppendLine($" ulong* ids = stackalloc ulong[{components.Count}];");
sb.AppendLine($" uint* access = stackalloc uint[{components.Count}];");
for (var k = 0; k < components.Count; k++)
{
var p = components[k];
sb.AppendLine($" ids[{k}] = global::Engine.Runtime.Ecs.ComponentId<{p.TypeName}>(table, world);");
sb.AppendLine($" access[{k}] = {(p.RefKind == TickRefKind.In ? "NativeApi.AccessIn" : "NativeApi.AccessInOut")};");
}
sb.AppendLine(" delegate* unmanaged[Cdecl]<NativeApi.EngineChunkColumns*, float, void> fn = &RunChunk;");
sb.AppendLine($" fixed (byte* name = \"{systemName}\\0\"u8)");
sb.AppendLine(" {");
sb.AppendLine(" return table->EngineRegisterSystem(world, name, fn, userData);");
sb.AppendLine($" return table->EngineSystemRegister(world, name, ids, access, {components.Count}u, fn, userData);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
}
private static string SystemName(SystemSpec spec)
{
// Same spelling as Type.FullName minus nesting '+': "Ns.Outer.Inner".
var parts = new List<string>();
if (spec.Namespace.Length != 0)
{
parts.Add(spec.Namespace);
}
parts.AddRange(spec.ContainingTypes.Reverse().Select(static c => c.Name));
parts.Add(spec.StructName);
return string.Join(".", parts);
}
private static void EmitChunkRunner(StringBuilder sb, SystemSpec spec, TickSignatureSnapshot tick, string selfFqn)
{
var phases = tick.PhaseNames.Length != 0
@@ -571,241 +621,130 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
? string.Join(", ", phases)
: "Update";
// The column element IS the system struct: col0's type is the
// canonical selfFqn computed in EmitOne — never from the first
// param's spelling — and its ref-ness from the Tick snapshot
// so ref/in self both map. Fully-qualified so the emitted file
// needs no extra usings.
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// Zero-copy chunk runner (phases: {phaseList}).");
sb.AppendLine(" /// Unpacks EcsChunkView columns into per-row refs and");
sb.AppendLine(" /// invokes the user Tick in a bounds-check-eliminated loop.");
sb.AppendLine(" /// Unpacks EngineChunkColumns into one span per component term and");
sb.AppendLine(" /// invokes the user Tick per row with the frame delta.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]");
sb.AppendLine(" public static unsafe void RunChunk(NativeApi.EcsChunkView* view)");
sb.AppendLine(" public static unsafe void RunChunk(NativeApi.EngineChunkColumns* view, float dt)");
sb.AppendLine(" {");
sb.AppendLine(" int count = view->Count;");
sb.AppendLine(" if (count <= 0)");
sb.AppendLine(" {");
sb.AppendLine(" return;");
sb.AppendLine(" }");
sb.AppendLine();
if (!IsRunnable(tick, selfFqn))
{
// Unrunnable shape: empty body (only the guard call, which throws
// if ever reached — but Register already refused with -2, and the
// loader only binds runnable shapes, so this never fires on the
// frame path and no Tick call is emitted at all).
sb.AppendLine(" global::Engine.Generated.EngineRequireExtraColumns.Unsupported();");
// Never bound (Register returned -2): keep the body inert.
sb.AppendLine(" _ = view;");
sb.AppendLine(" _ = dt;");
sb.AppendLine(" }");
return;
}
EmitColumnSetup(sb, tick, tick.SelfRefKind, selfFqn);
sb.AppendLine(" for (int i = 0; i < count; i++)");
var components = ComponentParams(tick);
sb.AppendLine(" int count = view->Count;");
sb.AppendLine($" if (count <= 0 || view->TermCount != {components.Count} || global::System.Threading.Volatile.Read(ref s_faulted) != 0)");
sb.AppendLine(" {");
EmitRowCall(sb, tick);
sb.AppendLine(" return;");
sb.AppendLine(" }");
sb.AppendLine();
for (var k = 0; k < components.Count; k++)
{
var p = components[k];
var spanKind = p.RefKind == TickRefKind.In ? "ReadOnlySpan" : "Span";
var elemType = k == 0 ? selfFqn : p.TypeName;
sb.AppendLine($" var col{k} = new {spanKind}<{elemType}>((void*)view->Columns[{k}], count);");
}
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" for (int i = 0; i < count; i++)");
sb.AppendLine(" {");
sb.AppendLine($" col0[i].Tick({string.Join(", ", RowArgs(tick))});");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" catch (global::System.Exception)");
sb.AppendLine(" {");
sb.AppendLine(" global::System.Threading.Volatile.Write(ref s_faulted, 1);");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
private static void EmitColumnSetup(StringBuilder sb, TickSignatureSnapshot tick, TickRefKind selfRefKind, string selfType)
// Component parameters in Tick order (scalars excluded). Index k is the
// native term index and the column index col{k}.
private static List<TickParameterSnapshot> ComponentParams(TickSignatureSnapshot tick)
{
// The system column is emitted FIRST: the receiver (col0[i]) is also
// the ref/in self argument, so the self slice always exists for
// runnable shapes. col0 is ALWAYS the system struct — never derived
// from the first param's type, so `Tick(in Other o, ref Self s)` (a
// non-self first param) cannot silently mistype the column.
//
// The native EcsChunkView carries exactly two column pointers (Local
// + UserData); a Tick with more than two component params cannot map
// onto the ABI, so the emitter stops at two and reports the rest in
// a comment; IsRunnable routes those shapes to the guard instead.
var selfRef = selfRefKind == TickRefKind.In ? "ReadOnlySpan" : "Span";
sb.AppendLine($" var col0 = new {selfRef}<{selfType}>((void*)view->Local, count);");
// Extra component columns ride packed behind the receiver in Tick
// parameter order. Scalars (in float dt and friends) are NOT columns:
// they are loop-invariant Tick arguments, filled with default(T) at
// the call site by EmitRowCall, so they never consume the ABI channel.
var compIndex = 1;
var skippedSelf = false;
var list = new List<TickParameterSnapshot>();
foreach (var p in tick.Parameters)
{
if ((int)p.RefKind == 0 || IsScalarTickArg(p.TypeName))
if (!IsFloatScalar(p.TypeName))
{
continue;
list.Add(p);
}
if (!skippedSelf)
{
skippedSelf = true;
continue;
}
if (compIndex == 1)
{
var spanKind = p.RefKind == TickRefKind.In ? "ReadOnlySpan" : "Span";
sb.AppendLine($" var col1 = new {spanKind}<{p.TypeName}>(view->UserData, count);");
}
else
{
sb.AppendLine($" // Column {compIndex} ({p.TypeName} {p.Name}): no ABI channel; Tick overload not runnable.");
}
compIndex++;
}
return list;
}
private static TickParameterSnapshot? FirstComponent(TickSignatureSnapshot tick)
private static IEnumerable<string> RowArgs(TickSignatureSnapshot tick)
{
// Instance Tick is invoked through the column element itself
// (col0[i].Tick(ref col0[i], ...)): the element is BOTH the receiver
// AND the explicit ref/in self argument. C# does not fuse the hidden
// `this` with an explicit `ref Self` parameter (CS7036 if omitted).
var k = 0;
foreach (var p in tick.Parameters)
{
if ((int)p.RefKind != 0 && !IsScalarTickArg(p.TypeName))
if (IsFloatScalar(p.TypeName))
{
return p;
yield return p.RefKind == TickRefKind.In ? "in dt" : "dt";
continue;
}
}
return null;
yield return (p.RefKind == TickRefKind.In ? "in col" : "ref col") + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + "[i]";
k++;
}
}
private static bool IsRunnable(TickSignatureSnapshot tick, string selfFqn)
{
// Runnable = receiver-side self exists AND maps cleanly onto the two
// ABI channels. Scalars (by value or ref/in, including a leading dt)
// are skipped BEFORE any shape check — they pin to default(T) at the
// call site and never consume a channel or select the self slot.
// The first non-scalar component param must be ref/in AND spell the
// system struct itself (selfFqn form): `Tick(in Other o, ref Self s)`
// is not runnable (columns cannot silently mistype).
TickParameterSnapshot? firstComponent = null;
var components = new List<TickParameterSnapshot>();
foreach (var p in tick.Parameters)
{
if ((int)p.RefKind == 0 || IsScalarTickArg(p.TypeName))
if (IsFloatScalar(p.TypeName))
{
// dt: by value or `in` only (a `ref float` would alias the
// runner's parameter, which Tick must not write).
if (p.RefKind == TickRefKind.Ref)
{
return false;
}
continue;
}
firstComponent = p;
break;
if (p.RefKind == TickRefKind.None)
{
return false;
}
if (!p.IsComponent)
{
return false;
}
components.Add(p);
}
if (firstComponent is null || firstComponent.TypeName != selfFqn)
if (components.Count == 0 || components.Count > MaxTerms || components[0].TypeName != selfFqn)
{
return false;
}
var components = 0;
var seenSelf = false;
foreach (var p in tick.Parameters)
{
if ((int)p.RefKind == 0 || IsScalarTickArg(p.TypeName))
{
continue;
}
if (!seenSelf)
{
seenSelf = true;
components++;
continue;
}
components++;
if (components > 2)
{
return false;
}
}
return seenSelf && components <= 2;
return components.Select(static c => c.TypeName).Distinct(StringComparer.Ordinal).Count() == components.Count;
}
private static bool IsScalarTickArg(string typeName)
private static bool IsFloatScalar(string typeName)
{
return typeName == "float"
|| typeName == "double"
|| typeName == "global::System.Single"
|| typeName == "global::System.Double"
|| typeName.EndsWith("::float", StringComparison.Ordinal)
|| typeName.EndsWith("::double", StringComparison.Ordinal)
|| typeName.EndsWith("::System.Single", StringComparison.Ordinal)
|| typeName.EndsWith("::System.Double", StringComparison.Ordinal);
}
private static bool IsRefOrInScalar(TickParameterSnapshot p)
{
return (int)p.RefKind != 0 && IsScalarTickArg(p.TypeName);
}
private static string RefPrefix(TickParameterSnapshot p)
{
return (int)p.RefKind == 2 ? "in" : "ref";
}
private static void EmitRowCall(StringBuilder sb, TickSignatureSnapshot tick)
{
// Instance Tick is invoked through the column element itself
// (col0[i].Tick(ref col0[i], ...)): the element is BOTH the receiver
// AND the explicit ref/in self argument. Both slots must be filled —
// C# does not fuse the hidden `this` with an explicit `ref Self`
// parameter, so omitting it is CS7036.
//
// Ref-kind tests use integer values (not == on the enum) so the
// emitter stays correct even if TickRefKind gains members: 0 means
// by-value, anything else is a component reference.
//
// The scalar dt (in float / float / double) has no ABI channel yet:
// M2-01 Tick signatures still carry it, so it is pinned to its
// default for now and the M2-04 registration shim owns publishing the
// frame delta (mirrors MovementSystem.SetDt in M1-05).
//
// Only called for runnable shapes (IsRunnable gate in EmitChunkRunner
// and EmitSizeRegistration): the first component param is the self
// slot, exactly one extra component rides col1, scalars pin inline.
var args = new List<string>();
var seenSelf = false;
foreach (var p in tick.Parameters)
{
var isComponent = (int)p.RefKind != 0 && !IsScalarTickArg(p.TypeName);
// First component param: forward the receiver element as the
// explicit self argument (ref col0[i] / in col0[i]).
if (!seenSelf && isComponent)
{
seenSelf = true;
args.Add((int)p.RefKind == 2 ? "in col0[i]" : "ref col0[i]");
continue;
}
// Scalars (by value, or ref/in scalars like dt): no ABI channel
// yet, pin to default. M2-04 owns publishing the frame delta.
// ref/in scalars cannot take `default(T)` inline (CS8156: a
// default expression is not a passable variable), so they bind
// through a loop-scoped temp — still zero-alloc, still throw-free.
if ((int)p.RefKind == 0 || IsScalarTickArg(p.TypeName))
{
if (IsRefOrInScalar(p))
{
var temp = $"__tick_{p.Name}";
sb.AppendLine($" {p.TypeName} {temp} = default({p.TypeName});");
args.Add($"{RefPrefix(p)} {temp}");
}
else
{
args.Add($"default({p.TypeName})");
}
continue;
}
// Exactly one extra component rides the second ABI channel.
args.Add((int)p.RefKind == 2 ? "in col1[i]" : "ref col1[i]");
}
sb.AppendLine($" col0[i].Tick({string.Join(", ", args)});");
|| typeName.EndsWith("::System.Single", StringComparison.Ordinal);
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ internal enum TickRefKind
internal sealed record TickParameterSnapshot(
TickRefKind RefKind,
string TypeName,
string Name);
string Name,
bool IsComponent);
/// <summary>One user Tick overload: parameters + UpdateInPhase phases.</summary>
internal sealed record TickSignatureSnapshot(
+35 -4
View File
@@ -1,7 +1,7 @@
// <auto-generated>
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
// ABI 4.1.0, column stride 64.
// ABI 4.2.0, column stride 64.
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
// </auto-generated>
#nullable enable
@@ -25,10 +25,19 @@ namespace Engine.Bindings;
public static unsafe class NativeApi
{
public const int AbiVersionMajor = 4;
public const int AbiVersionMinor = 1;
public const int AbiVersionMinor = 2;
public const int AbiVersionPatch = 0;
public const int ColumnStride = 64;
public const string NativeLibraryName = "engine_core";
public const int MaxTerms = 8;
public const int AccessIn = 0;
public const int AccessInOut = 1;
public const int ErrNullArg = -1;
public const int ErrBadArg = -2;
public const int ErrSizeMismatch = -3;
public const int ErrNotFound = -4;
public const int ErrInRun = -5;
public const int ErrInternal = -6;
/// <summary>Opaque native world handle (owns the Flecs world).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8)]
@@ -133,6 +142,22 @@ public static unsafe class NativeApi
public void* UserData;
}
/// <summary>
/// Mirror of EngineChunkColumns (ABI 4.2): one matched table slice for a
/// generic system. Columns[i] is the base address of term i's column
/// (cast to the term's component pointer); valid only for the callback.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 8, Size = 120)]
public unsafe struct EngineChunkColumns
{
public fixed ulong Columns[8];
public fixed int Sizes[8];
public ulong* Entities;
public int Count;
public int TermCount;
public void* UserData;
}
/// <summary>
/// Managed per-chunk system signature (ABI 2: view + frame dt by value).
/// Implement as a static method marked
@@ -197,6 +222,12 @@ public static unsafe class NativeApi
public delegate* unmanaged[Cdecl]<EngineEditorSession*, EngineEditorSnapshot*> EngineEditorSnapshotTake;
public delegate* unmanaged[Cdecl]<EngineEditorSession*, EngineEditorSnapshot*, int> EngineEditorSnapshotRestore;
public delegate* unmanaged[Cdecl]<EngineEditorSnapshot*, void> EngineEditorSnapshotDiscard;
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, uint, uint, ulong> EngineComponentRegister;
public delegate* unmanaged[Cdecl]<EngineWorld*, ulong> EngineEntityCreate;
public delegate* unmanaged[Cdecl]<EngineWorld*, ulong, ulong, void*, uint, int> EngineEntitySet;
public delegate* unmanaged[Cdecl]<EngineWorld*, ulong, ulong, void*, uint, int> EngineEntityGet;
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, ulong*, uint*, uint, delegate* unmanaged[Cdecl]<EngineChunkColumns*, float, void>, void*, int> EngineSystemRegister;
public delegate* unmanaged[Cdecl]<EngineWorld*, FrameDispatch*, int> EngineSystemsRun;
}
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
@@ -245,9 +276,9 @@ public static unsafe class NativeApi
throw new InvalidOperationException($"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.");
}
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48)
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120)
{
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)}.");
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)}.");
}
_table = table;
+134
View File
@@ -0,0 +1,134 @@
// Ecs.cs — ABI 4.2: typed helpers over the generic component/system tail.
//
// Every IComponent type T is registered with the native world by NAME:
// typeof(T).FullName (UTF-8, NUL-terminated), sizeof(T) and AlignOf<T>().
// The name bytes are built once per T (init-time allocation, cached in a
// static generic class); the frame path never touches this file.
//
// Generated systems (Engine.Analyzers) call ComponentId<T> while
// registering, so the names here and in generated code always agree.
using System.Text;
using Engine.Bindings;
namespace Engine.Runtime;
/// <summary>
/// Typed access to native components and entities through the ABI 4.2
/// generic tail. Init-time API: not for use inside system callbacks.
/// </summary>
public static unsafe class Ecs
{
/// <summary>
/// Registers <typeparamref name="T"/> with the world (idempotent) and
/// returns its native component id. Throws when the native world
/// already holds a component with this name but a different layout.
/// </summary>
public static ulong ComponentId<T>(NativeApi.ApiTable* table, NativeApi.EngineWorld* world)
where T : unmanaged
{
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(world);
var name = ComponentName<T>.Utf8;
ulong id;
fixed (byte* p = name)
{
id = table->EngineComponentRegister(world, p, (uint)sizeof(T), (uint)Layout.AlignOf<T>());
}
if (id == 0)
{
throw new InvalidOperationException(
$"component_register failed for {typeof(T).FullName} (size {sizeof(T)}, align {Layout.AlignOf<T>()}): " +
"a component with this name and a different layout already exists, or the layout is invalid.");
}
return id;
}
/// <summary>Creates one empty entity. Throws on failure.</summary>
public static Entity CreateEntity(NativeApi.ApiTable* table, NativeApi.EngineWorld* world)
{
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(world);
var id = table->EngineEntityCreate(world);
if (id == 0)
{
throw new InvalidOperationException("entity_create failed (null world, or called inside systems_run).");
}
return new Entity(id);
}
/// <summary>Adds or overwrites component <typeparamref name="T"/> on an entity.</summary>
public static void Set<T>(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity entity, in T value)
where T : unmanaged
{
var component = ComponentId<T>(table, world);
int rc;
fixed (T* p = &value)
{
rc = table->EngineEntitySet(world, entity.Id, component, p, (uint)sizeof(T));
}
if (rc != 0)
{
throw new InvalidOperationException($"entity_set<{typeof(T).FullName}> failed with code {rc}.");
}
}
/// <summary>Reads component <typeparamref name="T"/> of an entity. Throws when absent.</summary>
public static T Get<T>(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity entity)
where T : unmanaged
{
var component = ComponentId<T>(table, world);
T value = default;
var rc = table->EngineEntityGet(world, entity.Id, component, &value, (uint)sizeof(T));
if (rc != 0)
{
throw new InvalidOperationException($"entity_get<{typeof(T).FullName}> failed with code {rc}.");
}
return value;
}
/// <summary>Runs every generic system once with frame delta <paramref name="dt"/>.</summary>
public static void RunSystems(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, float dt)
{
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(world);
var dispatch = new NativeApi.FrameDispatch { Dt = dt };
var rc = table->EngineSystemsRun(world, &dispatch);
if (rc != 0)
{
throw new InvalidOperationException($"systems_run failed with code {rc}.");
}
}
private static class ComponentName<T>
where T : unmanaged
{
// FullName is non-null for every closed, non-generic-parameter type.
internal static readonly byte[] Utf8 = Encoding.UTF8.GetBytes(typeof(T).FullName + "\0");
}
}
/// <summary>Managed layout queries for blittable component types.</summary>
public static class Layout
{
/// <summary>
/// Alignment of <typeparamref name="T"/> as the runtime lays it out
/// inside another struct (the offset of a T placed after one byte).
/// </summary>
public static unsafe int AlignOf<T>()
where T : unmanaged
=> sizeof(AlignProbe<T>) - sizeof(T);
private struct AlignProbe<T>
where T : unmanaged
{
#pragma warning disable CS0169, IDE0051, CA1823 // fields exist only to be laid out
private byte _pad;
private T _value;
#pragma warning restore CS0169, IDE0051, CA1823
}
}
+10 -2
View File
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
{
var failures = 0;
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
failures += Check(NativeApi.AbiVersionMinor == 1, "abi minor == 1 (4.1: physics + editor tails)");
failures += Check(NativeApi.AbiVersionMinor == 2, "abi minor == 2 (4.2: generic component/system tail)");
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
@@ -27,7 +27,10 @@ public static class AbiLayoutTests
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
failures += Check(sizeof(NativeApi.ApiTable) == 312, "ApiTable 312B (ABI 4.1: +4 physics +18 editor entries)");
failures += Check(sizeof(NativeApi.ApiTable) == 360, "ApiTable 360B (ABI 4.2: +6 generic entries)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentRegister").ToInt64() == 312, "component_register @ 312 (ABI 4.2 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineSystemsRun").ToInt64() == 352, "systems_run @ 352 (ABI 4.2 tail)");
failures += Check(sizeof(NativeApi.EngineChunkColumns) == 120, "EngineChunkColumns 120B");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EnginePhysicsRaycast").ToInt64() == 136, "physics_raycast @ 136 (M4 tail frozen)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorCreate").ToInt64() == 168, "editor_create @ 168 (M5 tail)");
failures += Check(sizeof(NativeApi.EngineEditorSession) == 8, "EngineEditorSession opaque 8B");
@@ -41,6 +44,10 @@ public static class AbiLayoutTests
failures += Check(sizeof(Engine.Runtime.RaycastHit) == 48, "Runtime.RaycastHit 48B");
}
failures += Check(Marshal.OffsetOf<NativeApi.EngineChunkColumns>("Entities").ToInt64() == 96, "columns.Entities @ 96");
failures += Check(Marshal.OffsetOf<NativeApi.EngineChunkColumns>("Count").ToInt64() == 104, "columns.Count @ 104");
failures += Check(Marshal.OffsetOf<NativeApi.EngineChunkColumns>("TermCount").ToInt64() == 108, "columns.TermCount @ 108");
failures += Check(Marshal.OffsetOf<NativeApi.EngineChunkColumns>("UserData").ToInt64() == 112, "columns.UserData @ 112");
failures += Check(Marshal.OffsetOf<NativeApi.EcsChunkView>("Local") == IntPtr.Zero, "chunk.Local @ 0");
failures += Check(Marshal.OffsetOf<NativeApi.EcsChunkView>("World").ToInt64() == 8, "chunk.World @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.EcsChunkView>("Entities").ToInt64() == 16, "chunk.Entities @ 16");
@@ -73,6 +80,7 @@ public static class AbiLayoutTests
failures += Check(src.Contains("unmanaged[Cdecl]<EnginePhysicsWorld*, Ray*, RaycastHit*, uint, uint> EnginePhysicsRaycastBatch", StringComparison.Ordinal), "physics_raycast_batch present, plain Cdecl");
failures += Check(!src.Contains("SuppressGCTransition]<EnginePhysicsWorld*", StringComparison.Ordinal), "physics entries NOT suppressed (may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, EngineEditorSession*> EngineEditorCreate", StringComparison.Ordinal), "editor_create present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, ulong*, uint*, uint, delegate* unmanaged[Cdecl]<EngineChunkColumns*, float, void>, void*, int> EngineSystemRegister", StringComparison.Ordinal), "system_register present, plain Cdecl");
failures += Check(!src.Contains("DllImport", StringComparison.Ordinal), "no DllImport P/Invoke stubs");
}
else
@@ -0,0 +1,263 @@
// GeneratorDriverTests.cs — ABI 4.2 gate: runs the REAL ComponentSystemGenerator
// (loaded by path, like AnalyzerTests) over in-memory IComponent fixtures,
// requires the output to compile with zero errors, then loads the emitted
// assembly and calls the generated [UnmanagedCallersOnly] RunChunk through a
// function pointer over hand-built EngineChunkColumns. No native library:
// runs on every CI OS in the managed job.
using System.Reflection;
using Engine.Bindings;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Engine.Tests;
public static unsafe class GeneratorDriverTests
{
private const string Fixture = """
using System;
using Engine.Runtime;
namespace Fx;
public partial struct Health : IComponent
{
public float Value;
}
public partial struct Mover : IComponent
{
public float Speed;
public float X;
public float Y;
public void Tick(ref Mover self, in Health health, float dt)
{
self.X += health.Value * dt;
self.Y += self.Speed * dt;
}
}
public partial struct Spinner : IComponent
{
public float Angle;
public void Tick(ref Spinner self, in float dt) => self.Angle += dt;
}
public partial struct Bomb : IComponent
{
private static readonly InvalidOperationException Boom = new("boom");
public int Ticks;
public void Tick(ref Bomb self)
{
self.Ticks++;
if (self.Ticks == 2)
{
throw Boom;
}
}
}
// Unrunnable shapes: Register must return -2.
public partial struct ByValue : IComponent
{
public int V;
public void Tick(ref ByValue self, Health health) => self.V++;
}
public partial struct DoubleDt : IComponent
{
public int V;
public void Tick(ref DoubleDt self, double dt) => self.V++;
}
public partial struct DupTerm : IComponent
{
public int V;
public void Tick(ref DupTerm self, in Health a, in Health b) => self.V++;
}
// Non-component ref parameter: Register must return -2.
public struct PlainData
{
public int V;
}
public partial struct BadParam : IComponent
{
public int V;
public void Tick(ref BadParam self, ref PlainData other) => self.V++;
}
""";
public static int Run()
{
var failures = 0;
var asm = CompileWithGenerator(out var errors, out var generated);
failures += Check(errors.Count == 0, $"generated output compiles with 0 errors (got {errors.Count}: {string.Join(" | ", errors)})");
failures += Check(generated.Contains("RunChunk(NativeApi.EngineChunkColumns* view, float dt)", StringComparison.Ordinal), "runner takes (EngineChunkColumns*, float dt)");
failures += Check(generated.Contains("table->EngineSystemRegister(world, name, ids, access, 2u, fn, userData)", StringComparison.Ordinal), "Mover registers 2 terms");
failures += Check(generated.Contains("col0[i].Tick(ref col0[i], in col1[i], dt)", StringComparison.Ordinal), "Mover row call forwards in Health + dt");
failures += Check(generated.Contains("col0[i].Tick(ref col0[i], in dt)", StringComparison.Ordinal), "Spinner row call forwards in dt");
if (asm is null)
{
Console.WriteLine($"GeneratorDriverTests: {failures} FAILED (no assembly)");
return failures + 1;
}
failures += CheckMover(asm);
failures += CheckBomb(asm);
foreach (var name in new[] { "Fx.ByValue", "Fx.DoubleDt", "Fx.DupTerm", "Fx.BadParam" })
{
failures += Check(CallRegister(asm, name) == -2, $"{name}.Register -> -2 (unrunnable shape)");
}
failures += Check(CallRegister(asm, "Fx.Mover") == -1, "Fx.Mover.Register(null table) -> -1");
Console.WriteLine(failures == 0 ? "GeneratorDriverTests: all checks passed" : $"GeneratorDriverTests: {failures} FAILED");
return failures;
}
private static int CheckMover(Assembly asm)
{
// Mover = 3 floats (Speed, X, Y); Health = 1 float.
const int rows = 4;
var movers = stackalloc float[rows * 3];
var health = stackalloc float[rows];
for (var i = 0; i < rows; i++)
{
movers[i * 3] = i; // Speed
movers[(i * 3) + 1] = 0; // X
movers[(i * 3) + 2] = 0; // Y
health[i] = 10 * (i + 1);
}
var view = default(NativeApi.EngineChunkColumns);
view.Columns[0] = (ulong)movers;
view.Columns[1] = (ulong)health;
view.Sizes[0] = 12;
view.Sizes[1] = 4;
view.Count = rows;
view.TermCount = 2;
RunChunk(asm, "Fx.Mover")(&view, 0.5f);
var ok = true;
for (var i = 0; i < rows; i++)
{
ok &= movers[(i * 3) + 1] == 10 * (i + 1) * 0.5f && movers[(i * 3) + 2] == i * 0.5f;
}
var failures = Check(ok, "Mover RunChunk: X += Health*dt, Y += Speed*dt for 4 rows");
// Wrong term count: the runner must not touch memory.
view.TermCount = 1;
RunChunk(asm, "Fx.Mover")(&view, 0.5f);
failures += Check(movers[1] == 5.0f, "Mover RunChunk ignores a view with the wrong term count");
return failures;
}
private static int CheckBomb(Assembly asm)
{
var ticks = stackalloc int[3];
var view = default(NativeApi.EngineChunkColumns);
view.Columns[0] = (ulong)ticks;
view.Sizes[0] = 4;
view.Count = 3;
view.TermCount = 1;
var fn = RunChunk(asm, "Fx.Bomb");
fn(&view, 0f);
fn(&view, 0f); // row 0 reaches 2 and throws; the runner catches it
fn(&view, 0f); // faulted: skipped entirely
var faulted = (bool)asm.GetType("Fx.Bomb", throwOnError: true)!.GetProperty("Faulted")!.GetValue(null)!;
var failures = Check(faulted, "Bomb.Faulted after Tick threw");
failures += Check(ticks[0] == 2 && ticks[1] == 1 && ticks[2] == 1, $"Bomb rows after fault == 2,1,1 (got {ticks[0]},{ticks[1]},{ticks[2]})");
return failures;
}
private static delegate* unmanaged[Cdecl]<NativeApi.EngineChunkColumns*, float, void> RunChunk(Assembly asm, string type)
{
var mi = asm.GetType(type, throwOnError: true)!.GetMethod("RunChunk", BindingFlags.Public | BindingFlags.Static)!;
return (delegate* unmanaged[Cdecl]<NativeApi.EngineChunkColumns*, float, void>)mi.MethodHandle.GetFunctionPointer();
}
private static int CallRegister(Assembly asm, string type)
{
var mi = asm.GetType(type, throwOnError: true)!.GetMethod("Register", BindingFlags.Public | BindingFlags.Static)!;
var fn = (delegate*<NativeApi.ApiTable*, NativeApi.EngineWorld*, void*, int>)mi.MethodHandle.GetFunctionPointer();
return fn(null, null, null);
}
private static Assembly? CompileWithGenerator(out List<string> errors, out string generated)
{
// The driver MUST get the same parse options as the input tree, or
// AddSyntaxTrees throws "Inconsistent language versions".
var parseOptions = new CSharpParseOptions(LanguageVersion.Preview);
var tree = CSharpSyntaxTree.ParseText(Fixture, parseOptions);
var compilation = CSharpCompilation.Create(
"GeneratorDriverFixture",
[tree],
References(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithAllowUnsafe(true));
var genType = GeneratorAssembly.Value.GetType("Engine.Analyzers.ComponentSystemGenerator", throwOnError: true)!;
var generator = ((IIncrementalGenerator)Activator.CreateInstance(genType)!).AsSourceGenerator();
var driver = CSharpGeneratorDriver.Create([generator], parseOptions: parseOptions);
driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var genDiagnostics);
generated = string.Join("\n", output.SyntaxTrees.Skip(1).Select(static t => t.ToString()));
errors = genDiagnostics.Concat(output.GetDiagnostics())
.Where(static d => d.Severity == DiagnosticSeverity.Error)
.Select(static d => d.ToString())
.ToList();
if (errors.Count != 0)
{
return null;
}
using var ms = new MemoryStream();
var emit = output.Emit(ms);
if (!emit.Success)
{
errors.AddRange(emit.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.ToString()));
return null;
}
return Assembly.Load(ms.ToArray());
}
private static readonly Lazy<Assembly> GeneratorAssembly = new(
() => Assembly.LoadFrom(FindAnalyzerDll()));
private static IEnumerable<MetadataReference> References()
{
var tpa = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!;
foreach (var path in tpa.Split(Path.PathSeparator))
{
yield return MetadataReference.CreateFromFile(path);
}
}
private static string FindAnalyzerDll()
{
// Same layout rules as AnalyzerTests.FindBin (direct, then staged).
var config = typeof(GeneratorDriverTests).Assembly
.GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration ?? "Debug";
var managed = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var direct = Path.Combine(managed, "Engine.Analyzers", "bin", config, "netstandard2.0", "Engine.Analyzers.dll");
if (File.Exists(direct))
{
return direct;
}
var staged = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "managed", "Engine.Analyzers", "bin", config, "netstandard2.0", "Engine.Analyzers.dll"));
return File.Exists(staged) ? staged : direct;
}
private static int Check(bool ok, string name)
{
Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
return ok ? 0 : 1;
}
}
+1
View File
@@ -10,6 +10,7 @@ failures += Engine.Tests.CompositorApiTests.Run(args);
failures += Engine.Tests.GiApiTests.Run(args);
failures += Engine.Tests.PhysicsApiTests.Run(args);
failures += Engine.Tests.GeneratorTests.Run();
failures += Engine.Tests.GeneratorDriverTests.Run();
failures += Engine.Tests.EditorApiTests.Run(args);
failures += Engine.Tests.CliApiTests.Run(args);
failures += Engine.Tests.ExportApiTests.Run(args);
@@ -64,9 +64,16 @@ extern "C" {
// Engine.Editor query the Flecs hierarchy (EcsChildOf/EcsIsA), edit native
// component memory in place, and checkpoint/restore worlds for PIE.
// All ABI 1-3 entries keep order + signature; new fields extend the tail.
//
// ABI 4.2.0: appends the generic component/system tail
// (component_register/entity_create/entity_set/entity_get/system_register/
// systems_run) used by Engine.Analyzers-generated IComponent systems.
// Every user component gets its own Flecs column; systems receive one
// EngineChunkColumns view per matched table. All ABI 4.1 entries keep
// order + signature; new fields extend the tail.
// ---------------------------------------------------------------------------
#define ENGINE_ABI_VERSION_MAJOR 4
#define ENGINE_ABI_VERSION_MINOR 1
#define ENGINE_ABI_VERSION_MINOR 2
#define ENGINE_ABI_VERSION_PATCH 0
// 64-byte cache-line pitch for the component columns handed to managed
@@ -438,6 +445,96 @@ typedef int32_t (*EngineEditorSnapshotRestoreFn)(
typedef void (*EngineEditorSnapshotDiscardFn)(
EngineEditorSnapshot* snapshot);
// ---------------------------------------------------------------------------
// Generic component/system tail (ABI 4.2: Illusion Layer).
//
// Components are registered by NAME (a UTF-8 type name, e.g. the C#
// Type.FullName "Game.Mover"). The name is never split on '.', so
// "Game.Mover" is one flat Flecs entity. Registering the same name again
// with the same size/alignment returns the same id; a different
// size/alignment returns 0 (never aborts).
//
// A system is a list of up to ENGINE_ABI_MAX_TERMS component ids plus an
// access flag per term (ENGINE_ABI_ACCESS_IN = read-only,
// ENGINE_ABI_ACCESS_INOUT = read-write). systems_run walks systems in
// registration order and calls each callback once per matched table with
// an EngineChunkColumns view: columns[i] is the base of term i's column
// (element j at (char*)columns[i] + j * sizes[i]).
//
// Callbacks run on the thread that called systems_run. They MUST NOT call
// entity_create/entity_set/system_register (those return
// ENGINE_ABI_ERR_IN_RUN while systems_run is active).
// ---------------------------------------------------------------------------
#define ENGINE_ABI_MAX_TERMS 8
#define ENGINE_ABI_ACCESS_IN 0
#define ENGINE_ABI_ACCESS_INOUT 1
// Error codes shared by the generic tail (negative int32_t returns).
#define ENGINE_ABI_ERR_NULL_ARG -1
#define ENGINE_ABI_ERR_BAD_ARG -2
#define ENGINE_ABI_ERR_SIZE_MISMATCH -3
#define ENGINE_ABI_ERR_NOT_FOUND -4
#define ENGINE_ABI_ERR_IN_RUN -5
#define ENGINE_ABI_ERR_INTERNAL -6
// One matched table slice handed to a generic system callback. 120 bytes
// on 64-bit targets (the engine is 64-bit only).
typedef struct EngineChunkColumns {
void* columns[8]; // term i column base, i < term_count
int32_t sizes[8]; // term i element size in bytes
const uint64_t* entities; // entity ids for the rows (never NULL)
int32_t count; // rows in this slice (> 0)
int32_t term_count; // terms in this system (1..8)
void* user_data; // context passed to system_register
} EngineChunkColumns;
// Generic system callback: one call per matched table, dt by value.
typedef void (*EngineSystemCallback)(const EngineChunkColumns* view, float dt);
// Register (or look up) a component by name. Returns the component id,
// or 0 on failure (null/empty name, size == 0, alignment not a power of
// two or > 64, size % alignment != 0, or an existing component with the
// same name but a different size/alignment).
typedef uint64_t (*EngineComponentRegisterFn)(EngineWorld* world,
const char* name,
uint32_t size,
uint32_t alignment);
// Create one empty entity. Returns its id, or 0 on failure (including
// while systems_run is active).
typedef uint64_t (*EngineEntityCreateFn)(EngineWorld* world);
// Copy `size` bytes from `data` into `component` on `entity` (adds the
// component if missing). `size` must equal the registered size.
// Returns 0 on success or an ENGINE_ABI_ERR_* code.
typedef int32_t (*EngineEntitySetFn)(EngineWorld* world, uint64_t entity,
uint64_t component, const void* data,
uint32_t size);
// Copy `component` of `entity` into `out` (`size` must equal the
// registered size). Returns 0 on success, ENGINE_ABI_ERR_NOT_FOUND when
// the entity does not have the component, or another ENGINE_ABI_ERR_*.
typedef int32_t (*EngineEntityGetFn)(EngineWorld* world, uint64_t entity,
uint64_t component, void* out,
uint32_t size);
// Register a system over `term_count` component ids (1..8) with one
// access flag per term. Returns the system index (>= 0) or an
// ENGINE_ABI_ERR_* code. `name` is copied (truncated to 63 bytes).
typedef int32_t (*EngineSystemRegisterFn)(EngineWorld* world,
const char* name,
const uint64_t* components,
const uint32_t* access,
uint32_t term_count,
EngineSystemCallback callback,
void* user_data);
// Run every generic system once, in registration order, serially on the
// calling thread. dispatch may be NULL (dt = 0). Returns 0 on success or
// an ENGINE_ABI_ERR_* code.
typedef int32_t (*EngineSystemsRunFn)(EngineWorld* world,
const FrameDispatch* dispatch);
// ---------------------------------------------------------------------------
// EngineApiTable — the single-entry-point ABI surface.
//
@@ -491,6 +588,13 @@ typedef struct EngineApiTable {
EngineEditorSnapshotTakeFn editor_snapshot_take;
EngineEditorSnapshotRestoreFn editor_snapshot_restore;
EngineEditorSnapshotDiscardFn editor_snapshot_discard;
// ABI 4.2 generic component/system tail. All earlier order frozen.
EngineComponentRegisterFn component_register;
EngineEntityCreateFn entity_create;
EngineEntitySetFn entity_set;
EngineEntityGetFn entity_get;
EngineSystemRegisterFn system_register;
EngineSystemsRunFn systems_run;
} EngineApiTable;
+87
View File
@@ -29,6 +29,7 @@ struct enkiTaskScheduler;
#include "engine/ecs/ecs_world.hpp"
#include "engine/ecs/transform.hpp"
#include "flecs.h"
#include "generic_systems.hpp"
// Compile-time pin: native component layout MUST match the ABI mirrors.
// tools/abi_codegen enforces the same on the C# side (sizeof == 64).
@@ -52,6 +53,14 @@ static_assert(sizeof(EcsChunkView) == 48, "EcsChunkView must stay 48 bytes");
static_assert(offsetof(EcsChunkView, count) == 24,
"EcsChunkView.count offset pinned at 24");
static_assert(ENGINE_ABI_COLUMN_STRIDE == 64, "column stride pinned at 64");
// ABI 4.2 generic view: 64-bit only (8 pointers + 8 sizes + tail = 120B).
static_assert(sizeof(void*) == 8, "engine ABI is 64-bit only");
static_assert(sizeof(EngineChunkColumns) == 120, "EngineChunkColumns must be 120 bytes");
static_assert(offsetof(EngineChunkColumns, sizes) == 64, "EngineChunkColumns.sizes @ 64");
static_assert(offsetof(EngineChunkColumns, entities) == 96, "EngineChunkColumns.entities @ 96");
static_assert(offsetof(EngineChunkColumns, count) == 104, "EngineChunkColumns.count @ 104");
static_assert(offsetof(EngineChunkColumns, term_count) == 108, "EngineChunkColumns.term_count @ 108");
static_assert(offsetof(EngineChunkColumns, user_data) == 112, "EngineChunkColumns.user_data @ 112");
namespace {
@@ -79,6 +88,8 @@ struct EngineWorldImpl {
// where complete — ApiWorldDestroy runs in this TU, so default is fine.
struct Plan;
std::unique_ptr<Plan> plan;
// ABI 4.2: name-registered components + term-list systems.
engine::bridge::GenericSystems generic;
};
// Lane geometry for one dispatch (defined at namespace scope: local
@@ -178,6 +189,8 @@ auto ApiWorldDestroy(EngineWorld* world) -> void {
return;
}
auto* impl = ImplOf(world);
// Generic-system queries belong to the Flecs world: fini them first.
impl->generic.shutdown();
if (impl->chunk_query != nullptr) {
ecs_query_fini(impl->chunk_query);
impl->chunk_query = nullptr;
@@ -968,6 +981,74 @@ auto ApiEditorSnapshotDiscard(EngineEditorSnapshot* snapshot) -> void {
delete impl; // NOLINT NOSONAR(cpp:S5025,cpp:S8268) frees the snapshot handle itself
}
// --- ABI 4.2 generic component/system tail ---------------------------------
auto ApiComponentRegister(EngineWorld* world, const char* name, uint32_t size,
uint32_t alignment) -> uint64_t {
if (world == nullptr) {
return 0;
}
auto* impl = ImplOf(world);
return impl->generic.register_component(impl->ecs.raw(), name, size,
alignment);
}
auto ApiEntityCreate(EngineWorld* world) -> uint64_t {
if (world == nullptr) {
return 0;
}
auto* impl = ImplOf(world);
return impl->generic.create_entity(impl->ecs.raw());
}
auto ApiEntitySet(EngineWorld* world, uint64_t entity, uint64_t component,
const void* data, uint32_t size) -> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(world);
const int32_t rc =
impl->generic.set(impl->ecs.raw(), entity, component, data, size);
if (rc == 0) {
// Adding a component moves the entity to another table: drop the
// run_systems lane plan (it caches Local/Velocity column pointers).
impl->plan.reset();
}
return rc;
}
auto ApiEntityGet(EngineWorld* world, uint64_t entity, uint64_t component,
void* out, uint32_t size) -> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(world);
return impl->generic.get(impl->ecs.raw(), entity, component, out, size);
}
auto ApiSystemRegister(EngineWorld* world, const char* name,
const uint64_t* components, const uint32_t* access,
uint32_t term_count, EngineSystemCallback callback,
void* user_data) -> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(world);
return impl->generic.register_system(impl->ecs.raw(), name, components,
access, term_count, callback,
user_data);
}
auto ApiSystemsRun(EngineWorld* world, const FrameDispatch* dispatch)
-> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(world);
const float dt = (dispatch != nullptr) ? dispatch->dt : 0.0F;
return impl->generic.run(impl->ecs.raw(), dt);
}
const EngineApiTable kApiTable = {
sizeof(EngineApiTable),
ENGINE_ABI_VERSION_MAJOR,
@@ -1010,6 +1091,12 @@ const EngineApiTable kApiTable = {
&ApiEditorSnapshotTake,
&ApiEditorSnapshotRestore,
&ApiEditorSnapshotDiscard,
&ApiComponentRegister,
&ApiEntityCreate,
&ApiEntitySet,
&ApiEntityGet,
&ApiSystemRegister,
&ApiSystemsRun,
};
} // namespace
+228
View File
@@ -0,0 +1,228 @@
// generic_systems.cpp — ABI 4.2 generic component/system tail; see header.
#include "generic_systems.hpp"
#include <cstdio>
#include <cstring>
namespace engine::bridge {
namespace {
constexpr std::size_t kMaxNameLen = 255;
// Component size/alignment as Flecs recorded it; nullptr when `id` is not a
// component (or not alive).
auto TypeInfoOf(ecs_world_t* world, uint64_t id) noexcept
-> const ecs_type_info_t* {
if (id == 0 || !ecs_is_alive(world, static_cast<ecs_entity_t>(id))) {
return nullptr;
}
return ecs_get_type_info(world, static_cast<ecs_id_t>(id));
}
auto IsPowerOfTwo(uint32_t v) noexcept -> bool {
return v != 0U && (v & (v - 1U)) == 0U;
}
} // namespace
auto GenericSystems::shutdown() noexcept -> void {
for (auto& s : systems_) {
if (s.query != nullptr) {
ecs_query_fini(s.query);
s.query = nullptr;
}
}
systems_.clear();
}
auto GenericSystems::register_component(ecs_world_t* world, const char* name,
uint32_t size,
uint32_t alignment) noexcept
-> uint64_t {
if (world == nullptr || name == nullptr || name[0] == '\0') {
return 0;
}
if (std::strlen(name) > kMaxNameLen || size == 0U ||
!IsPowerOfTwo(alignment) || alignment > 64U || size % alignment != 0U) {
return 0;
}
// sep = "" keeps "Game.Mover" one flat name (the default '.' separator
// would look up a "Mover" child of a "Game" scope instead).
const ecs_entity_t existing =
ecs_lookup_path_w_sep(world, 0, name, "", "", false);
if (existing != 0) {
const ecs_type_info_t* ti = TypeInfoOf(world, existing);
if (ti != nullptr && ti->size == static_cast<ecs_size_t>(size) &&
ti->alignment == static_cast<ecs_size_t>(alignment)) {
return existing;
}
// Different layout (or a non-component entity with this name). Never
// call ecs_component_init here: Flecs aborts the process on a size
// mismatch (INVALID_COMPONENT_SIZE), even in release builds.
return 0;
}
ecs_entity_desc_t ed{};
ed.name = name;
ed.sep = "";
ed.root_sep = "";
const ecs_entity_t ent = ecs_entity_init(world, &ed);
if (ent == 0) {
return 0;
}
ecs_component_desc_t cd{};
cd.entity = ent;
cd.type.size = static_cast<ecs_size_t>(size);
cd.type.alignment = static_cast<ecs_size_t>(alignment);
return ecs_component_init(world, &cd);
}
auto GenericSystems::create_entity(ecs_world_t* world) noexcept -> uint64_t {
if (world == nullptr || in_run_) {
return 0;
}
return ecs_new(world);
}
auto GenericSystems::set(ecs_world_t* world, uint64_t entity,
uint64_t component, const void* data,
uint32_t size) noexcept -> int32_t {
if (world == nullptr || data == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (in_run_) {
return ENGINE_ABI_ERR_IN_RUN;
}
if (entity == 0 || !ecs_is_alive(world, static_cast<ecs_entity_t>(entity))) {
return ENGINE_ABI_ERR_BAD_ARG;
}
const ecs_type_info_t* ti = TypeInfoOf(world, component);
if (ti == nullptr) {
return ENGINE_ABI_ERR_BAD_ARG;
}
if (ti->size != static_cast<ecs_size_t>(size)) {
return ENGINE_ABI_ERR_SIZE_MISMATCH;
}
ecs_set_id(world, static_cast<ecs_entity_t>(entity),
static_cast<ecs_id_t>(component), static_cast<std::size_t>(size),
data);
return 0;
}
auto GenericSystems::get(ecs_world_t* world, uint64_t entity,
uint64_t component, void* out,
uint32_t size) noexcept -> int32_t {
if (world == nullptr || out == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (entity == 0 || !ecs_is_alive(world, static_cast<ecs_entity_t>(entity))) {
return ENGINE_ABI_ERR_BAD_ARG;
}
const ecs_type_info_t* ti = TypeInfoOf(world, component);
if (ti == nullptr) {
return ENGINE_ABI_ERR_BAD_ARG;
}
if (ti->size != static_cast<ecs_size_t>(size)) {
return ENGINE_ABI_ERR_SIZE_MISMATCH;
}
const void* src = ecs_get_id(world, static_cast<ecs_entity_t>(entity),
static_cast<ecs_id_t>(component));
if (src == nullptr) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
std::memcpy(out, src, size);
return 0;
}
auto GenericSystems::register_system(ecs_world_t* world, const char* name,
const uint64_t* components,
const uint32_t* access,
uint32_t term_count,
EngineSystemCallback callback,
void* user_data) noexcept -> int32_t {
if (world == nullptr || components == nullptr || access == nullptr ||
callback == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (in_run_) {
return ENGINE_ABI_ERR_IN_RUN;
}
if (term_count == 0U || term_count > ENGINE_ABI_MAX_TERMS) {
return ENGINE_ABI_ERR_BAD_ARG;
}
System sys{};
ecs_query_desc_t qd{};
for (uint32_t i = 0; i < term_count; ++i) {
const ecs_type_info_t* ti = TypeInfoOf(world, components[i]);
if (ti == nullptr) {
return ENGINE_ABI_ERR_BAD_ARG;
}
if (access[i] != ENGINE_ABI_ACCESS_IN &&
access[i] != ENGINE_ABI_ACCESS_INOUT) {
return ENGINE_ABI_ERR_BAD_ARG;
}
for (uint32_t k = 0; k < i; ++k) {
if (components[k] == components[i]) {
return ENGINE_ABI_ERR_BAD_ARG; // duplicate term
}
}
qd.terms[i].id = static_cast<ecs_id_t>(components[i]);
// Owned components only: every field is then a real column array
// (an inherited/shared match would be a single pointer, not an array).
qd.terms[i].src.id = EcsSelf;
qd.terms[i].inout = static_cast<int16_t>(
(access[i] == ENGINE_ABI_ACCESS_IN) ? EcsIn : EcsInOut);
sys.sizes[i] = static_cast<int32_t>(ti->size);
}
qd.cache_kind = EcsQueryCacheAuto;
sys.query = ecs_query_init(world, &qd);
if (sys.query == nullptr) {
return ENGINE_ABI_ERR_INTERNAL;
}
if (name != nullptr) {
std::snprintf(sys.name, sizeof(sys.name), "%s", name);
}
sys.callback = callback;
sys.user_data = user_data;
sys.term_count = term_count;
try {
systems_.push_back(sys);
} catch (...) {
ecs_query_fini(sys.query);
return ENGINE_ABI_ERR_INTERNAL;
}
return static_cast<int32_t>(systems_.size() - 1U);
}
auto GenericSystems::run(ecs_world_t* world, float dt) noexcept -> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (in_run_) {
return ENGINE_ABI_ERR_IN_RUN;
}
in_run_ = true;
for (const System& sys : systems_) {
ecs_iter_t it = ecs_query_iter(world, sys.query);
while (ecs_query_next(&it)) {
if (it.count <= 0) {
continue;
}
EngineChunkColumns view{};
for (uint32_t i = 0; i < sys.term_count; ++i) {
view.columns[i] = ecs_field_w_size(
&it, static_cast<std::size_t>(sys.sizes[i]),
static_cast<int8_t>(i));
view.sizes[i] = sys.sizes[i];
}
view.entities = reinterpret_cast<const uint64_t*>(it.entities); // NOLINT
view.count = it.count;
view.term_count = static_cast<int32_t>(sys.term_count);
view.user_data = sys.user_data;
sys.callback(&view, dt);
}
}
in_run_ = false;
return 0;
}
} // namespace engine::bridge
+56
View File
@@ -0,0 +1,56 @@
// generic_systems.hpp — ABI 4.2: name-registered components + term-list
// systems for Engine.Analyzers-generated IComponent code.
//
// Owned by EngineWorldImpl (engine_abi.cpp). Every method takes the raw
// Flecs world; nothing here owns the world. Not thread-safe: register and
// run are serialized by the caller (same rule as the rest of the ABI).
#pragma once
#include <cstdint>
#include <vector>
#include "engine/bridge/engine_abi.h"
#include "flecs.h"
namespace engine::bridge {
class GenericSystems {
public:
GenericSystems() = default;
GenericSystems(const GenericSystems&) = delete;
auto operator=(const GenericSystems&) -> GenericSystems& = delete;
GenericSystems(GenericSystems&&) = delete;
auto operator=(GenericSystems&&) -> GenericSystems& = delete;
~GenericSystems() = default;
// Finalizes every cached query. Call BEFORE the Flecs world is destroyed.
auto shutdown() noexcept -> void;
auto register_component(ecs_world_t* world, const char* name, uint32_t size,
uint32_t alignment) noexcept -> uint64_t;
auto create_entity(ecs_world_t* world) noexcept -> uint64_t;
auto set(ecs_world_t* world, uint64_t entity, uint64_t component,
const void* data, uint32_t size) noexcept -> int32_t;
auto get(ecs_world_t* world, uint64_t entity, uint64_t component, void* out,
uint32_t size) noexcept -> int32_t;
auto register_system(ecs_world_t* world, const char* name,
const uint64_t* components, const uint32_t* access,
uint32_t term_count, EngineSystemCallback callback,
void* user_data) noexcept -> int32_t;
auto run(ecs_world_t* world, float dt) noexcept -> int32_t;
private:
struct System {
char name[64];
ecs_query_t* query;
EngineSystemCallback callback;
void* user_data;
uint32_t term_count;
int32_t sizes[ENGINE_ABI_MAX_TERMS];
};
std::vector<System> systems_;
bool in_run_{false};
};
} // namespace engine::bridge
+20
View File
@@ -339,6 +339,13 @@ engine_apply_base_flags(engine_abi_layout)
add_test(NAME engine_abi_layout COMMAND engine_abi_layout)
set_tests_properties(engine_abi_layout PROPERTIES TIMEOUT 120)
# ABI 4.2: generic component/system tail (Illusion Layer native side).
add_executable(engine_generic_systems test_generic_systems.cpp)
target_link_libraries(engine_generic_systems PRIVATE engine_core)
engine_apply_base_flags(engine_generic_systems)
add_test(NAME engine_generic_systems COMMAND engine_generic_systems)
set_tests_properties(engine_generic_systems PROPERTIES TIMEOUT 120)
# M1-05: hostfxr embedding acceptance. The managed fixture
# (Benchmark1M.Systems.dll + Benchmark1M.runtimeconfig.json) is built by
# `dotnet build` of Engine.slnx; the native smoke test boots CoreCLR
@@ -384,6 +391,19 @@ set_tests_properties(engine_hostfxr_smoke PROPERTIES
TIMEOUT 300
FIXTURES_REQUIRED benchmark1m_fixtures)
# ABI 4.2: Illusion Layer end-to-end gate. samples/IllusionSample is built
# by the same `dotnet build Engine.slnx` fixture; it loads engine_core by
# absolute path, runs Engine.Analyzers-generated systems on native Flecs
# columns, and prints ILLUSION-SAMPLE-OK (exit 0) when every check passes.
add_test(NAME engine_illusion_sample
COMMAND ${DOTNET_EXE}
"${CMAKE_SOURCE_DIR}/samples/IllusionSample/bin/Release/net10.0/IllusionSample.dll"
"$<TARGET_FILE:engine_core>")
set_tests_properties(engine_illusion_sample PROPERTIES
TIMEOUT 300
FIXTURES_REQUIRED benchmark1m_fixtures
PASS_REGULAR_EXPRESSION "ILLUSION-SAMPLE-OK")
# M5-04: one-click export fixtures — NativeAOT game + .pak staging.
# Publishes samples/ExportSample with PublishAot (build --no-build reuses
# the hostfxr fixture build above, so ILCompiler runs once per machine),
+1 -1
View File
@@ -215,7 +215,7 @@ auto main() -> int {
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
failures += check(sizeof(EngineApiTable) == 312, "abi: EngineApiTable 312B (ABI 4.1 physics+editor tails)");
failures += check(sizeof(EngineApiTable) == 360, "abi: EngineApiTable 360B (ABI 4.2 generic tail)");
failures += check(offsetof(EngineApiTable, run_systems) == 64,
"abi: run_systems @ 64 (M1/M2 head frozen)");
failures += check(offsetof(EngineApiTable, renderpass_create) == 72,
+149
View File
@@ -0,0 +1,149 @@
// test_generic_systems.cpp — ABI 4.2 generic component/system tail.
//
// Drives the table entries exactly as generated C# does: register two
// user components by name, spawn entities, register a (Mover inout,
// Health in) system, run it, and read rows back. Also pins the error
// contract (size mismatch returns 0 instead of aborting, in-run guard).
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include "engine/bridge/engine_abi.h"
namespace {
auto check(bool ok, const char* name) -> int {
std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name);
return ok ? 0 : 1;
}
// Mirrors of the C# fixture structs (sequential, 4-byte aligned).
struct Mover {
float speed;
float x;
float y;
};
struct Health {
float value;
};
static_assert(sizeof(Mover) == 12, "Mover fixture is 12 bytes");
const EngineApiTable* g_api = nullptr;
EngineWorld* g_world = nullptr;
int32_t g_calls = 0;
int32_t g_rows = 0;
int32_t g_in_run_create = -1;
int32_t g_bad_sizes = 0;
// x += health * dt; also probes the in-run guard once.
void MoveByHealth(const EngineChunkColumns* view, float dt) {
++g_calls;
g_rows += view->count;
if (view->term_count != 2 || view->sizes[0] != 12 || view->sizes[1] != 4) {
++g_bad_sizes;
return;
}
auto* movers = static_cast<Mover*>(view->columns[0]);
const auto* health = static_cast<const Health*>(view->columns[1]);
for (int32_t i = 0; i < view->count; ++i) {
movers[i].x += health[i].value * dt;
}
if (g_in_run_create < 0) {
g_in_run_create = static_cast<int32_t>(g_api->entity_create(g_world));
}
}
} // namespace
auto main() -> int {
int failures = 0;
g_api = engine_get_api_table();
failures += check(g_api != nullptr, "api table");
if (g_api == nullptr) {
return 1;
}
failures += check(g_api->component_register != nullptr && g_api->systems_run != nullptr,
"generic tail entries set");
g_world = g_api->world_create();
failures += check(g_world != nullptr, "world_create");
if (g_world == nullptr) {
return 1;
}
// --- components ---------------------------------------------------------
const uint64_t mover = g_api->component_register(g_world, "Game.Mover", 12, 4);
const uint64_t mover_again = g_api->component_register(g_world, "Game.Mover", 12, 4);
const uint64_t health = g_api->component_register(g_world, "Game.Health", 4, 4);
failures += check(mover != 0, "register Game.Mover");
failures += check(mover_again == mover, "re-register same layout returns same id");
failures += check(health != 0 && health != mover, "register Game.Health");
failures += check(g_api->component_register(g_world, "Game.Mover", 16, 4) == 0,
"re-register with different size returns 0 (no abort)");
failures += check(g_api->component_register(g_world, "", 4, 4) == 0, "empty name rejected");
failures += check(g_api->component_register(g_world, "Game.Bad", 4, 3) == 0,
"non power-of-two alignment rejected");
failures += check(g_api->component_register(g_world, "Game.Bad", 6, 4) == 0,
"size not a multiple of alignment rejected");
// --- entities -----------------------------------------------------------
uint64_t ents[6] = {};
for (int i = 0; i < 6; ++i) {
ents[i] = g_api->entity_create(g_world);
const Mover m{static_cast<float>(i), 0.0F, 0.0F};
failures += check(g_api->entity_set(g_world, ents[i], mover, &m, sizeof(m)) == 0,
"entity_set Mover");
if (i % 2 == 0) {
const Health h{10.0F * static_cast<float>(i + 1)};
failures += check(g_api->entity_set(g_world, ents[i], health, &h, sizeof(h)) == 0,
"entity_set Health (even rows)");
}
}
const Mover probe{};
failures += check(g_api->entity_set(g_world, ents[0], mover, &probe, 16) ==
ENGINE_ABI_ERR_SIZE_MISMATCH,
"entity_set wrong size -> SIZE_MISMATCH");
Health no_health{};
failures += check(g_api->entity_get(g_world, ents[1], health, &no_health, sizeof(no_health)) ==
ENGINE_ABI_ERR_NOT_FOUND,
"entity_get missing component -> NOT_FOUND");
// --- system -------------------------------------------------------------
const uint64_t ids[2] = {mover, health};
const uint32_t access[2] = {ENGINE_ABI_ACCESS_INOUT, ENGINE_ABI_ACCESS_IN};
const int32_t sys = g_api->system_register(g_world, "MoveByHealth", ids, access, 2,
&MoveByHealth, nullptr);
failures += check(sys == 0, "system_register returns index 0");
const uint64_t dup_ids[2] = {mover, mover};
failures += check(g_api->system_register(g_world, "Dup", dup_ids, access, 2, &MoveByHealth,
nullptr) == ENGINE_ABI_ERR_BAD_ARG,
"duplicate term rejected");
failures += check(g_api->system_register(g_world, "Zero", ids, access, 0, &MoveByHealth,
nullptr) == ENGINE_ABI_ERR_BAD_ARG,
"zero terms rejected");
const FrameDispatch dispatch{0.5F};
failures += check(g_api->systems_run(g_world, &dispatch) == 0, "systems_run ok");
failures += check(g_rows == 3, "system saw exactly the 3 rows with Health");
failures += check(g_bad_sizes == 0, "view carried term_count 2 and sizes 12/4");
failures += check(g_in_run_create == 0, "entity_create inside a callback returns 0");
for (int i = 0; i < 6; ++i) {
Mover got{};
failures += check(g_api->entity_get(g_world, ents[i], mover, &got, sizeof(got)) == 0,
"entity_get Mover");
const float want_x = (i % 2 == 0) ? 10.0F * static_cast<float>(i + 1) * 0.5F : 0.0F;
char label[96];
std::snprintf(label, sizeof(label), "row %d: speed=%.1f x=%.1f (want speed=%d x=%.1f)", i,
static_cast<double>(got.speed), static_cast<double>(got.x), i,
static_cast<double>(want_x));
failures += check(got.speed == static_cast<float>(i) && got.x == want_x, label);
}
failures += check(g_api->systems_run(g_world, nullptr) == 0, "systems_run with NULL dispatch");
g_api->world_destroy(g_world);
std::printf(failures == 0 ? "generic_systems: all checks passed\n"
: "generic_systems: %d FAILED\n",
failures);
return failures == 0 ? 0 : 1;
}
+62
View File
@@ -0,0 +1,62 @@
// Components.cs — user-style IComponent scripts. Engine.Analyzers emits
// Register/RunChunk for every struct that declares a Tick.
using Engine.Runtime;
namespace IllusionSample;
/// <summary>Read-only input for <see cref="Mover"/>; no Tick of its own.</summary>
public partial struct Health : IComponent
{
/// <summary>Hit points; doubles as an x-velocity in this sample.</summary>
public float Value;
}
/// <summary>Moves along x by Health.Value and along y by Speed.</summary>
public partial struct Mover : IComponent
{
/// <summary>Units per second along y.</summary>
public float Speed;
/// <summary>Position x.</summary>
public float X;
/// <summary>Position y.</summary>
public float Y;
/// <summary>Per-row update: two terms (self inout, Health in) plus dt.</summary>
public readonly void Tick(ref Mover self, in Health health, float dt)
{
self.X += health.Value * dt;
self.Y += self.Speed * dt;
}
}
/// <summary>Single-term system with an <c>in float</c> dt.</summary>
public partial struct Spinner : IComponent
{
/// <summary>Accumulated angle.</summary>
public float Angle;
/// <summary>Per-row update.</summary>
public readonly void Tick(ref Spinner self, in float dt) => self.Angle += dt;
}
/// <summary>Throws on its second increment of any row: proves fault isolation.</summary>
public partial struct Bomb : IComponent
{
/// <summary>Times this row ticked.</summary>
public int Ticks;
// Pre-allocated: ENG0001 forbids `new` inside Tick (hot path).
private static readonly InvalidOperationException Boom = new("Bomb fixture fault");
/// <summary>Per-row update that throws once Ticks reaches 2.</summary>
public readonly void Tick(ref Bomb self)
{
self.Ticks++;
if (self.Ticks == 2)
{
throw Boom;
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- ABI 4.2: end-to-end gate for the Illusion Layer. User IComponent
structs are compiled by Engine.Analyzers into chunk runners, bound
through component_register/system_register, and run by the native
world. Run: dotnet IllusionSample.dll <path-to-engine_core-library> -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>IllusionSample</RootNamespace>
<AssemblyName>IllusionSample</AssemblyName>
<Description>Generated IComponent systems running on native Flecs columns (ABI 4.2).</Description>
<!-- The IComponent contract (M2-01) IS public blittable fields plus an
instance Tick(ref Self self, ...): CA1051/CA1822 fire on exactly
that shape, so user-script projects suppress them. -->
<NoWarn>$(NoWarn);CA1051;CA1822</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\managed\Engine.Bindings\Engine.Bindings.csproj" />
<ProjectReference Include="..\..\managed\Engine.Runtime\Engine.Runtime.csproj" />
<ProjectReference Include="..\..\managed\Engine.Analyzers\Engine.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
+195
View File
@@ -0,0 +1,195 @@
// Program.cs — ABI 4.2 Illusion Layer end-to-end gate.
//
// 1. Load engine_core from the absolute path in args[0] and validate the
// ABI table (same checks as the generated ApiLoader).
// 2. Register three GENERATED systems (Mover, Spinner, Bomb), spawn
// entities with Ecs.Set, run 3 frames, and check every row.
// 3. Fault isolation: Bomb throws inside Tick; the frame keeps running,
// Bomb.Faulted turns true, the other systems keep their results.
// 4. Zero-GC: 100,000 Movers, 100 frames, 0 managed bytes allocated.
// Prints ILLUSION-SAMPLE-OK and exits 0 on success; exits 1 on any failure.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using Engine.Bindings;
using Engine.Runtime;
namespace IllusionSample;
internal static unsafe class Program
{
private static int s_failures;
internal static int Main(string[] args)
{
if (args.Length != 1 || !File.Exists(args[0]))
{
Console.Error.WriteLine("usage: IllusionSample <absolute path to the engine_core shared library>");
return 2;
}
try
{
var table = LoadApiTable(args[0]);
CheckLayout();
CheckBehaviour(table);
CheckZeroGc(table);
}
catch (Exception ex)
{
Console.Error.WriteLine($"IllusionSample FAILED: {ex}");
return 1;
}
Console.WriteLine(s_failures == 0 ? "ILLUSION-SAMPLE-OK" : $"IllusionSample: {s_failures} FAILURES");
return s_failures == 0 ? 0 : 1;
}
private static void CheckLayout()
{
Check(Layout.AlignOf<byte>() == 1, "AlignOf<byte> == 1");
Check(Layout.AlignOf<float>() == 4, "AlignOf<float> == 4");
Check(Layout.AlignOf<double>() == 8, "AlignOf<double> == 8");
Check(Layout.AlignOf<Vector128<float>>() == 16, "AlignOf<Vector128<float>> == 16");
Check(Layout.AlignOf<Mover>() == 4 && sizeof(Mover) == 12, "Mover is 12 bytes, align 4");
Check(Mover.ComponentSize == 12, "generated Mover.ComponentSize == 12");
}
private static void CheckBehaviour(NativeApi.ApiTable* table)
{
var world = table->EngineWorldCreate();
Check(world != null, "world_create");
try
{
Check(Mover.Register(table, world, null) == 0, "Mover.Register -> system 0");
Check(Spinner.Register(table, world, null) == 1, "Spinner.Register -> system 1");
Check(Bomb.Register(table, world, null) == 2, "Bomb.Register -> system 2");
const int movers = 1000;
var ids = new Entity[movers];
for (var i = 0; i < movers; i++)
{
ids[i] = Ecs.CreateEntity(table, world);
Ecs.Set(table, world, ids[i], new Mover { Speed = i, X = 0, Y = 0 });
if (i % 2 == 0)
{
Ecs.Set(table, world, ids[i], new Health { Value = 2.0f });
}
}
var spinner = Ecs.CreateEntity(table, world);
Ecs.Set(table, world, spinner, new Spinner { Angle = 0 });
var bombs = new Entity[3];
for (var i = 0; i < bombs.Length; i++)
{
bombs[i] = Ecs.CreateEntity(table, world);
Ecs.Set(table, world, bombs[i], new Bomb { Ticks = 0 });
}
for (var frame = 0; frame < 3; frame++)
{
Ecs.RunSystems(table, world, 0.5f);
}
var moverOk = true;
for (var i = 0; i < movers; i++)
{
var m = Ecs.Get<Mover>(table, world, ids[i]);
var wantX = i % 2 == 0 ? 3.0f : 0.0f;
// Rows without Health do not match the (Mover, Health) system.
var wantY = i % 2 == 0 ? i * 1.5f : 0.0f;
if (m.X != wantX || m.Y != wantY || m.Speed != i)
{
moverOk = false;
Console.Error.WriteLine($"row {i}: X={m.X} Y={m.Y} Speed={m.Speed} (want X={wantX} Y={wantY} Speed={i})");
break;
}
}
Check(moverOk, "Mover: 3 frames x dt 0.5 -> rows with Health X=3 Y=Speed*1.5; rows without untouched");
Check(Ecs.Get<Spinner>(table, world, spinner).Angle == 1.5f, "Spinner: in float dt -> Angle 1.5");
Check(Bomb.Faulted, "Bomb.Faulted after Tick threw");
Check(!Mover.Faulted && !Spinner.Faulted, "Mover/Spinner not faulted");
var bombTicks = string.Join(",", bombs.Select(b => Ecs.Get<Bomb>(table, world, b).Ticks));
Check(bombTicks == "2,1,1", $"Bomb ticks after fault == 2,1,1 (got {bombTicks})");
}
finally
{
table->EngineWorldDestroy(world);
}
}
private static void CheckZeroGc(NativeApi.ApiTable* table)
{
var world = table->EngineWorldCreate();
Check(world != null, "world_create (gc world)");
try
{
Check(Mover.Register(table, world, null) == 0, "Mover.Register on gc world");
for (var i = 0; i < 100_000; i++)
{
var e = Ecs.CreateEntity(table, world);
Ecs.Set(table, world, e, new Mover { Speed = 1 });
Ecs.Set(table, world, e, new Health { Value = 1 });
}
var dispatch = new NativeApi.FrameDispatch { Dt = 1.0f / 60.0f };
for (var i = 0; i < 10; i++)
{
_ = table->EngineSystemsRun(world, &dispatch);
}
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
GC.WaitForPendingFinalizers();
var before = GC.GetTotalAllocatedBytes(precise: true);
var t0 = Stopwatch.GetTimestamp();
var rc = 0;
for (var i = 0; i < 100; i++)
{
rc |= table->EngineSystemsRun(world, &dispatch);
}
var t1 = Stopwatch.GetTimestamp();
var allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
Check(rc == 0, "systems_run x100 returned 0");
Check(allocated == 0, $"0 managed bytes over 100 frames x 100k rows (got {allocated})");
Console.WriteLine($"[info] 100k Mover rows: {(t1 - t0) * 1000.0 / Stopwatch.Frequency / 100:F3} ms/frame");
}
finally
{
table->EngineWorldDestroy(world);
}
}
private static NativeApi.ApiTable* LoadApiTable(string libPath)
{
var handle = NativeLibrary.Load(Path.GetFullPath(libPath));
if (!NativeLibrary.TryGetExport(handle, "engine_get_api_table", out var addr))
{
throw new EntryPointNotFoundException("engine_get_api_table not exported.");
}
var table = ((delegate* unmanaged[Cdecl]<NativeApi.ApiTable*>)addr)();
if (table == null || table->Size < (ulong)sizeof(NativeApi.ApiTable))
{
throw new InvalidOperationException("engine_get_api_table returned null or a table older than the bindings.");
}
if (table->AbiVersionMajor != NativeApi.AbiVersionMajor || table->AbiVersionMinor != NativeApi.AbiVersionMinor)
{
throw new InvalidOperationException($"ABI mismatch: native {table->AbiVersionMajor}.{table->AbiVersionMinor} != managed {NativeApi.AbiVersionMajor}.{NativeApi.AbiVersionMinor}.");
}
Check(sizeof(NativeApi.EngineChunkColumns) == 120, "EngineChunkColumns is 120 bytes");
return table;
}
private static void Check(bool ok, string name)
{
Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
if (!ok)
{
s_failures++;
}
}
}
+106 -6
View File
@@ -58,6 +58,7 @@ internal static class Program
{
private static readonly Regex DefineRe = new(@"^#define\s+(ENGINE_ABI_VERSION_MAJOR|ENGINE_ABI_VERSION_MINOR|ENGINE_ABI_VERSION_PATCH|ENGINE_ABI_COLUMN_STRIDE)\s+(\d+)\s*$", RegexOptions.Compiled);
private static readonly Regex FnTypedefRe = new(@"^typedef\s+(.+?)\s*\(\*(\w+)\)\((.*)\)\s*;\s*$", RegexOptions.Compiled);
private static readonly Regex GenericDefineRe = new(@"^#define\s+ENGINE_ABI_(MAX_TERMS|ACCESS_IN|ACCESS_INOUT|ERR_NULL_ARG|ERR_BAD_ARG|ERR_SIZE_MISMATCH|ERR_NOT_FOUND|ERR_IN_RUN|ERR_INTERNAL)\s+(-?\d+)\s*$", RegexOptions.Compiled);
private static readonly Regex FieldRe = new(@"^([\w\s\*]+?)\s+(\w+)(\s*\[\s*(\d+)\s*\])?\s*$", RegexOptions.Compiled);
internal static int Main(string[] args)
@@ -114,7 +115,8 @@ internal static class Program
var chunk = structs.Single(s => s.Name == "EcsChunkView");
AssertComponentLayout(local, world);
var cs = Emit(header, version, stride, structs, chunk, fns, lines);
var generic = ParseGenericDefines(lines, header);
var cs = Emit(header, version, stride, structs, chunk, fns, lines, generic);
var outPath = Path.Combine(repoRoot, "managed", "Engine.Bindings", "NativeApi.g.cs");
File.WriteAllText(outPath, cs, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
Console.WriteLine($"abi_codegen: {fns.Count} entries -> {outPath}");
@@ -143,6 +145,49 @@ internal static class Program
return new AbiVersion(vals["ENGINE_ABI_VERSION_MAJOR"], vals["ENGINE_ABI_VERSION_MINOR"], vals["ENGINE_ABI_VERSION_PATCH"]);
}
// ABI 4.2: the generic-tail #defines (MAX_TERMS, ACCESS_*, ERR_*). All
// nine are required; emission order is fixed by GenericConstNames.
private static Dictionary<string, int> ParseGenericDefines(string[] lines, string header)
{
var vals = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var line in lines)
{
var m = GenericDefineRe.Match(line.Trim());
if (m.Success)
{
vals[m.Groups[1].Value] = int.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
}
}
foreach (var (key, _) in GenericConstNames)
{
if (!vals.ContainsKey(key))
{
throw new InvalidOperationException($"{header}: missing #define ENGINE_ABI_{key}");
}
}
if (vals["MAX_TERMS"] != 8)
{
throw new InvalidOperationException($"{header}: ENGINE_ABI_MAX_TERMS must be 8 (EngineChunkColumns arrays are [8]).");
}
return vals;
}
private static readonly (string Key, string CsName)[] GenericConstNames =
[
("MAX_TERMS", "MaxTerms"),
("ACCESS_IN", "AccessIn"),
("ACCESS_INOUT", "AccessInOut"),
("ERR_NULL_ARG", "ErrNullArg"),
("ERR_BAD_ARG", "ErrBadArg"),
("ERR_SIZE_MISMATCH", "ErrSizeMismatch"),
("ERR_NOT_FOUND", "ErrNotFound"),
("ERR_IN_RUN", "ErrInRun"),
("ERR_INTERNAL", "ErrInternal"),
];
private static int ParseStride(string[] lines, string header)
{
foreach (var line in lines)
@@ -248,7 +293,7 @@ internal static class Program
structs.Add(new StructDef(name, fields));
}
foreach (var want in new[] { "EngineLocalTransform", "EngineWorldTransform", "EcsChunkView", "FrameDispatch" })
foreach (var want in new[] { "EngineLocalTransform", "EngineWorldTransform", "EcsChunkView", "FrameDispatch", "EngineChunkColumns" })
{
if (structs.All(s => s.Name != want))
{
@@ -350,6 +395,9 @@ internal static class Program
"EngineEditorIsPrefabInstanceFn", "EngineEditorBeginEditFn",
"EngineEditorEndEditFn", "EngineEditorSnapshotTakeFn",
"EngineEditorSnapshotRestoreFn", "EngineEditorSnapshotDiscardFn",
"EngineComponentRegisterFn", "EngineEntityCreateFn",
"EngineEntitySetFn", "EngineEntityGetFn",
"EngineSystemRegisterFn", "EngineSystemsRunFn",
};
foreach (var r in required)
{
@@ -374,7 +422,9 @@ internal static class Program
or "EngineEditorSnapshot*"
or "EngineLocalTransform*"
or "const EcsChunkView*" or "const FrameDispatch*"
or "ManagedSystemCallback" or "EngineRenderPassCallback";
or "ManagedSystemCallback" or "EngineRenderPassCallback"
or "const void*" or "const uint64_t*" or "const uint32_t*"
or "const EngineChunkColumns*" or "EngineSystemCallback";
if (!ok)
{
throw new InvalidOperationException($"{header}:{line}: unsupported ABI type '{ctype}' (extend the generator's type map deliberately).");
@@ -447,6 +497,11 @@ internal static class Program
"const EngineEditorSession*" => "EngineEditorSession*",
"EngineEditorSnapshot*" => "EngineEditorSnapshot*",
"EngineLocalTransform*" => "LocalTransform*",
"const void*" => "void*",
"const uint64_t*" => "ulong*",
"const uint32_t*" => "uint*",
"const EngineChunkColumns*" => "EngineChunkColumns*",
"EngineSystemCallback" => "delegate* unmanaged[Cdecl]<EngineChunkColumns*, float, void>",
_ => throw new InvalidOperationException($"no C# mapping for '{ctype}'"),
};
@@ -466,7 +521,7 @@ internal static class Program
return sb.ToString();
}
private static string Emit(string header, AbiVersion v, int stride, List<StructDef> structs, StructDef chunk, List<FnDef> fns, string[] lines)
private static string Emit(string header, AbiVersion v, int stride, List<StructDef> structs, StructDef chunk, List<FnDef> fns, string[] lines, Dictionary<string, int> generic)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated>");
@@ -500,6 +555,11 @@ internal static class Program
sb.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $" public const int AbiVersionPatch = {v.Patch};");
sb.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $" public const int ColumnStride = {stride};");
sb.AppendLine(" public const string NativeLibraryName = \"engine_core\";");
foreach (var (key, csName) in GenericConstNames)
{
sb.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $" public const int {csName} = {generic[key]};");
}
sb.AppendLine();
var table = structs.Single(s => s.Name == "EngineApiTable");
EmitOpaqueHandles(sb);
@@ -507,6 +567,7 @@ internal static class Program
EmitLocalTransform(sb, structs.Single(s => s.Name == "EngineLocalTransform"));
EmitWorldTransform(sb, structs.Single(s => s.Name == "EngineWorldTransform"));
EmitChunkView(sb, chunk);
EmitChunkColumns(sb, structs.Single(s => s.Name == "EngineChunkColumns"));
EmitCallback(sb, fns, header, lines);
EmitApiTable(sb, table, fns);
EmitLoader(sb);
@@ -596,6 +657,44 @@ internal static class Program
sb.AppendLine();
}
private static void EmitChunkColumns(StringBuilder sb, StructDef cols)
{
// Field list is ABI: fail closed on any drift from the 120-byte shape.
string[] wantNames = ["columns", "sizes", "entities", "count", "term_count", "user_data"];
string[] wantTypes = ["void*", "int32_t", "const uint64_t*", "int32_t", "int32_t", "void*"];
int[] wantArr = [8, 8, 0, 0, 0, 0];
if (cols.Fields.Count != wantNames.Length)
{
throw new InvalidOperationException($"EngineChunkColumns must have {wantNames.Length} fields (got {cols.Fields.Count}).");
}
for (var i = 0; i < wantNames.Length; i++)
{
var f = cols.Fields[i];
if (f.Name != wantNames[i] || f.CType != wantTypes[i] || f.ArrayLen != wantArr[i])
{
throw new InvalidOperationException($"EngineChunkColumns field {i} must be '{wantTypes[i]} {wantNames[i]}{(wantArr[i] > 0 ? $"[{wantArr[i]}]" : string.Empty)}', got '{f.CType} {f.Name}{(f.ArrayLen > 0 ? $"[{f.ArrayLen}]" : string.Empty)}'.");
}
}
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Mirror of EngineChunkColumns (ABI 4.2): one matched table slice for a");
sb.AppendLine(" /// generic system. Columns[i] is the base address of term i's column");
sb.AppendLine(" /// (cast to the term's component pointer); valid only for the callback.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8, Size = 120)]");
sb.AppendLine(" public unsafe struct EngineChunkColumns");
sb.AppendLine(" {");
sb.AppendLine(" public fixed ulong Columns[8];");
sb.AppendLine(" public fixed int Sizes[8];");
sb.AppendLine(" public ulong* Entities;");
sb.AppendLine(" public int Count;");
sb.AppendLine(" public int TermCount;");
sb.AppendLine(" public void* UserData;");
sb.AppendLine(" }");
sb.AppendLine();
}
private static void EmitCallback(StringBuilder sb, List<FnDef> fns, string header, string[] lines)
{
var cb = ParseManagedCallback(lines, header);
@@ -832,6 +931,7 @@ internal static class Program
// EngineRenderPassCallback) — used, just not as table fields.
unused.Remove("ManagedSystemCallback");
unused.Remove("EngineRenderPassCallback");
unused.Remove("EngineSystemCallback");
if (unused.Count != 0)
{
throw new InvalidOperationException($"Fn typedef(s) not used by EngineApiTable: {string.Join(", ", unused)} (remove the typedef or wire it into the table).");
@@ -901,9 +1001,9 @@ internal static class Program
sb.AppendLine(" throw new InvalidOperationException($\"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.\");");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48)");
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120)");
sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)}.\");");
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)}.\");");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" _table = table;");