mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 10:55:58 -10:00
* [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:
1 parent
67e748c53e
commit
41c8e21893
20 files changed
+1759
-26
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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\":";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;");
|
||||
|
||||
Reference in new issue
Block a user