[P3-04] Component field metadata (issue #90) (#216)

* [P3-04] Component field metadata (issue #90)

ABI 4.24 tail: EngineFieldDesc + component_describe/field_count/field_at.
Generator emits per-component Describe (all-or-nothing); built-ins
described natively; scene JSON named-field round-trip; v1 format kept.

* [P3-04] dedupe save/load describe resolvers (sonar)

* [P3-04] drop stray scene probe files

---------

Co-authored-by: hermes-agent <hermes-agent@local>
This commit is contained in:
BillyOutlastandhermes-agent authored and GitHub committed 2026-11-04 18:46:59 -05:00
1 parent 67e748c53e
commit 41c8e21893
20 files changed
+1759 -26

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# P3-04 notes — Component field metadata from the generator (#90)
## What landed
- ABI 4.24 tail (append-at-end, minor 4.23 → 4.24): `EngineFieldDesc
{ const char* name; uint32_t offset; uint32_t type }` (type enum 0..10:
f32/i32/u32/bool8/vec2/vec3/vec4/quat/color4/entity/asset-ref; lane sizes
4/4/4/1/8/12/16/16/16/8/8) + `component_describe` (bulk store),
`component_field_count`, `component_field_at` (out-param struct fill).
New entries are plain Cdecl (describe copies strings, may allocate).
- Native store: `GenericSystems::descriptions_` (per-world, name-keyed,
native-owned string copies). describe requires a prior register
(NOT_FOUND otherwise); all-or-nothing validation (empty name, duplicate,
type > 10, offset+lane > size, overlaps → BAD_ARG; count == 0 stores an
empty description; count > 256 → BAD_ARG); re-describe overwrites
(hot-reload). field_count returns count or negative ERR; field_at fills
*out_desc (name aliases native storage) or NOT_FOUND (also OOB).
- Built-ins described automatically in `GenericSystems::register_component`
(same describe() core): LocalTransform (position vec3 @0, rotation quat
@12, scale vec3 @28), Camera (fov_y_rad @0, near_z @4, far_z @8,
is_main u32 @12), MeshRenderer (mesh_id asset-ref @0, base_color color4
@4), DirectionalLight (direction vec3 @0, shadow f32 @12). Size-checked
against the live component (drift fails closed = undescribed).
- Generator: stage-1b picks up every partial IComponent struct with fields
(Tick-less included); stage-2b maps fields all-or-nothing
(float→f32, int→i32, uint→u32, bool→bool8, Vector2/fixed[2]→vec2,
float3/Vector3/fixed[3]→vec3, float4/Vector4/fixed[4]→vec4,
quaternion/Quaternion→quat, Entity→entity, AssetRef<T>→asset-ref;
skips: non-public, backing fields, Nullable, enums, nested structs,
FixedString64, float4x4, long/ulong/double/short/byte, pointers, bad
fixed buffers). Emits `Describe(table, world)` (stackalloc FieldDesc,
Marshal.OffsetOf per field, ComponentName cache for the component name)
next to every Register (runnable calls Describe first, unrunnable calls
Describe then returns -2). Stage-2b always emits exactly one Describe
(real or no-op) so Register always links.
- Ecs.cs: `ComponentName<T>` made public + Utf8 public (generated Describe
reuses the cache — byte-identical to the register name).
- Scene: `save/load_scene_with_descriptions` overloads (resolver callback
over GenericSystems via bridge lambdas; existing save/load delegate to
the null-describe legacy path). Writer emits named fields in description
order when count > 0 else the $bytes blob unchanged (f32/i32/u32 numbers,
bool8 true/false, vec/quat/color4 arrays, entity file-local ids,
asset-ref raw u64). Reader tries $bytes first (legacy loads), then named
(zero-init buffer, unknown keys ignored, absent keys zero, atomic
per-component skip on any field error; named payload for unknown
component → warn+skip, never register with a guessed align; entity lanes
zeroed in pass 1 + fixups applied in pass 2). Version stays 1.
- Codegen: ValidateCType + CsType accept `EngineFieldDesc*` /
`const EngineFieldDesc*` → `FieldDesc*`; emits `FieldDesc` mirror
(16B) + `FieldType` enum; loader pins `sizeof(FieldDesc) == 16`.
## Acceptance
- GeneratorDriverTests: Health { float Current; int Max } produces
Describe with Current→F32, Max→I32, Marshal.OffsetOf calls,
EngineComponentDescribe; null-table Describe → -1.
- Scene round-trip writes `"Engine.Tests.SceneHealth":{"Current":75,"Max":100}`
(dotted FullName contract; the spec's `"Health"` shape holds for a
component actually named Health).
- Native test_scene_io: Health {75.0F, 100} named save → file contains
`"Game.Health":{"Current":75,"Max":100}`, load restores values, re-save
byte-stable.
- FieldMetadataTests: shape (16B, @0/@8/@12, enum 0..10) + live negatives
(NOT_FOUND/NULL_ARG/BAD_ARG/OOB/re-describe) + Current f32 @0 / Max i32
@4 readback.
## Gotchas
- `ComponentName<T>` was private: fixture assemblies compile against
Engine.Runtime as a binary (no InternalsVisibleTo), so Describe must use
a public cache. Made public deliberately.
- Unrunnable Register(null,null,null) must return -2 (existing test): guard
Describe with `if (table != null && world != null)`, don't change the -2.
- CheckMover hand-built Health as 4B; the new 2-field Health is 8B —
stackalloc byte[rows*8] + pointer writes.
- Bridge resolvers: thread_local staging vectors; the writer/reader consume
rows synchronously within describe(), so reuse is safe.
- `std::memcpy` needs `<cstring>` in scene_json_read.cpp; `std::function`
needs `<functional>` in the writer.
- Fixed-buffer mapping happens at IFieldSymbol level (IsFixedSizeBuffer +
FixedSize), not in MapFieldType (which only sees the element type).
## P3-05 handoff
- Any described IComponent is inspectable via component_field_count/at +
entity_get/set whole-struct read-modify-write. Undescribed components
stay $bytes and are inspector-opaque. Type→control map: f32/i32/u32→
NumericUpDown, bool8→CheckBox, vec3→3×NumericUpDown, quat→Euler degrees,
color4→ColorPicker, entity→ComboBox, asset-ref→TextBox+drop.
@@ -66,12 +66,29 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
transform: static (ctx, ct) => ToStructRef(ctx, ct))
.Where(static r => r is not null);
// Stage 1b (syntax only, cheap): every partial struct that COULD be
// an IComponent (field-bearing candidates for Describe emission).
// Tick-less components never reach IsSystemCandidate, so they need
// their own track. Semantics resolve in stage 2.
var describeStructs = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => IsDescribeCandidate(node),
transform: static (ctx, ct) => ToStructRef(ctx, ct))
.Where(static r => r is not null);
// Stage 2 (symbols, cached per struct): resolve IComponent + Ticks.
var systems = context.CompilationProvider
.Combine(structs.Collect())
.SelectMany(static (pair, ct) => ResolveAll(pair.Left, pair.Right, ct));
context.RegisterSourceOutput(systems, static (spc, spec) => EmitOne(spc, spec));
// Stage 2b: resolve IComponent + instance fields for Describe.
var describes = context.CompilationProvider
.Combine(describeStructs.Collect())
.SelectMany(static (pair, ct) => ResolveAllDescribes(pair.Left, pair.Right, ct));
context.RegisterSourceOutput(describes, static (spc, spec) => EmitDescribe(spc, spec));
}
// --- Stage 1: syntax ---------------------------------------------------
@@ -102,6 +119,21 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
return false;
}
private static bool IsDescribeCandidate(SyntaxNode node)
{
// Any partial struct is a potential Describe host: field-less
// Tick-only components (e.g. FrameHost) still get a Describe call
// in Register, so they need the no-op Describe emitted. Semantics
// (IComponent conformance, field mapping) resolve in stage 2b;
// syntax only checks shape.
if (node is not StructDeclarationSyntax s)
{
return false;
}
return s.Modifiers.Any(SyntaxKind.PartialKeyword);
}
private static string? ToStructRef(GeneratorSyntaxContext ctx, CancellationToken ct)
{
// Freeze the fully-qualified metadata name as the cross-stage key.
@@ -273,6 +305,275 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
ReadPhases(sym));
}
// --- Stage 2b: Describe resolution -------------------------------------
private static ImmutableArray<DescribeSpec> ResolveAllDescribes(
Compilation compilation,
ImmutableArray<string?> refs,
CancellationToken ct)
{
var iComponent = compilation.GetTypeByMetadataName(IComponentMetadataName);
if (iComponent is null)
{
return ImmutableArray<DescribeSpec>.Empty;
}
var float3 = ResolveByName(compilation, "global::Engine.Runtime.float3");
var float4 = ResolveByName(compilation, "global::Engine.Runtime.float4");
var quaternion = ResolveByName(compilation, "global::Engine.Runtime.quaternion");
var entity = ResolveByName(compilation, "global::Engine.Runtime.Entity");
var assetRef = compilation.GetTypeByMetadataName("Engine.Runtime.AssetRef`1");
var vec2 = ResolveByName(compilation, "global::System.Numerics.Vector2");
var vec3 = ResolveByName(compilation, "global::System.Numerics.Vector3");
var vec4 = ResolveByName(compilation, "global::System.Numerics.Vector4");
var sysQuat = ResolveByName(compilation, "global::System.Numerics.Quaternion");
var builder = ImmutableArray.CreateBuilder<DescribeSpec>();
var seen = new HashSet<string>();
foreach (var name in refs)
{
ct.ThrowIfCancellationRequested();
if (name is null || !seen.Add(name))
{
continue;
}
var spec = ResolveDescribe(compilation, iComponent, name, ct,
float3, float4, quaternion, entity, assetRef, vec2, vec3, vec4, sysQuat);
if (spec is not null)
{
builder.Add(spec);
}
}
return builder.ToImmutable();
}
private static DescribeSpec? ResolveDescribe(
Compilation compilation,
INamedTypeSymbol iComponent,
string fullyQualifiedName,
CancellationToken ct,
INamedTypeSymbol? float3,
INamedTypeSymbol? float4,
INamedTypeSymbol? quaternion,
INamedTypeSymbol? entity,
INamedTypeSymbol? assetRef,
INamedTypeSymbol? vec2,
INamedTypeSymbol? vec3,
INamedTypeSymbol? vec4,
INamedTypeSymbol? sysQuat)
{
var sym = ResolveByName(compilation, fullyQualifiedName);
if (sym is null)
{
return null;
}
if (!Implements(sym, iComponent))
{
return null;
}
// Same redeclaration contract as ResolveOne: public, partial,
// non-generic. Describe needs sizeof(T), so open generics are out.
if (sym.IsGenericType || !IsPartial(sym, ct)
|| sym.DeclaredAccessibility != Accessibility.Public)
{
return null;
}
for (var c = sym.ContainingType; c is not null; c = c.ContainingType)
{
if (!IsPartial(c, ct))
{
return null;
}
}
if (!sym.IsValueType)
{
return null;
}
// Snapshot instance fields in declaration order (sort by source
// position; GetMembers order is not declaration order). Skip
// statics/consts (they are not instance data).
var fields = sym.GetMembers()
.OfType<IFieldSymbol>()
.Where(static f => !f.IsStatic && !f.IsConst)
.OrderBy(static f => f.DeclaringSyntaxReferences
.Select(static r => r.Span.Start).DefaultIfEmpty(int.MaxValue).First())
.ToList();
// All-or-nothing: any unmapped field kills the whole component.
var mapped = ImmutableArray.CreateBuilder<DescribeFieldSnapshot>();
foreach (var f in fields)
{
ct.ThrowIfCancellationRequested();
// Non-public fields: skip => undescribed. Auto-property
// backing fields (<X>k__BackingField / CompilerGenerated):
// skip => undescribed (properties are not lanes).
if (f.DeclaredAccessibility != Accessibility.Public ||
f.IsImplicitlyDeclared)
{
return null;
}
// readonly instance fields are INCLUDED (scene writes raw
// memory; P3-05 may render them read-only).
uint? descType;
if (f.IsFixedSizeBuffer)
{
// `public fixed float BaseColor[4]`: element must be
// float and size in {2,3,4} => vec{2,3,4}. Anything else
// fixed => undescribed. Roslyn surfaces the size via
// FixedSize and the element via the field type.
descType = (f.Type.SpecialType == SpecialType.System_Single &&
(f.FixedSize == 2 || f.FixedSize == 3 || f.FixedSize == 4))
? (uint?)(f.FixedSize == 2 ? 4u : f.FixedSize == 3 ? 5u : 6u)
: null;
}
else
{
descType = MapFieldType(f.Type, ct,
float3, float4, quaternion, entity, assetRef, vec2, vec3, vec4, sysQuat);
}
if (descType is null)
{
return null;
}
mapped.Add(new DescribeFieldSnapshot(f.Name, descType.Value));
}
// Zero fields: legal (stores an empty description). The writer
// treats count == 0 as undescribed ($bytes path).
var containing = ImmutableArray.CreateBuilder<ContainingTypeSnapshot>();
for (var c = sym.ContainingType; c is not null; c = c.ContainingType)
{
containing.Add(SnapshotContaining(c));
}
return new DescribeSpec(
sym.ContainingNamespace.IsGlobalNamespace
? string.Empty
: sym.ContainingNamespace.ToDisplayString(),
sym.Name,
containing.ToImmutable(),
mapped.ToImmutable());
}
// Map one field type to an ENGINE_FIELD_* lane. Null = unmapped
// (whole component undescribed). Fixed float[N] (N in {2,3,4}) maps
// to vecN; anything else fixed => null.
private static uint? MapFieldType(
ITypeSymbol type,
CancellationToken ct,
INamedTypeSymbol? float3,
INamedTypeSymbol? float4,
INamedTypeSymbol? quaternion,
INamedTypeSymbol? entity,
INamedTypeSymbol? assetRef,
INamedTypeSymbol? vec2,
INamedTypeSymbol? vec3,
INamedTypeSymbol? vec4,
INamedTypeSymbol? sysQuat)
{
ct.ThrowIfCancellationRequested();
// Nullable<T>: skipped (no lane).
if (type is INamedTypeSymbol named &&
named.IsGenericType &&
named.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T)
{
return null;
}
// Enums: skipped even with integral underlying type (conservative).
if (type.TypeKind == TypeKind.Enum)
{
return null;
}
// fixed float[N]: IFieldSymbol.FixedSize + element type float.
if (type is IPointerTypeSymbol)
{
return null;
}
var special = type.SpecialType;
if (special == SpecialType.System_Single)
{
return 0;
}
if (special == SpecialType.System_Int32)
{
return 1;
}
if (special == SpecialType.System_UInt32)
{
return 2;
}
if (special == SpecialType.System_Boolean)
{
return 3;
}
// Named-type lanes (compare by symbol identity where resolved).
if (type is INamedTypeSymbol nt)
{
if ((vec2 is not null && SymbolEqualityComparer.Default.Equals(nt, vec2)) ||
IsFixedFloatBuffer(nt, 2))
{
return 4;
}
if (((float3 is not null && SymbolEqualityComparer.Default.Equals(nt, float3)) ||
(vec3 is not null && SymbolEqualityComparer.Default.Equals(nt, vec3))) ||
IsFixedFloatBuffer(nt, 3))
{
return 5;
}
if (((float4 is not null && SymbolEqualityComparer.Default.Equals(nt, float4)) ||
(vec4 is not null && SymbolEqualityComparer.Default.Equals(nt, vec4))) ||
IsFixedFloatBuffer(nt, 4))
{
return 6;
}
if ((quaternion is not null && SymbolEqualityComparer.Default.Equals(nt, quaternion)) ||
(sysQuat is not null && SymbolEqualityComparer.Default.Equals(nt, sysQuat)))
{
return 7;
}
if (entity is not null && SymbolEqualityComparer.Default.Equals(nt, entity))
{
return 9;
}
if (assetRef is not null && nt.IsGenericType &&
SymbolEqualityComparer.Default.Equals(nt.ConstructedFrom, assetRef))
{
return 10;
}
}
return null;
}
private static bool IsFixedFloatBuffer(ITypeSymbol type, int size)
{
// Fixed buffers surface as the element type with IsFixedSizeBuffer
// on the FIELD, not the type — this helper only handles named
// vector structs. True fixed-buffer detection happens at the field
// level (see ResolveDescribe note). Kept for struct-shape vectors.
return false;
}
private static TickRefKind SelfRefKind(ImmutableArray<TickParameterSnapshot>.Builder pars)
{
// The column element doubles as receiver AND self arg: its ref-ness
@@ -591,8 +892,15 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
sb.AppendLine(" /// </summary>");
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(" if (table != null && world != null)");
sb.AppendLine(" {");
sb.AppendLine(" int describeRc = Describe(table, world);");
sb.AppendLine(" if (describeRc != 0)");
sb.AppendLine(" {");
sb.AppendLine(" return describeRc;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" _ = userData;");
sb.AppendLine(" return -2;");
sb.AppendLine(" }");
@@ -615,6 +923,12 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
sb.AppendLine(" return -1;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" int describeRc = Describe(table, world);");
sb.AppendLine(" if (describeRc != 0)");
sb.AppendLine(" {");
sb.AppendLine(" return describeRc;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine($" ulong* ids = stackalloc ulong[{components.Count}];");
sb.AppendLine($" uint* access = stackalloc uint[{components.Count}];");
for (var k = 0; k < components.Count; k++)
@@ -739,6 +1053,119 @@ public sealed class ComponentSystemGenerator : IIncrementalGenerator
sb.AppendLine(" }");
}
// --- Stage 3b: Describe emit (P3-04) ------------------------------------
//
// Ownership rule: stage-2b ALWAYS emits exactly one Describe per
// resolved IComponent struct (real body when every field maps,
// no-op returning 0 otherwise). The Tick pipeline NEVER emits
// Describe — it only CALLS it from Register. One definition, no
// CS0111 collision; Register always links.
// Shared Describe body emitter. selfFqn is the global:: dotted name.
private static void EmitDescribeBody(StringBuilder sb, DescribeSpec describe, string selfFqn)
{
sb.AppendLine(" public static unsafe int Describe(NativeApi.ApiTable* table, NativeApi.EngineWorld* world)");
sb.AppendLine(" {");
if (describe.Fields.Length == 0)
{
sb.AppendLine(" _ = table;");
sb.AppendLine(" _ = world;");
sb.AppendLine(" return 0;");
sb.AppendLine(" }");
return;
}
sb.AppendLine(" if (table == null || world == null)");
sb.AppendLine(" {");
sb.AppendLine(" return -1;");
sb.AppendLine(" }");
sb.AppendLine($" _ = global::Engine.Runtime.Ecs.ComponentId<{selfFqn}>(table, world);");
sb.AppendLine($" NativeApi.FieldDesc* fields = stackalloc NativeApi.FieldDesc[{describe.Fields.Length}];");
for (var k = 0; k < describe.Fields.Length; k++)
{
var f = describe.Fields[k];
var csType = f.Type switch
{
0 => "F32",
1 => "I32",
2 => "U32",
3 => "Bool8",
4 => "Vec2",
5 => "Vec3",
6 => "Vec4",
7 => "Quat",
8 => "Color4",
9 => "Entity",
_ => "AssetRef",
};
sb.Append(" fixed (byte* f" + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + " = \"" + f.Name + "\\0\"u8) { fields[" + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + "].Name = f" + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + "; fields[" + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + "].Offset = (uint)global::System.Runtime.InteropServices.Marshal.OffsetOf<" + selfFqn + ">(\"" + f.Name + "\"); fields[" + k.ToString(System.Globalization.CultureInfo.InvariantCulture) + "].Type = (uint)NativeApi.FieldType." + csType + "; }\n");
}
sb.AppendLine(" fixed (byte* comp = global::Engine.Runtime.Ecs.ComponentName<" + selfFqn + ">.Utf8)");
sb.AppendLine(" {");
sb.AppendLine($" return table->EngineComponentDescribe(world, comp, fields, {describe.Fields.Length}u);");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
// Stage-2b source output: one partial per IComponent struct carrying
// the Describe method (real or no-op). Hint names use Describe_ prefix
// so they never collide with Chunk_ outputs.
private static void EmitDescribe(SourceProductionContext spc, DescribeSpec spec)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("// Engine.Analyzers (P3-04): IComponent field metadata.");
sb.AppendLine("// Matches NativeApi.FieldDesc + EngineComponentDescribe.");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using System.Runtime.InteropServices;");
sb.AppendLine("using Engine.Bindings;");
sb.AppendLine();
if (spec.Namespace.Length != 0)
{
sb.AppendLine($"namespace {spec.Namespace};");
sb.AppendLine();
}
foreach (var outer in spec.ContainingTypes.Reverse())
{
sb.AppendLine($" partial {outer.Keyword} {outer.Name}{outer.TypeArgs}");
sb.AppendLine(" {");
}
sb.AppendLine($" public partial struct {spec.StructName}");
sb.AppendLine(" {");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Describes this component's fields to the native world");
sb.AppendLine(" /// (component_describe). All-or-nothing: when any field is");
sb.AppendLine(" /// not understood, this is a no-op returning 0 (component");
sb.AppendLine(" /// stays $bytes). Offsets come from Marshal.OffsetOf.");
sb.AppendLine(" /// </summary>");
var outerPath = string.Join(".", spec.ContainingTypes.Reverse().Select(static c => c.Name + c.TypeArgs));
var selfFqn = (spec.Namespace.Length == 0 ? "global::" : "global::" + spec.Namespace + ".")
+ (spec.ContainingTypes.Length == 0 ? string.Empty : outerPath + ".")
+ spec.StructName;
EmitDescribeBody(sb, spec, selfFqn);
sb.AppendLine(" }");
for (var i = 0; i < spec.ContainingTypes.Length; i++)
{
sb.AppendLine(" }");
}
var hint = "Describe_" + spec.Namespace.Replace(".", "_") + "_" +
string.Join("_", spec.ContainingTypes.Reverse().Select(static c => c.Name)) +
(spec.ContainingTypes.Length == 0 ? string.Empty : "_") +
spec.StructName + ".g.cs";
foreach (var ch in Path.GetInvalidFileNameChars())
{
hint = hint.Replace(ch, '_');
}
spc.AddSource(hint.TrimStart('_'), sb.ToString());
}
// 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)
+10
View File
@@ -50,6 +50,16 @@ internal sealed record ContainingTypeSnapshot(
string TypeArgs);
/// <summary>One system struct: identity + resolved Tick overloads.</summary>
/// <summary>One described field: C# name + ENGINE_FIELD_* lane.</summary>
internal sealed record DescribeFieldSnapshot(string Name, uint Type);
/// <summary>One component struct: identity + mapped fields (all-or-nothing).</summary>
internal sealed record DescribeSpec(
string Namespace,
string StructName,
ImmutableArray<ContainingTypeSnapshot> ContainingTypes,
ImmutableArray<DescribeFieldSnapshot> Fields);
internal sealed record SystemSpec(
string Namespace,
string StructName,
+32 -4
View File
@@ -1,7 +1,7 @@
// <auto-generated>
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
// ABI 4.23.0, column stride 64.
// ABI 4.24.0, column stride 64.
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
// </auto-generated>
#nullable enable
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
public static unsafe class NativeApi
{
public const int AbiVersionMajor = 4;
public const int AbiVersionMinor = 23;
public const int AbiVersionMinor = 24;
public const int AbiVersionPatch = 0;
public const int ColumnStride = 64;
public const string NativeLibraryName = "engine_core";
@@ -123,6 +123,31 @@ public static unsafe class NativeApi
public void* Handle;
}
/// <summary>Mirror of EngineFieldDesc: one described component field, 16 bytes (P3-04).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8, Size = 16)]
public unsafe struct FieldDesc
{
public byte* Name;
public uint Offset;
public uint Type;
}
/// <summary>Field type enum (ENGINE_FIELD_*): 0 f32, 1 i32, 2 u32, 3 bool8, 4 vec2, 5 vec3, 6 vec4, 7 quat, 8 color4, 9 entity, 10 asset-ref.</summary>
public enum FieldType : uint
{
F32 = 0,
I32 = 1,
U32 = 2,
Bool8 = 3,
Vec2 = 4,
Vec3 = 5,
Vec4 = 6,
Quat = 7,
Color4 = 8,
Entity = 9,
AssetRef = 10,
}
/// <summary>Opaque native physics world handle (caller-owned engine::physics::PhysicsWorld, not the bare JPH::PhysicsSystem).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8)]
public unsafe struct EnginePhysicsWorld
@@ -609,6 +634,9 @@ public static unsafe class NativeApi
public delegate* unmanaged[Cdecl]<EngineAnimator*, byte*, int> EngineAnimatorSetTrigger;
public delegate* unmanaged[Cdecl]<EngineAnimator*, float, int> EngineAnimatorUpdate;
public delegate* unmanaged[Cdecl]<EngineAnimator*, byte*, int, int> EngineAnimatorStateName;
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, FieldDesc*, uint, int> EngineComponentDescribe;
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineComponentFieldCount;
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, uint, FieldDesc*, int> EngineComponentFieldAt;
}
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
@@ -657,9 +685,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 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64)
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16)
{
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)}.");
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} FieldDesc={sizeof(FieldDesc)}.");
}
_table = table;
+7 -2
View File
@@ -104,11 +104,16 @@ public static unsafe class Ecs
}
}
private static class ComponentName<T>
/// <summary>
/// Cached UTF-8 NUL-terminated FullName bytes for <typeparamref name="T"/>.
/// Used by generated Describe methods so the describe name is
/// byte-identical to the register name (never re-spell the name).
/// </summary>
public 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");
public static readonly byte[] Utf8 = Encoding.UTF8.GetBytes(typeof(T).FullName + "\0");
}
}
+13 -2
View File
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
{
var failures = 0;
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
failures += Check(NativeApi.AbiVersionMinor == 23, "abi minor == 23 (4.23: P5-03 animator tail after 4.22 playback)");
failures += Check(NativeApi.AbiVersionMinor == 24, "abi minor == 24 (4.24: P3-04 field-metadata tail after 4.23 animator)");
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
@@ -36,7 +36,7 @@ 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) == 1256, "ApiTable 1256B (ABI 4.23: +7 animator entries after 4.22 playback tail)");
failures += Check(sizeof(NativeApi.ApiTable) == 1280, "ApiTable 1280B (ABI 4.24: +3 field-metadata entries after 4.23 animator tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 672, "editor_destroy_entity @ 672 (ABI 4.10 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 704, "snapshot_restore_subtree @ 704 (ABI 4.10 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAudioCreate").ToInt64() == 712, "audio_create @ 712 (ABI 4.11 tail)");
@@ -75,6 +75,14 @@ public static class AbiLayoutTests
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAnimatorSetTrigger").ToInt64() == 1232, "animator_set_trigger @ 1232 (ABI 4.23 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAnimatorUpdate").ToInt64() == 1240, "animator_update @ 1240 (ABI 4.23 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAnimatorStateName").ToInt64() == 1248, "animator_state_name @ 1248 (ABI 4.23 tail end)");
// P3-04 field-metadata tail (ABI 4.24, appended after 4.23 animator tail).
failures += Check(sizeof(NativeApi.FieldDesc) == 16, "FieldDesc 16B (P3-04 field row)");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Name").ToInt64() == 0, "FieldDesc.Name @ 0");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Offset").ToInt64() == 8, "FieldDesc.Offset @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Type").ToInt64() == 12, "FieldDesc.Type @ 12");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentDescribe").ToInt64() == 1256, "component_describe @ 1256 (ABI 4.24 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentFieldCount").ToInt64() == 1264, "component_field_count @ 1264 (ABI 4.24 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentFieldAt").ToInt64() == 1272, "component_field_at @ 1272 (ABI 4.24 tail end)");
failures += Check(sizeof(NativeApi.EngineTimelinePlayer) == 8, "EngineTimelinePlayer opaque 8B");
failures += Check(sizeof(NativeApi.TimelineActiveClip) == 16, "TimelineActiveClip 16B (P5-07 active row)");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineActiveClip>("Clip").ToInt64() == 4, "TimelineActiveClip.Clip @ 4");
@@ -174,6 +182,9 @@ public static class AbiLayoutTests
failures += Check(src.Contains("unmanaged[Cdecl]<EngineAnimator*, byte*, int> EngineAnimatorSetTrigger", StringComparison.Ordinal), "animator_set_trigger present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineAnimator*, float, int> EngineAnimatorUpdate", StringComparison.Ordinal), "animator_update present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineAnimator*, byte*, int, int> EngineAnimatorStateName", StringComparison.Ordinal), "animator_state_name present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, FieldDesc*, uint, int> EngineComponentDescribe", StringComparison.Ordinal), "component_describe present, plain Cdecl (copies strings, may allocate)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineComponentFieldCount", StringComparison.Ordinal), "component_field_count present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, uint, FieldDesc*, int> EngineComponentFieldAt", StringComparison.Ordinal), "component_field_at present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineRenderer*, EngineUi*, int> EngineRendererAttachUi", StringComparison.Ordinal), "renderer_attach_ui present, plain Cdecl (frame submit may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimeline*> EngineTimelineCreate", StringComparison.Ordinal), "timeline_create present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerPlay", StringComparison.Ordinal), "timeline_player_play present, plain Cdecl");
+211
View File
@@ -0,0 +1,211 @@
// FieldMetadataTests.cs — P3-04: component field-metadata contract gate.
//
// Type-shape checks (no native needed): FieldDesc mirror (16B, Name @0,
// Offset @8, Type @12), FieldType enum (F32=0..AssetRef=10), and the three
// ABI 4.24 tail entries (component_describe/field_count/field_at).
// Live negative tests (skipped when engine_core is unloadable):
// describe-before-register -> NOT_FOUND, type 11 -> BAD_ARG, offset past
// size -> BAD_ARG, nulls -> NULL_ARG, field_at OOB -> NOT_FOUND,
// re-describe overwrite works, and a Health-shaped component round-trips
// Current f32 @0 / Max i32 @4 through field_at.
using System.Runtime.InteropServices;
using Engine.Bindings;
using Engine.Runtime;
namespace Engine.Tests;
public static unsafe class FieldMetadataTests
{
public static int Run(string[]? args = null)
{
var failures = 0;
// --- Type-shape contract (no native needed) ----------------------
failures += Check(sizeof(NativeApi.FieldDesc) == 16, "FieldDesc 16B");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Name").ToInt64() == 0, "FieldDesc.Name @ 0");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Offset").ToInt64() == 8, "FieldDesc.Offset @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.FieldDesc>("Type").ToInt64() == 12, "FieldDesc.Type @ 12");
failures += Check((uint)NativeApi.FieldType.F32 == 0, "FieldType.F32 == 0");
failures += Check((uint)NativeApi.FieldType.I32 == 1, "FieldType.I32 == 1");
failures += Check((uint)NativeApi.FieldType.U32 == 2, "FieldType.U32 == 2");
failures += Check((uint)NativeApi.FieldType.Bool8 == 3, "FieldType.Bool8 == 3");
failures += Check((uint)NativeApi.FieldType.Vec2 == 4, "FieldType.Vec2 == 4");
failures += Check((uint)NativeApi.FieldType.Vec3 == 5, "FieldType.Vec3 == 5");
failures += Check((uint)NativeApi.FieldType.Vec4 == 6, "FieldType.Vec4 == 6");
failures += Check((uint)NativeApi.FieldType.Quat == 7, "FieldType.Quat == 7");
failures += Check((uint)NativeApi.FieldType.Color4 == 8, "FieldType.Color4 == 8");
failures += Check((uint)NativeApi.FieldType.Entity == 9, "FieldType.Entity == 9");
failures += Check((uint)NativeApi.FieldType.AssetRef == 10, "FieldType.AssetRef == 10");
var fields = typeof(NativeApi.ApiTable).GetFields().Select(f => f.Name).ToArray();
foreach (var want in new[] { "EngineComponentDescribe", "EngineComponentFieldCount", "EngineComponentFieldAt" })
{
failures += Check(fields.Contains(want), $"ApiTable.{want} exists");
}
var liveProbe = (args ?? []).Contains("--live-native-probe");
NativeApi.ApiTable* table;
try
{
var preloaded = EngineCoreLocator.TryLoad();
if (preloaded != IntPtr.Zero)
{
Physics.TableOverride = (IntPtr)EngineCoreLocator.LoadTable(preloaded);
table = (NativeApi.ApiTable*)Physics.TableOverride;
}
else
{
table = NativeApi.ApiLoader.Load();
}
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException || ex is InvalidOperationException)
{
if (liveProbe)
{
failures += Check(false, $"live probe FAILED to load native lib: {ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"FieldMetadataTests: {failures} FAILED");
return failures;
}
Console.WriteLine($"[SKIP] field metadata live (no native lib: {ex.GetType().Name})");
Console.WriteLine(failures == 0 ? "FieldMetadataTests: all checks passed" : $"FieldMetadataTests: {failures} FAILED");
return failures;
}
try
{
failures += RunLive(table);
}
catch (Exception ex)
{
failures += Check(false, $"field metadata live threw: {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine(failures == 0 ? "FieldMetadataTests: all checks passed" : $"FieldMetadataTests: {failures} FAILED");
return failures;
}
private static unsafe int RunLive(NativeApi.ApiTable* table)
{
var failures = 0;
failures += Check(table->EngineComponentDescribe != null, "live: component_describe non-null");
failures += Check(table->EngineComponentFieldCount != null, "live: component_field_count non-null");
failures += Check(table->EngineComponentFieldAt != null, "live: component_field_at non-null");
if (table->EngineComponentDescribe == null || table->EngineComponentFieldCount == null || table->EngineComponentFieldAt == null)
{
return failures;
}
var world = table->EngineWorldCreate();
failures += Check(world != null, "live: world create");
if (world == null)
{
return failures;
}
try
{
// describe-before-register -> NOT_FOUND.
fixed (byte* ghost = "Ghost.Component\0"u8)
{
failures += Check(table->EngineComponentDescribe(world, ghost, null, 0) == NativeApi.ErrNotFound, "live: describe-before-register -> NOT_FOUND");
failures += Check(table->EngineComponentFieldCount(world, ghost) == NativeApi.ErrNotFound, "live: field_count unknown -> NOT_FOUND");
var outDesc = default(NativeApi.FieldDesc);
failures += Check(table->EngineComponentFieldAt(world, ghost, 0, &outDesc) == NativeApi.ErrNotFound, "live: field_at unknown -> NOT_FOUND");
}
// Register a Health-shaped component (float Current + int Max = 8B).
var compId = Ecs.ComponentId<LiveHealth>(table, world);
failures += Check(compId != 0, "live: LiveHealth registered");
fixed (byte* name = Ecs.ComponentName<LiveHealth>.Utf8)
{
// Null-table paths: field_count/field_at on null world -> NULL_ARG.
failures += Check(table->EngineComponentFieldCount(null, name) == NativeApi.ErrNullArg, "live: field_count null world -> NULL_ARG");
// Registered but undescribed: count 0 (plan §3.2 rule 7).
failures += Check(table->EngineComponentFieldCount(world, name) == 0, "live: field_count undescribed == 0");
// field_at undescribed -> NOT_FOUND.
var outDesc = default(NativeApi.FieldDesc);
failures += Check(table->EngineComponentFieldAt(world, name, 0, &outDesc) == NativeApi.ErrNotFound, "live: field_at undescribed -> NOT_FOUND");
// Null out_desc -> NULL_ARG.
failures += Check(table->EngineComponentFieldAt(world, name, 0, null) == NativeApi.ErrNullArg, "live: field_at null out -> NULL_ARG");
// Null fields with count > 0 -> NULL_ARG.
failures += Check(table->EngineComponentDescribe(world, name, null, 2) == NativeApi.ErrNullArg, "live: describe null fields -> NULL_ARG");
// Bad type (11) -> BAD_ARG.
var bad = stackalloc NativeApi.FieldDesc[1];
fixed (byte* fn = "X\0"u8)
{
bad[0].Name = fn;
bad[0].Offset = 0;
bad[0].Type = 11;
failures += Check(table->EngineComponentDescribe(world, name, bad, 1) == NativeApi.ErrBadArg, "live: describe type 11 -> BAD_ARG");
}
// Offset past size -> BAD_ARG.
fixed (byte* fn2 = "Y\0"u8)
{
bad[0].Name = fn2;
bad[0].Offset = 8;
bad[0].Type = (uint)NativeApi.FieldType.F32;
failures += Check(table->EngineComponentDescribe(world, name, bad, 1) == NativeApi.ErrBadArg, "live: describe offset past size -> BAD_ARG");
}
// Real describe: Current f32 @0, Max i32 @4.
var ok = stackalloc NativeApi.FieldDesc[2];
fixed (byte* f0 = "Current\0"u8)
fixed (byte* f1 = "Max\0"u8)
{
ok[0].Name = f0;
ok[0].Offset = (uint)Marshal.OffsetOf<LiveHealth>("Current");
ok[0].Type = (uint)NativeApi.FieldType.F32;
ok[1].Name = f1;
ok[1].Offset = (uint)Marshal.OffsetOf<LiveHealth>("Max");
ok[1].Type = (uint)NativeApi.FieldType.I32;
failures += Check(table->EngineComponentDescribe(world, name, ok, 2) == 0, "live: describe Health ok");
}
failures += Check(table->EngineComponentFieldCount(world, name) == 2, "live: field_count == 2");
// Read back through field_at: Current f32 @0, Max i32 @4.
var d0 = default(NativeApi.FieldDesc);
var d1 = default(NativeApi.FieldDesc);
failures += Check(table->EngineComponentFieldAt(world, name, 0, &d0) == 0, "live: field_at 0 ok");
failures += Check(table->EngineComponentFieldAt(world, name, 1, &d1) == 0, "live: field_at 1 ok");
failures += Check(d0.Offset == 0 && d0.Type == (uint)NativeApi.FieldType.F32, "live: Current f32 @0");
failures += Check(d1.Offset == 4 && d1.Type == (uint)NativeApi.FieldType.I32, "live: Max i32 @4");
failures += Check(Marshal.PtrToStringUTF8((IntPtr)d0.Name) == "Current", "live: field_at 0 name Current");
failures += Check(Marshal.PtrToStringUTF8((IntPtr)d1.Name) == "Max", "live: field_at 1 name Max");
// OOB -> NOT_FOUND.
var oob = default(NativeApi.FieldDesc);
failures += Check(table->EngineComponentFieldAt(world, name, 2, &oob) == NativeApi.ErrNotFound, "live: field_at OOB -> NOT_FOUND");
// Re-describe overwrite: single-field version sticks.
fixed (byte* f0b = "Current\0"u8)
{
ok[0].Name = f0b;
failures += Check(table->EngineComponentDescribe(world, name, ok, 1) == 0, "live: re-describe ok");
}
failures += Check(table->EngineComponentFieldCount(world, name) == 1, "live: field_count == 1 after re-describe");
}
}
finally
{
table->EngineWorldDestroy(world);
}
return failures;
}
private static int Check(bool ok, string name)
{
Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
return ok ? 0 : 1;
}
}
/// <summary>P3-04 live-test component: Health { float Current; int Max } (8B).</summary>
#pragma warning disable CA1051 // Blittable interop struct by design.
public struct LiveHealth : Engine.Runtime.IComponent
{
public float Current;
public int Max;
}
#pragma warning restore CA1051
+22 -6
View File
@@ -21,7 +21,8 @@ public static unsafe class GeneratorDriverTests
public partial struct Health : IComponent
{
public float Value;
public float Current;
public int Max;
}
public partial struct Mover : IComponent
@@ -32,7 +33,7 @@ public static unsafe class GeneratorDriverTests
public void Tick(ref Mover self, in Health health, float dt)
{
self.X += health.Value * dt;
self.X += health.Current * dt;
self.Y += self.Speed * dt;
}
}
@@ -104,12 +105,19 @@ public static unsafe class GeneratorDriverTests
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");
// P3-04 acceptance: Health Describe covers Current f32 @0, Max i32 @4.
failures += Check(generated.Contains("public static unsafe int Describe(NativeApi.ApiTable* table, NativeApi.EngineWorld* world)", StringComparison.Ordinal), "Describe method emitted");
failures += Check(generated.Contains("FieldType.F32", StringComparison.Ordinal), "Describe maps float -> F32");
failures += Check(generated.Contains("FieldType.I32", StringComparison.Ordinal), "Describe maps int -> I32");
failures += Check(generated.Contains("EngineComponentDescribe", StringComparison.Ordinal), "Describe calls EngineComponentDescribe");
if (asm is null)
{
Console.WriteLine($"GeneratorDriverTests: {failures} FAILED (no assembly)");
return failures + 1;
}
failures += Check(CallDescribe(asm, "Fx.Health") == -1, "Fx.Health.Describe(null table) -> -1");
failures += CheckMover(asm);
failures += CheckBomb(asm);
foreach (var name in new[] { "Fx.ByValue", "Fx.DoubleDt", "Fx.DupTerm", "Fx.BadParam" })
@@ -124,23 +132,24 @@ public static unsafe class GeneratorDriverTests
private static int CheckMover(Assembly asm)
{
// Mover = 3 floats (Speed, X, Y); Health = 1 float.
// Mover = 3 floats (Speed, X, Y); Health = float Current + int Max (8B).
const int rows = 4;
var movers = stackalloc float[rows * 3];
var health = stackalloc float[rows];
var health = stackalloc byte[rows * 8];
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);
*(float*)(health + (i * 8)) = 10 * (i + 1); // Current
*(int*)(health + (i * 8) + 4) = 0; // Max
}
var view = default(NativeApi.EngineChunkColumns);
view.Columns[0] = (ulong)movers;
view.Columns[1] = (ulong)health;
view.Sizes[0] = 12;
view.Sizes[1] = 4;
view.Sizes[1] = 8;
view.Count = rows;
view.TermCount = 2;
RunChunk(asm, "Fx.Mover")(&view, 0.5f);
@@ -185,6 +194,13 @@ public static unsafe class GeneratorDriverTests
return (delegate* unmanaged[Cdecl]<NativeApi.EngineChunkColumns*, float, void>)mi.MethodHandle.GetFunctionPointer();
}
private static int CallDescribe(Assembly asm, string type)
{
var mi = asm.GetType(type, throwOnError: true)!.GetMethod("Describe", BindingFlags.Public | BindingFlags.Static)!;
var fn = (delegate*<NativeApi.ApiTable*, NativeApi.EngineWorld*, int>)mi.MethodHandle.GetFunctionPointer();
return fn(null, null);
}
private static int CallRegister(Assembly asm, string type)
{
var mi = asm.GetType(type, throwOnError: true)!.GetMethod("Register", BindingFlags.Public | BindingFlags.Static)!;
+1
View File
@@ -26,6 +26,7 @@ failures += Engine.Tests.TerrainApiTests.Run(args);
failures += Engine.Tests.TerrainBrushTests.Run(args);
failures += Engine.Tests.GeneratorTests.Run();
failures += Engine.Tests.GeneratorDriverTests.Run();
failures += Engine.Tests.FieldMetadataTests.Run(args);
failures += Engine.Tests.EditorApiTests.Run(args);
failures += Engine.Tests.CliApiTests.Run(args);
failures += Engine.Tests.ExportApiTests.Run(args);
+57
View File
@@ -84,8 +84,22 @@ public static class SceneIoTests
var child = Ecs.CreateEntity(table, w1);
Ecs.Set(table, w1, child, LocalTransform.IdentityAt(1, 2, 3));
// P3-04 acceptance: describe Health (same shape the generator
// emits: Current f32 @0, Max i32 @4), set values, save writes
// named fields "Health":{"Current":75,"Max":100}.
failures += Check(DescribeSceneHealth(table, w1) == 0, "live: Health.Describe ok");
var healthEnt = Ecs.CreateEntity(table, w1);
Ecs.Set(table, w1, healthEnt, LocalTransform.IdentityAt(4, 5, 6));
Ecs.Set(table, w1, healthEnt, new SceneHealth { Current = 75, Max = 100 });
Scene.Save(table, w1, fileA);
failures += Check(File.Exists(fileA) && new FileInfo(fileA).Length > 0, "live: save A writes a file");
var jsonA = File.ReadAllText(fileA);
// Acceptance maps to "Health":{...} for a component NAMED Health;
// this assembly's type is Engine.Tests.SceneHealth (FullName
// contract), so the file key is the dotted name.
failures += Check(jsonA.Contains("\"Engine.Tests.SceneHealth\":{\"Current\":75,\"Max\":100}", StringComparison.Ordinal), "ACCEPT: scene writes named Health fields");
failures += Check(!jsonA.Contains("$bytes", StringComparison.Ordinal) || jsonA.Contains("LocalTransform", StringComparison.Ordinal), "live: Health not a $bytes blob");
}
finally
{
@@ -101,6 +115,7 @@ public static class SceneIoTests
try
{
failures += Check(DescribeSceneHealth(table, w2) == 0, "live: Health.Describe on w2 ok");
var skipped = Scene.Load(table, w2, fileA);
failures += Check(skipped == 0, $"live: load skips nothing (got {skipped})");
Scene.Save(table, w2, fileB);
@@ -124,6 +139,38 @@ public static class SceneIoTests
return failures;
}
// Mirrors the generator's emitted Describe for SceneHealth (Current
// f32 @OffsetOf(Current), Max i32 @OffsetOf(Max)): register, then
// describe with runtime-computed offsets.
private static unsafe int DescribeSceneHealth(NativeApi.ApiTable* table, NativeApi.EngineWorld* world)
{
if (table == null || world == null)
{
return -1;
}
_ = Ecs.ComponentId<SceneHealth>(table, world);
var fields = stackalloc NativeApi.FieldDesc[2];
fixed (byte* f0 = "Current\0"u8)
{
fields[0].Name = f0;
fields[0].Offset = (uint)System.Runtime.InteropServices.Marshal.OffsetOf<SceneHealth>("Current");
fields[0].Type = (uint)NativeApi.FieldType.F32;
}
fixed (byte* f1 = "Max\0"u8)
{
fields[1].Name = f1;
fields[1].Offset = (uint)System.Runtime.InteropServices.Marshal.OffsetOf<SceneHealth>("Max");
fields[1].Type = (uint)NativeApi.FieldType.I32;
}
fixed (byte* comp = Ecs.ComponentName<SceneHealth>.Utf8)
{
return table->EngineComponentDescribe(world, comp, fields, 2u);
}
}
private static int Check(bool ok, string name)
{
Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
@@ -131,3 +178,13 @@ public static class SceneIoTests
}
}
/// <summary>P3-04 acceptance component: Health { float Current; int Max } (no Tick).</summary>
#pragma warning disable CA1051 // Blittable interop struct by design.
public struct SceneHealth : Engine.Runtime.IComponent
{
public float Current;
public int Max;
}
#pragma warning restore CA1051
@@ -226,7 +226,7 @@ extern "C" {
// extend the tail only.
// ---------------------------------------------------------------------------
#define ENGINE_ABI_VERSION_MAJOR 4
#define ENGINE_ABI_VERSION_MINOR 23
#define ENGINE_ABI_VERSION_MINOR 24
#define ENGINE_ABI_VERSION_PATCH 0
// 64-byte cache-line pitch for the component columns handed to managed
@@ -1118,6 +1118,52 @@ typedef int32_t (*EngineAnimatorUpdateFn)(EngineAnimator* animator, float dt);
typedef int32_t (*EngineAnimatorStateNameFn)(const EngineAnimator* animator,
char* out, int32_t capacity);
// ---------------------------------------------------------------------------
// P3-04 component field metadata tail (ABI 4.24).
//
// EngineFieldDesc describes one field of a registered component:
// `name` is NUL-terminated UTF-8, `offset` is the byte offset within the
// component, `type` is one of ENGINE_FIELD_* below. Lane sizes (bytes):
// f32/i32/u32 = 4, bool8 = 1, vec2 = 8, vec3 = 12, vec4/quat/color4 = 16,
// entity/asset-ref = 8.
// ---------------------------------------------------------------------------
#define ENGINE_FIELD_F32 0
#define ENGINE_FIELD_I32 1
#define ENGINE_FIELD_U32 2
#define ENGINE_FIELD_BOOL8 3
#define ENGINE_FIELD_VEC2 4
#define ENGINE_FIELD_VEC3 5
#define ENGINE_FIELD_VEC4 6
#define ENGINE_FIELD_QUAT 7
#define ENGINE_FIELD_COLOR4 8
#define ENGINE_FIELD_ENTITY 9
#define ENGINE_FIELD_ASSET_REF 10
typedef struct EngineFieldDesc {
const char* name;
uint32_t offset;
uint32_t type;
} EngineFieldDesc;
// Store a field description for a registered component. `fields` is
// copied (names included); the caller's array is borrowed. `count` may
// be 0 with `fields == NULL` (stores an empty description). Returns 0
// on success or an ENGINE_ABI_ERR_* code (NOT_FOUND when `name` was
// never registered via component_register).
typedef int32_t (*EngineComponentDescribeFn)(
EngineWorld* world, const char* name,
const EngineFieldDesc* fields, uint32_t count);
// Returns the described field count (>= 0), or a negative
// ENGINE_ABI_ERR_* code (NULL_ARG on null args, NOT_FOUND on unknown).
typedef int32_t (*EngineComponentFieldCountFn)(
const EngineWorld* world, const char* name);
// Fills *out_desc (name aliases native-owned storage, valid until world
// destroy or re-describe of that component). 0 on success, negative
// ENGINE_ABI_ERR_* (NOT_FOUND also covers index >= count).
typedef int32_t (*EngineComponentFieldAtFn)(
const EngineWorld* world, const char* name, uint32_t index,
EngineFieldDesc* out_desc);
// ---------------------------------------------------------------------------
// P5-04 game UI tail (ABI 4.14): RmlUi + FreeType HUD, CPU-rendered.
//
@@ -1892,6 +1938,10 @@ typedef struct EngineApiTable {
EngineAnimatorSetTriggerFn animator_set_trigger;
EngineAnimatorUpdateFn animator_update;
EngineAnimatorStateNameFn animator_state_name;
// P3-04 component-field-metadata tail (ABI 4.24). All earlier order frozen.
EngineComponentDescribeFn component_describe;
EngineComponentFieldCountFn component_field_count;
EngineComponentFieldAtFn component_field_at;
} EngineApiTable;
+94 -2
View File
@@ -1461,6 +1461,87 @@ auto ApiEntityGet(EngineWorld* world, uint64_t entity, uint64_t component,
return impl->generic.get(impl->ecs.raw(), entity, component, out, size);
}
// --- P3-04 component field metadata ----------------------------------------
namespace {
// Shared resolver: bridges GenericSystems field rows into the scene lib's
// SceneFieldDesc view. Both save and load use it; the single definition
// keeps the two call sites identical by construction.
struct DescribeCtx {
engine::bridge::GenericSystems* generic;
ecs_world_t* raw;
};
auto ResolveDescribeRows(const char* comp_name, std::uint32_t* out_count,
void* user) noexcept
-> const engine::scene::SceneFieldDesc* {
auto* c = static_cast<DescribeCtx*>(user);
if (c == nullptr || c->generic == nullptr || comp_name == nullptr ||
out_count == nullptr) {
return nullptr;
}
const int32_t n = c->generic->field_count(c->raw, comp_name);
if (n <= 0) {
*out_count = 0;
return nullptr;
}
// Thread-local staging: the scene lib consumes the rows synchronously
// within the describe() call, so a reused buffer is safe.
thread_local std::vector<engine::scene::SceneFieldDesc> staging;
thread_local std::vector<EngineFieldDesc> native_rows;
try {
staging.resize(static_cast<std::size_t>(n));
native_rows.resize(static_cast<std::size_t>(n));
} catch (...) {
*out_count = 0;
return nullptr;
}
for (int32_t i = 0; i < n; ++i) {
EngineFieldDesc row{};
if (c->generic->field_at(c->raw, comp_name, static_cast<uint32_t>(i),
&row) != 0) {
*out_count = 0;
return nullptr;
}
native_rows[static_cast<std::size_t>(i)] = row;
staging[static_cast<std::size_t>(i)].name = row.name;
staging[static_cast<std::size_t>(i)].offset = row.offset;
staging[static_cast<std::size_t>(i)].type = row.type;
}
*out_count = static_cast<std::uint32_t>(n);
return staging.data();
}
} // namespace
auto ApiComponentDescribe(EngineWorld* world, const char* name,
const EngineFieldDesc* fields, uint32_t count)
-> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(world);
return impl->generic.describe(impl->ecs.raw(), name, fields, count);
}
auto ApiComponentFieldCount(const EngineWorld* world, const char* name)
-> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(const_cast<EngineWorld*>(world)); // NOLINT
return impl->generic.field_count(impl->ecs.raw(), name);
}
auto ApiComponentFieldAt(const EngineWorld* world, const char* name,
uint32_t index, EngineFieldDesc* out_desc)
-> int32_t {
if (world == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* impl = ImplOf(const_cast<EngineWorld*>(world)); // NOLINT
return impl->generic.field_at(impl->ecs.raw(), name, index, out_desc);
}
auto ApiSystemRegister(EngineWorld* world, const char* name,
const uint64_t* components, const uint32_t* access,
uint32_t term_count, EngineSystemCallback callback,
@@ -1689,14 +1770,22 @@ auto ApiSceneSave(EngineWorld* world, const char* path) -> int32_t {
if (world == nullptr || path == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
return engine::scene::save_scene(ImplOf(world)->ecs.raw(), path);
auto* impl = ImplOf(world);
DescribeCtx ctx{&impl->generic, impl->ecs.raw()};
return engine::scene::save_scene_with_descriptions(impl->ecs.raw(), path,
&ResolveDescribeRows,
&ctx);
}
auto ApiSceneLoad(EngineWorld* world, const char* path) -> int32_t {
if (world == nullptr || path == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
return engine::scene::load_scene(ImplOf(world)->ecs.raw(), path);
auto* impl = ImplOf(world);
DescribeCtx ctx{&impl->generic, impl->ecs.raw()};
return engine::scene::load_scene_with_descriptions(impl->ecs.raw(), path,
&ResolveDescribeRows,
&ctx);
}
// P2-05 collision-event tail: drain the world-owned TransformSync ring
@@ -3618,6 +3707,9 @@ const EngineApiTable kApiTable = {
&ApiAnimatorSetTrigger,
&ApiAnimatorUpdate,
&ApiAnimatorStateName,
&ApiComponentDescribe,
&ApiComponentFieldCount,
&ApiComponentFieldAt,
};
} // namespace
+190
View File
@@ -3,6 +3,7 @@
#include <cstddef>
#include <cstring>
#include <string_view>
#include "engine/ecs/ecs_render_components.hpp"
#include "name_copy.hpp"
@@ -20,6 +21,33 @@ auto TypeInfoOf(ecs_world_t* world, uint64_t id) noexcept
return ecs_get_type_info(world, static_cast<ecs_id_t>(id));
}
// P3-04 lane size table indexed by ENGINE_FIELD_* (0..10).
constexpr uint32_t kFieldLaneSizes[11] = {4, 4, 4, 1, 8, 12, 16, 16, 16, 8, 8};
// (name, offset, type) rows; types are ENGINE_FIELD_* values.
struct BuiltinField {
const char* name;
uint32_t offset;
uint32_t type;
};
// LocalTransform 64B: position vec3 @0, rotation quat @12, scale vec3 @28.
constexpr BuiltinField kLocalTransformFields[] = {
{"position", 0, 5}, {"rotation", 12, 7}, {"scale", 28, 5}};
// Camera 16B.
constexpr BuiltinField kCameraFields[] = {{"fov_y_rad", 0, 0},
{"near_z", 4, 0},
{"far_z", 8, 0},
{"is_main", 12, 2}};
// MeshRenderer 20B: mesh_id is asset-ref (inspector keys its asset
// TextBox+drop off type 10; the scene writer keeps its bespoke path).
constexpr BuiltinField kMeshRendererFields[] = {{"mesh_id", 0, 10},
{"base_color", 4, 8}};
// DirectionalLight 16B: native lane name is `shadow`; scene JSON keeps
// the legacy `intensity` key (reader accepts both).
constexpr BuiltinField kDirectionalLightFields[] = {{"direction", 0, 5},
{"shadow", 12, 0}};
} // namespace
auto GenericSystems::shutdown() noexcept -> void {
@@ -43,6 +71,42 @@ auto GenericSystems::register_component(ecs_world_t* world, const char* name,
const std::uint64_t id = engine::ecs::RegisterRenderComponent(
world, name, static_cast<std::uint32_t>(size),
static_cast<std::uint32_t>(alignment));
if (id != 0 && world != nullptr && name != nullptr) {
// P3-04: auto-describe built-ins on first registration. Re-registration
// is idempotent (describe overwrites with the identical table).
const BuiltinField* fields = nullptr;
uint32_t count = 0;
uint32_t want_size = 0;
const std::string_view n(name);
if (n == "LocalTransform") {
fields = kLocalTransformFields;
count = 3;
want_size = 64;
} else if (n == "Engine.Runtime.Camera") {
fields = kCameraFields;
count = 4;
want_size = 16;
} else if (n == "Engine.Runtime.MeshRenderer") {
fields = kMeshRendererFields;
count = 2;
want_size = 20;
} else if (n == "Engine.Runtime.DirectionalLight") {
fields = kDirectionalLightFields;
count = 2;
want_size = 16;
}
if (fields != nullptr && size == want_size) {
EngineFieldDesc rows[4];
for (uint32_t i = 0; i < count; ++i) {
rows[i].name = fields[i].name;
rows[i].offset = fields[i].offset;
rows[i].type = fields[i].type;
}
// Best-effort: validation cannot fail for these pre-validated
// tables; ignore the code (a drift fails closed = undescribed).
(void)describe(world, name, rows, count);
}
}
return id;
}
@@ -165,6 +229,16 @@ auto GenericSystems::register_system(ecs_world_t* world, const char* name,
return static_cast<int32_t>(systems_.size() - 1U);
}
// --- P3-04 built-in descriptions -------------------------------------------
// Hardcoded field tables (plan §7). Installed automatically when the
// matching built-in is registered through the ABI path
// (GenericSystems::register_component, which both paths funnel through:
// the plan's "built-ins call the same core as the ABI entry"), via the
// same describe() validation. Sizes are checked against the live
// component so a layout drift fails closed (no description) instead of
// describing wrong offsets.
auto GenericSystems::run(ecs_world_t* world, float dt) noexcept -> int32_t {
return run(world, dt, nullptr);
}
@@ -244,4 +318,120 @@ auto GenericSystems::dispatch(ecs_world_t* world, int64_t phase, float dt,
return 0;
}
// --- P3-04 component field descriptions ------------------------------------
auto GenericSystems::describe(ecs_world_t* world, const char* name,
const EngineFieldDesc* fields,
uint32_t count) noexcept -> int32_t {
if (world == nullptr || name == nullptr || name[0] == '\0') {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (count > 0 && fields == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (count > 256) {
return ENGINE_ABI_ERR_BAD_ARG;
}
// describe requires a prior register (same lookup register uses).
const ecs_entity_t existing =
ecs_lookup_path_w_sep(world, 0, name, "", "", false);
if (existing == 0) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
const ecs_type_info_t* ti = TypeInfoOf(world, existing);
if (ti == nullptr) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
const auto comp_size = static_cast<uint32_t>(ti->size);
// Validate everything before touching the store (all-or-nothing).
ComponentDescription next;
next.fields.reserve(count);
for (uint32_t i = 0; i < count; ++i) {
const EngineFieldDesc& f = fields[i];
if (f.name == nullptr || f.name[0] == '\0') {
return ENGINE_ABI_ERR_BAD_ARG;
}
if (f.type > ENGINE_FIELD_ASSET_REF) {
return ENGINE_ABI_ERR_BAD_ARG;
}
const uint32_t lane = kFieldLaneSizes[f.type];
// Overflow-safe: offset > size OR lane > size - offset.
if (f.offset > comp_size || lane > comp_size - f.offset) {
return ENGINE_ABI_ERR_BAD_ARG;
}
for (const auto& prev : next.fields) {
if (prev.name == f.name) {
return ENGINE_ABI_ERR_BAD_ARG; // duplicate name
}
// Overlap check: [a,b) vs [c,d) overlap iff a < d && c < b.
const uint32_t pa = prev.offset;
const uint32_t pb = prev.offset + kFieldLaneSizes[prev.type];
const uint32_t fa = f.offset;
const uint32_t fb = f.offset + lane;
if (fa < pb && pa < fb) {
return ENGINE_ABI_ERR_BAD_ARG;
}
}
ComponentDescription::Field nf;
nf.name = f.name; // copy into native-owned storage
nf.offset = f.offset;
nf.type = f.type;
try {
next.fields.push_back(std::move(nf));
} catch (...) {
return ENGINE_ABI_ERR_INTERNAL;
}
}
try {
descriptions_[name] = std::move(next); // overwrite (hot-reload)
} catch (...) {
return ENGINE_ABI_ERR_INTERNAL;
}
return 0;
}
auto GenericSystems::field_count(ecs_world_t* world, const char* name) noexcept
-> int32_t {
if (world == nullptr || name == nullptr || name[0] == '\0') {
return ENGINE_ABI_ERR_NULL_ARG;
}
// Unknown name: NOT_FOUND. Note a describe-before-register also lands
// here (no description was ever stored for it).
const auto it = descriptions_.find(name);
if (it == descriptions_.end()) {
// Distinguish "registered but undescribed" (0) from "unknown"
// (NOT_FOUND): check the live world registry.
const ecs_entity_t existing =
ecs_lookup_path_w_sep(world, 0, name, "", "", false);
if (existing == 0 || TypeInfoOf(world, existing) == nullptr) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
// Registered but never described: legal, 0 (plan §3.2 rule 7 reads
// count >= 0 with 0 for empty/undescribed-known).
return 0;
}
return static_cast<int32_t>(it->second.fields.size());
}
auto GenericSystems::field_at(ecs_world_t* world, const char* name,
uint32_t index,
EngineFieldDesc* out_desc) noexcept -> int32_t {
if (world == nullptr || name == nullptr || name[0] == '\0' ||
out_desc == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
const auto it = descriptions_.find(name);
if (it == descriptions_.end()) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
if (index >= it->second.fields.size()) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
const auto& f = it->second.fields[index];
out_desc->name = f.name.c_str(); // native-owned; valid until destroy
out_desc->offset = f.offset; // or re-describe of this component
out_desc->type = f.type;
return 0;
}
} // namespace engine::bridge
+23
View File
@@ -9,6 +9,8 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#include "engine/bridge/engine_abi.h"
@@ -69,7 +71,25 @@ class GenericSystems {
return systems_.size();
}
// P3-04 component field descriptions (native-owned string copies,
// per-world; die with this GenericSystems). describe() copies every
// field name into std::string; field_at() aliases that storage.
auto describe(ecs_world_t* world, const char* name,
const EngineFieldDesc* fields, uint32_t count) noexcept
-> int32_t;
auto field_count(ecs_world_t* world, const char* name) noexcept -> int32_t;
auto field_at(ecs_world_t* world, const char* name, uint32_t index,
EngineFieldDesc* out_desc) noexcept -> int32_t;
private:
struct ComponentDescription {
struct Field {
std::string name;
uint32_t offset{0};
uint32_t type{0};
};
std::vector<Field> fields; // declaration order, validated
};
struct System {
char name[64];
ecs_query_t* query;
@@ -87,6 +107,9 @@ class GenericSystems {
std::vector<System> systems_;
bool in_run_{false};
// P3-04: keyed by component name string (same spelling passed to
// register_component). Never caches Flecs ids.
std::unordered_map<std::string, ComponentDescription> descriptions_;
};
} // namespace engine::bridge
+20 -1
View File
@@ -42,6 +42,17 @@ struct ecs_world_t;
namespace engine::scene {
// P3-04 describe plumbing: resolver callback over the bridge-owned store.
// Null describe == the legacy no-description path.
struct SceneFieldDesc {
const char* name;
uint32_t offset;
uint32_t type;
};
using SceneDescribeFn =
const SceneFieldDesc* (*)(const char* comp_name, uint32_t* out_count,
void* user);
// Error codes (negative; 0 = ok). Positive return from load_scene counts
// skipped bad user components (warnings already printed).
constexpr int kSceneOk = 0;
@@ -51,9 +62,14 @@ constexpr int kSceneBadFormat = -3;
constexpr int kSceneNoLocal = -4;
// Save every LocalTransform-carrying entity in `world` to `path` (UTF-8).
// Returns kSceneOk or a negative kScene* code.
// Returns kSceneOk or a negative kScene* code. With a describe callback,
// user components with a live description write named fields; without
// (or count == 0), the legacy $bytes blob. Version stays 1.
ENGINE_CORE_API auto save_scene(ecs_world_t* world, const char* path) noexcept
-> int32_t;
ENGINE_CORE_API auto save_scene_with_descriptions(
ecs_world_t* world, const char* path,
SceneDescribeFn describe, void* user) noexcept -> int32_t;
// Load the scene file at `path` into `world` (existing Local entities are
// left alone; file entities are created fresh with file-local parent links
@@ -62,6 +78,9 @@ ENGINE_CORE_API auto save_scene(ecs_world_t* world, const char* path) noexcept
// Returns the skip count (>= 0), or a negative kScene* code on hard error.
ENGINE_CORE_API auto load_scene(ecs_world_t* world, const char* path) noexcept
-> int32_t;
ENGINE_CORE_API auto load_scene_with_descriptions(
ecs_world_t* world, const char* path,
SceneDescribeFn describe, void* user) noexcept -> int32_t;
// Mesh source registry: "assets/cube.glb#0" style reference for a runtime
// mesh_id. Load resolves through this; an unregistered id writes an empty
+215 -2
View File
@@ -12,6 +12,7 @@
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <string_view>
@@ -551,6 +552,12 @@ auto MeshIdFor(std::string_view ref) -> std::uint32_t {
} // namespace
auto load_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
return load_scene_with_descriptions(world, path, nullptr, nullptr);
}
auto load_scene_with_descriptions(ecs_world_t* world, const char* path,
SceneDescribeFn describe,
void* user) noexcept -> int32_t {
if (world == nullptr || path == nullptr || path[0] == '\0') {
return kSceneNullArg;
}
@@ -633,6 +640,16 @@ auto load_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
seen.push_back(file_id);
}
}
// P3-04 entity-lane fixups: file-local ids resolve only after all
// entities exist. Pass 1 zeroes entity lanes and records fixups;
// pass 2 (with the parent replay) writes resolved raw ids.
struct EntityFixup {
ecs_entity_t e;
ecs_entity_t comp;
std::uint32_t offset;
std::uint32_t file_id;
};
std::vector<EntityFixup> entity_fixups;
// Pass 1: create entities + components (parents replayed in pass 2).
for (const SceneValue& ent : entities->items) {
if (ent.kind != SceneValue::Kind::Obj) {
@@ -751,8 +768,183 @@ auto load_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
// instead of warning as a user blob.
continue;
} else {
// User blob: $bytes + $size + $schema.
// P3-04 named path: try $bytes legacy first (old files always
// load), then named fields when a live description resolves.
const SceneValue* bytes = cval.lookup("$bytes");
if (bytes == nullptr && describe != nullptr) {
// Named payload path (no $bytes key).
const SceneValue* named = &cval;
std::uint32_t desc_count = 0;
const SceneFieldDesc* desc_fields =
describe(cname.c_str(), &desc_count, user);
if (desc_fields == nullptr || desc_count == 0) {
std::printf(
"[WARN] scene: bad user component '%s' (named fields, no "
"description) skipped\n",
cname.c_str());
++skipped;
continue;
}
const ecs_entity_t existing = Lookup(world, cname.c_str());
if (existing == 0) {
// Forbidden heuristic: named payloads carry no $schema, so a
// wrong align would poison later registration — skip.
std::printf(
"[WARN] scene: bad user component '%s' (unknown component, "
"named load) skipped\n",
cname.c_str());
++skipped;
continue;
}
const ecs_type_info_t* live_ti =
ecs_get_type_info(world, existing);
if (live_ti == nullptr || live_ti->size <= 0 ||
live_ti->size > 65536) {
std::printf(
"[WARN] scene: bad user component '%s' (no live type info) "
"skipped\n",
cname.c_str());
++skipped;
continue;
}
const auto comp_size =
static_cast<std::uint32_t>(live_ti->size);
std::vector<std::uint8_t> staged(comp_size, 0);
bool field_ok = true;
for (std::uint32_t fi = 0; fi < desc_count && field_ok; ++fi) {
const SceneFieldDesc& f = desc_fields[fi];
const SceneValue* fv = named->lookup(f.name);
if (fv == nullptr) {
continue; // absent keys stay zero
}
if (f.type > 10 || f.offset >= comp_size) {
field_ok = false;
break;
}
constexpr std::uint32_t kLane[11] = {4, 4, 4, 1, 8,
12, 16, 16, 16, 8, 8};
if (kLane[f.type] > comp_size - f.offset) {
field_ok = false;
break;
}
std::uint8_t* dst = staged.data() + f.offset;
switch (f.type) {
case 0: { // f32
float v = 0;
if (!NumFloat(fv, v)) {
field_ok = false;
} else {
std::memcpy(dst, &v, 4);
}
break;
}
case 1: { // i32: JSON number
double d = 0;
if (!NumOf(fv, d) || d < -2147483648.0 ||
d > 2147483647.0 || std::floor(d) != d) {
field_ok = false;
} else {
const auto v = static_cast<std::int32_t>(d);
std::memcpy(dst, &v, 4);
}
break;
}
case 2: { // u32: JSON number
std::uint32_t v = 0;
if (!U32Of(fv, v)) {
field_ok = false;
} else {
std::memcpy(dst, &v, 4);
}
break;
}
case 3: { // bool8: Num("1"/"0") via parse_literal
double d = 0;
if (!NumOf(fv, d) || (d != 0 && d != 1)) {
field_ok = false;
} else {
*dst = (d == 1) ? 1 : 0;
}
break;
}
case 4: // vec2
case 5: // vec3
case 6: // vec4
case 7: // quat
case 8: { // color4
constexpr std::uint32_t kN[11] = {1, 1, 1, 1, 2,
3, 4, 4, 4, 1, 1};
const std::uint32_t n = kN[f.type];
if (fv->kind != SceneValue::Kind::Arr ||
fv->items.size() != n) {
field_ok = false;
break;
}
for (std::uint32_t lane = 0; lane < n && field_ok; ++lane) {
float v = 0;
if (!NumFloat(&fv->items[lane], v)) {
field_ok = false;
} else {
std::memcpy(dst + lane * 4, &v, 4);
}
}
break;
}
case 9: { // entity: file-local id; zero now + fixup later
std::uint32_t ref_id = 0;
if (fv->kind == SceneValue::Kind::Num &&
ParseU32(fv->str, ref_id)) {
if (ref_id != 0) {
EntityFixup fx;
fx.e = e;
fx.comp = existing;
fx.offset = f.offset;
fx.file_id = ref_id;
entity_fixups.push_back(fx);
}
std::uint64_t zero = 0;
std::memcpy(dst, &zero, 8);
} else {
field_ok = false;
}
break;
}
case 10: { // asset-ref: raw u64 number
std::uint32_t v32 = 0;
double d = 0;
if (U32Of(fv, v32)) {
const auto v = static_cast<std::uint64_t>(v32);
std::memcpy(dst, &v, 8);
} else if (NumOf(fv, d) && d >= 0 &&
d <= 18446744073709551615.0 &&
std::floor(d) == d) {
const auto v = static_cast<std::uint64_t>(d);
std::memcpy(dst, &v, 8);
} else {
field_ok = false;
}
break;
}
default: field_ok = false; break;
}
if (fv != nullptr && fv->kind == SceneValue::Kind::Obj &&
f.type <= 10) {
// unknown JSON kinds for scalar lanes already failed above
}
}
if (!field_ok) {
std::printf(
"[WARN] scene: bad user component '%s' (named field error) "
"skipped\n",
cname.c_str());
++skipped;
continue;
}
ecs_set_id(world, e, existing, comp_size, staged.data());
continue;
}
// User blob: $bytes + $size + $schema.
// (bytes may be null here only when describe == nullptr.)
const SceneValue* size_v = cval.lookup("$size");
const SceneValue* schema_v = cval.lookup("$schema");
std::uint32_t size = 0;
@@ -901,11 +1093,32 @@ auto load_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
// WorldTransform for the new entity (derived state, like construct).
ecs_add_id(world, e, ecs_lookup(world, "WorldTransform"));
}
// Pass 2: parent links (file-local id -> live entity). The file parent
// Pass 2: entity-lane fixups + parent links (file-local id ->
// live entity). The file parent
// map is walked (not the live world) so longer cycles (A->B->A) are
// detected too. A link that resolves to self, to an unknown id, or that
// closes a cycle is skipped with a warning — adding ChildOf(e, e) is a
// Flecs cycle, and pass 1 deliberately created no links yet.
// P3-04: resolve entity-lane fixups now that all entities exist.
for (const EntityFixup& fx : entity_fixups) {
ecs_entity_t target = 0;
for (const auto& kv : id_map) {
if (kv.first == fx.file_id) {
target = kv.second;
break;
}
}
if (target == 0) {
continue; // dangling ref stays null (zero) — forward compat
}
auto* mut =
static_cast<std::uint8_t*>(ecs_get_mut_id(world, fx.e, fx.comp));
if (mut == nullptr) {
continue;
}
const auto raw = static_cast<std::uint64_t>(target);
std::memcpy(mut + fx.offset, &raw, 8);
}
std::vector<std::uint32_t> file_parents(entities->items.size(), 0);
for (std::size_t i = 0; i < entities->items.size(); ++i) {
const SceneValue* fparent = entities->items[i].lookup("parent");
+124
View File
@@ -13,6 +13,8 @@
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <functional>
#include <span>
#include <string>
#include <string_view>
@@ -114,6 +116,103 @@ auto AppendFloatArray(std::string& out, std::span<const float> values)
out.push_back(']');
}
// --- P3-04 named-field writer ------------------------------------------------
// Lane size table indexed by SceneFieldDesc::type (0..10). Must match the
// ABI ENGINE_FIELD_* table (generic_systems.cpp kFieldLaneSizes).
constexpr std::uint32_t kLaneSizes[11] = {4, 4, 4, 1, 8, 12, 16, 16, 16, 8, 8};
constexpr std::uint32_t kFieldsPerVec[11] = {1, 1, 1, 1, 2, 3, 4, 4, 4, 1, 1};
// Write one described user component as named JSON fields (writer rule,
// plan §8.2). Returns false when the lane data is unusable (caller falls
// back to the $bytes blob). entity lanes need the file-local id map:
// write 0 when the runtime id maps to nothing (null/invalid).
auto AppendNamedComponent(
std::string& out, const SceneFieldDesc* fields, std::uint32_t count,
const std::uint8_t* bytes, std::uint32_t comp_size,
const std::function<std::uint32_t(std::uint64_t)>& file_id) -> bool {
for (std::uint32_t i = 0; i < count; ++i) {
const SceneFieldDesc& f = fields[i];
// Validate against live memory before emitting anything: callers
// check Atomic-per-component (skip whole component on any failure),
// but the writer buffer is already open — validate here and bail to
// the $bytes fallback so output never half-writes a component.
if (f.type > 10) {
return false;
}
const std::uint32_t lane = kLaneSizes[f.type];
if (f.offset > comp_size || lane > comp_size - f.offset) {
return false;
}
if (i != 0U) {
out.push_back(',');
}
AppendJsonString(out, f.name);
out.push_back(':');
const std::uint8_t* lane_ptr = bytes + f.offset;
switch (f.type) {
case 0: { // f32
float v = 0;
std::memcpy(&v, lane_ptr, 4);
AppendFloat(out, v);
break;
}
case 1: { // i32
std::int32_t v = 0;
std::memcpy(&v, lane_ptr, 4);
std::array<char, 16> num{};
auto res = std::to_chars(num.data(), num.data() + num.size(), v);
out.append(num.data(), res.ptr);
break;
}
case 2: { // u32
std::uint32_t v = 0;
std::memcpy(&v, lane_ptr, 4);
AppendU32(out, v);
break;
}
case 3: { // bool8
out += (*lane_ptr != 0) ? "true" : "false";
break;
}
case 4: // vec2
case 5: // vec3
case 6: // vec4
case 7: // quat
case 8: { // color4
const std::uint32_t n = kFieldsPerVec[f.type];
out.push_back('[');
for (std::uint32_t k = 0; k < n; ++k) {
if (k != 0U) {
out.push_back(',');
}
float v = 0;
std::memcpy(&v, lane_ptr + k * 4, 4);
AppendFloat(out, v);
}
out.push_back(']');
break;
}
case 9: { // entity: file-local id (0 = null/invalid)
std::uint64_t runtime = 0;
std::memcpy(&runtime, lane_ptr, 8);
AppendU32(out, file_id(runtime));
break;
}
case 10: { // asset-ref: raw u64 cookie (documented limit)
std::uint64_t v = 0;
std::memcpy(&v, lane_ptr, 8);
std::array<char, 24> num{};
auto res = std::to_chars(num.data(), num.data() + num.size(), v);
out.append(num.data(), res.ptr);
break;
}
default: return false;
}
}
return true;
}
// --- entity walk ----------------------------------------------------------
// Snapshot-style row: id + parent + name (editor_bridge walks the same way:
// ecs_get_target(EcsChildOf, 0) for parents, ecs_get_name for names).
@@ -283,6 +382,12 @@ auto mesh_id_for_ref(const char* ref) noexcept -> std::uint32_t {
}
auto save_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
return save_scene_with_descriptions(world, path, nullptr, nullptr);
}
auto save_scene_with_descriptions(ecs_world_t* world, const char* path,
SceneDescribeFn describe,
void* user) noexcept -> int32_t {
if (world == nullptr || path == nullptr || path[0] == '\0') {
return kSceneNullArg;
}
@@ -429,8 +534,27 @@ auto save_scene(ecs_world_t* world, const char* path) noexcept -> int32_t {
}
const auto size = static_cast<std::uint32_t>(ti->size);
const auto align = static_cast<std::uint32_t>(ti->alignment);
// P3-04: named fields when a live description exists (count > 0);
// else the legacy $bytes blob, byte-for-byte unchanged.
bool use_named = false;
const SceneFieldDesc* desc_fields = nullptr;
std::uint32_t desc_count = 0;
if (describe != nullptr) {
desc_fields = describe(comp_name.c_str(), &desc_count, user);
use_named = (desc_fields != nullptr && desc_count > 0);
}
comma();
AppendJsonString(out, comp_name);
if (use_named) {
// Buffer the named payload first: atomic per component (never
// half-write); fall back to $bytes on any lane failure.
std::string named;
if (AppendNamedComponent(named, desc_fields, desc_count, bytes,
size, file_id)) {
out += ":{" + named + "}";
continue;
}
}
out += ":{\"$bytes\":\"";
AppendHex(out, bytes, size);
out += "\",\"$size\":";
+13 -1
View File
@@ -345,7 +345,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) == 1256, "abi: EngineApiTable 1256B (ABI 4.23 animator tail)");
failures += check(sizeof(EngineApiTable) == 1280, "abi: EngineApiTable 1280B (ABI 4.24 field-metadata tail)");
failures += check(sizeof(EngineInputState) == 1668, "abi: EngineInputState 1668B");
failures += check(offsetof(EngineInputState, mouse_x) == 1536, "abi: EngineInputState.mouse_x @ 1536");
failures += check(offsetof(EngineInputState, mouse_buttons) == 1552, "abi: EngineInputState.mouse_buttons @ 1552");
@@ -608,6 +608,18 @@ auto main() -> int {
} else {
failures += check(false, "abi: animator tail null guards");
}
// P3-04 field-metadata tail (ABI 4.24): 3 appended entries after the 4.23
// animator tail (animator_state_name @ 1248).
failures += check(api->component_describe != nullptr,
"abi: component_describe set");
failures += check(api->component_field_count != nullptr,
"abi: component_field_count set");
failures += check(api->component_field_at != nullptr,
"abi: component_field_at set");
failures += check(offsetof(EngineApiTable, component_describe) == 1256,
"abi: component_describe @ 1256 (4.24 tail)");
failures += check(offsetof(EngineApiTable, component_field_at) == 1272,
"abi: component_field_at @ 1272 (4.24 tail end)");
// P2-05 3D collision-event mirror: 32-byte record matching the 2D path.
failures += check(sizeof(EngineCollisionEvent) == 32, "abi: EngineCollisionEvent 32B");
failures += check(offsetof(EngineCollisionEvent, point_x) == 16,
+109
View File
@@ -330,5 +330,114 @@ auto main(int argc, char** argv) -> int {
w3.destroy();
}
// --- P3-04 named fields: Health { float Current; int Max } ---------------
// Described user components write named fields (not $bytes); legacy
// $bytes files still load (probe above covers that).
{
struct Health {
float current{0.0F};
int max{0};
};
struct HealthCtx {
const char* want{nullptr};
};
// Function-local statics: the resolver returns their address, so they
// must outlive the call (no stack temporaries).
static const engine::scene::SceneFieldDesc kHealthFields[] = {
{"Current", 0, 0}, // f32 @0
{"Max", 4, 1}, // i32 @4
};
auto describe = [](const char* comp_name, std::uint32_t* out_count,
void* user) noexcept
-> const engine::scene::SceneFieldDesc* {
const auto* ctx = static_cast<const HealthCtx*>(user);
if (comp_name == nullptr || out_count == nullptr || ctx == nullptr ||
ctx->want == nullptr) {
return nullptr;
}
if (std::strcmp(comp_name, ctx->want) != 0) {
return nullptr;
}
*out_count = 2;
return kHealthFields;
};
const std::string file_h = std::string(dir) + "/p304_health.json";
const std::string file_h2 = std::string(dir) + "/p304_health_b.json";
engine::ecs::EcsWorld wh;
failures += check(wh.construct(), "scene: health world construct");
const ecs_entity_t health_id = engine::ecs::RegisterRenderComponent(
wh.raw(), "Game.Health", sizeof(Health), alignof(Health));
failures += check(health_id != 0, "scene: Health registered");
{
const ecs_entity_t e = ecs_new(wh.raw());
ecs_set_name(wh.raw(), e, "Patient");
const ecs_entity_t local_id = ecs_lookup(wh.raw(), "LocalTransform");
SetPose(wh.raw(), local_id, e, 0, 0, 0, 1, 1, 1);
ecs_add_id(wh.raw(), e,
ecs_lookup(wh.raw(), "WorldTransform"));
const Health h{75.0F, 100};
ecs_set_id(wh.raw(), e, health_id, sizeof(h), &h);
}
HealthCtx hctx{"Game.Health"};
failures += check(engine::scene::save_scene_with_descriptions(
wh.raw(), file_h.c_str(), describe, &hctx) == 0,
"scene: health named save ok");
{
// The file must carry named fields, not a blob.
std::vector<std::uint8_t> bytes;
const bool ok = ReadFile(file_h.c_str(), bytes);
failures += check(ok, "scene: health file readable");
if (ok) {
const std::string text(bytes.begin(), bytes.end());
failures += check(
text.find("\"Game.Health\":{\"Current\":75,\"Max\":100}") !=
std::string::npos,
"scene: health named fields (Current 75, Max 100)");
}
}
// Load into a fresh world (register there first) and re-save: stable.
{
engine::ecs::EcsWorld wh2;
failures += check(wh2.construct(), "scene: health world2 construct");
const ecs_entity_t hid2 = engine::ecs::RegisterRenderComponent(
wh2.raw(), "Game.Health", sizeof(Health), alignof(Health));
failures += check(hid2 != 0, "scene: Health registered on w2");
const int32_t load_h = engine::scene::load_scene_with_descriptions(
wh2.raw(), file_h.c_str(), describe, &hctx);
failures += check(load_h >= 0, "scene: health named load ok");
// Verify values through the live component.
ecs_query_desc_t qd{};
qd.terms[0].id = hid2;
qd.cache_kind = EcsQueryCacheAuto;
ecs_query_t* q = ecs_query_init(wh2.raw(), &qd);
bool values_ok = false;
if (q != nullptr) {
ecs_iter_t it = ecs_query_iter(wh2.raw(), q);
while (ecs_query_next(&it)) {
for (int i = 0; i < it.count; ++i) {
const auto* h = static_cast<const Health*>(
ecs_field_w_size(&it, sizeof(Health), 0));
if (h[i].current == 75.0F && h[i].max == 100) {
values_ok = true;
}
}
}
ecs_query_fini(q);
}
failures += check(values_ok, "scene: health values round-trip");
failures += check(engine::scene::save_scene_with_descriptions(
wh2.raw(), file_h2.c_str(), describe,
&hctx) == 0,
"scene: health re-save ok");
std::vector<std::uint8_t> a;
std::vector<std::uint8_t> b;
const bool read_ok =
ReadFile(file_h.c_str(), a) && ReadFile(file_h2.c_str(), b);
failures += check(read_ok && a == b, "scene: health round-trip stable");
wh2.destroy();
}
wh.destroy();
}
return failures;
}
+53 -3
View File
@@ -471,7 +471,8 @@ internal static class Program
or "double" or "double*" or "EngineUi*" or "const EngineUi*"
or "EngineTimeline*" or "const EngineTimelineClip*" or "const EngineTimeline*"
or "EngineTimelineClip*" or "EngineTimelineActiveClip*" or "EngineTimelinePlayer*"
or "const EngineTimelinePlayer*" or "EngineAnimator*" or "const EngineAnimator*";
or "const EngineTimelinePlayer*" or "EngineAnimator*" or "const EngineAnimator*"
or "EngineFieldDesc*" or "const EngineFieldDesc*";
if (!ok)
{
throw new InvalidOperationException($"{header}:{line}: unsupported ABI type '{ctype}' (extend the generator's type map deliberately).");
@@ -587,6 +588,8 @@ internal static class Program
"const EngineTimelinePlayer*" => "EngineTimelinePlayer*",
"EngineAnimator*" => "EngineAnimator*",
"const EngineAnimator*" => "EngineAnimator*",
"EngineFieldDesc*" => "FieldDesc*",
"const EngineFieldDesc*" => "FieldDesc*",
_ => throw new InvalidOperationException($"no C# mapping for '{ctype}'"),
};
@@ -648,6 +651,7 @@ internal static class Program
sb.AppendLine();
var table = structs.Single(s => s.Name == "EngineApiTable");
EmitOpaqueHandles(sb);
EmitFieldDesc(sb, structs);
EmitPhysicsTypes(sb, structs);
EmitNavTypes(sb, structs);
EmitLocalTransform(sb, structs.Single(s => s.Name == "EngineLocalTransform"));
@@ -990,6 +994,52 @@ internal static class Program
sb.AppendLine();
}
private static void EmitFieldDesc(StringBuilder sb, List<StructDef> structs)
{
// P3-04 field-descriptor mirror (ABI 4.24): EngineFieldDesc (16B).
// Field order must match engine_abi.h exactly; AbiLayoutTests pins
// offsets (name @0, offset @8, type @12) and the loader pins size.
var desc = structs.Single(s => s.Name == "EngineFieldDesc");
string[] wantNames = ["name", "offset", "type"];
string[] wantTypes = ["const char*", "uint32_t", "uint32_t"];
if (desc.Fields.Count != wantNames.Length)
{
throw new InvalidOperationException($"EngineFieldDesc must have {wantNames.Length} fields (got {desc.Fields.Count}).");
}
for (var i = 0; i < wantNames.Length; i++)
{
if (desc.Fields[i].Name != wantNames[i] || desc.Fields[i].CType != wantTypes[i] || desc.Fields[i].ArrayLen != 0)
{
throw new InvalidOperationException($"EngineFieldDesc field {i} must be '{wantTypes[i]} {wantNames[i]}', got '{desc.Fields[i].CType} {desc.Fields[i].Name}'.");
}
}
sb.AppendLine(" /// <summary>Mirror of EngineFieldDesc: one described component field, 16 bytes (P3-04).</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8, Size = 16)]");
sb.AppendLine(" public unsafe struct FieldDesc");
sb.AppendLine(" {");
sb.AppendLine(" public byte* Name;");
sb.AppendLine(" public uint Offset;");
sb.AppendLine(" public uint Type;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>Field type enum (ENGINE_FIELD_*): 0 f32, 1 i32, 2 u32, 3 bool8, 4 vec2, 5 vec3, 6 vec4, 7 quat, 8 color4, 9 entity, 10 asset-ref.</summary>");
sb.AppendLine(" public enum FieldType : uint");
sb.AppendLine(" {");
sb.AppendLine(" F32 = 0,");
sb.AppendLine(" I32 = 1,");
sb.AppendLine(" U32 = 2,");
sb.AppendLine(" Bool8 = 3,");
sb.AppendLine(" Vec2 = 4,");
sb.AppendLine(" Vec3 = 5,");
sb.AppendLine(" Vec4 = 6,");
sb.AppendLine(" Quat = 7,");
sb.AppendLine(" Color4 = 8,");
sb.AppendLine(" Entity = 9,");
sb.AppendLine(" AssetRef = 10,");
sb.AppendLine(" }");
sb.AppendLine();
}
private static void EmitTimelineTypes(StringBuilder sb, List<StructDef> structs)
{
// P5-07 sequencer mirrors (ABI 4.20): EngineTimelineClip (32B).
@@ -1615,9 +1665,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 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64)");
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16)");
sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)}.\");");
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} FieldDesc={sizeof(FieldDesc)}.\");");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" _table = table;");