mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 17:17:01 -10:00
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:
21 files changed
+1781
-238
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;");
|
||||
|
||||
Reference in new issue
Block a user