illusion: ILLU-M7A generator Register emission

This commit is contained in:
hermes-agent committed 2026-10-30 09:18:37 -09:00
1 parent 6a265a3263
commit d70a3c6a25
2 files changed
+136 -235

No files matched your search

+6
View File
@@ -94,3 +94,9 @@ Chunk_Spike_Mover.g.cs(37,65): error CS1503: Argument 3: cannot convert from 'de
- `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.
@@ -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.
@@ -274,13 +275,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;
}
@@ -429,8 +430,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 +509,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 +554,57 @@ 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);
}
// M7A split note: the RunChunk span-unpacking/row-loop body is owned
// by ILLU-M7B (ComponentParams/RowArgs helpers above are already in). This inert stub
// keeps the M7A half compiling with the stable ABI 4.2 signature so the
// Register emission (the CS1503 fix) is verifiable on its own.
private static void EmitChunkRunner(StringBuilder sb, SystemSpec spec, TickSignatureSnapshot tick, string selfFqn)
{
var phases = tick.PhaseNames.Length != 0
@@ -571,241 +614,93 @@ 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();");
sb.AppendLine(" }");
return;
}
EmitColumnSetup(sb, tick, tick.SelfRefKind, selfFqn);
sb.AppendLine(" for (int i = 0; i < count; i++)");
sb.AppendLine(" {");
EmitRowCall(sb, tick);
sb.AppendLine(" }");
sb.AppendLine(" _ = view;");
sb.AppendLine(" _ = dt;");
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;
}
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++;
}
}
private static TickParameterSnapshot? FirstComponent(TickSignatureSnapshot tick)
{
foreach (var p in tick.Parameters)
{
if ((int)p.RefKind != 0 && !IsScalarTickArg(p.TypeName))
{
return p;
list.Add(p);
}
}
return null;
return list;
}
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 (IsFloatScalar(p.TypeName))
{
yield return p.RefKind == TickRefKind.In ? "in dt" : "dt";
continue;
}
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;
}
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);
}
}