@forgeax/engine-scene 0.0.0-dev.8d955ade1c79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
  5. package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/components.unit.test.d.ts +2 -0
  7. package/dist/__tests__/components.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
  9. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
  11. package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
  13. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
  14. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
  17. package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/plugin.integration.test.d.ts +2 -0
  19. package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/propagation.unit.test.d.ts +2 -0
  21. package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
  24. package/dist/assets/scene-decoder.d.ts +8 -0
  25. package/dist/assets/scene-decoder.d.ts.map +1 -0
  26. package/dist/collect-subtree.d.ts +4 -0
  27. package/dist/collect-subtree.d.ts.map +1 -0
  28. package/dist/components/child-of.d.ts +31 -0
  29. package/dist/components/child-of.d.ts.map +1 -0
  30. package/dist/components/children.d.ts +48 -0
  31. package/dist/components/children.d.ts.map +1 -0
  32. package/dist/components/morph-weights.d.ts +5 -0
  33. package/dist/components/morph-weights.d.ts.map +1 -0
  34. package/dist/components/name.d.ts +4 -0
  35. package/dist/components/name.d.ts.map +1 -0
  36. package/dist/components/transform.d.ts +52 -0
  37. package/dist/components/transform.d.ts.map +1 -0
  38. package/dist/errors.d.ts +22 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/index.d.ts +15 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.mjs +1731 -0
  43. package/dist/index.mjs.map +1 -0
  44. package/dist/instances/collect-profile.d.ts +7 -0
  45. package/dist/instances/collect-profile.d.ts.map +1 -0
  46. package/dist/instances/externalization.d.ts +14 -0
  47. package/dist/instances/externalization.d.ts.map +1 -0
  48. package/dist/instances/scene-instances.d.ts +350 -0
  49. package/dist/instances/scene-instances.d.ts.map +1 -0
  50. package/dist/plugin.d.ts +3 -0
  51. package/dist/plugin.d.ts.map +1 -0
  52. package/dist/systems/hierarchy-projection.d.ts +16 -0
  53. package/dist/systems/hierarchy-projection.d.ts.map +1 -0
  54. package/dist/systems/index.d.ts +3 -0
  55. package/dist/systems/index.d.ts.map +1 -0
  56. package/dist/systems/propagate-transforms.d.ts +15 -0
  57. package/dist/systems/propagate-transforms.d.ts.map +1 -0
  58. package/package.json +58 -0
  59. package/src/__tests__/asset-owner.integration.test.ts +57 -0
  60. package/src/__tests__/components.unit.test.ts +26 -0
  61. package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
  62. package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
  63. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
  64. package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
  65. package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
  66. package/src/__tests__/plugin.integration.test.ts +23 -0
  67. package/src/__tests__/propagation.unit.test.ts +163 -0
  68. package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
  69. package/src/assets/scene-decoder.ts +153 -0
  70. package/src/collect-subtree.ts +30 -0
  71. package/src/components/child-of.ts +90 -0
  72. package/src/components/children.ts +113 -0
  73. package/src/components/morph-weights.ts +6 -0
  74. package/src/components/name.ts +22 -0
  75. package/src/components/transform.ts +89 -0
  76. package/src/errors.ts +34 -0
  77. package/src/index.ts +65 -0
  78. package/src/instances/collect-profile.ts +10 -0
  79. package/src/instances/externalization.ts +137 -0
  80. package/src/instances/scene-instances.ts +1564 -0
  81. package/src/plugin.ts +28 -0
  82. package/src/systems/hierarchy-projection.ts +134 -0
  83. package/src/systems/index.ts +11 -0
  84. package/src/systems/propagate-transforms.ts +376 -0
@@ -0,0 +1,1564 @@
1
+ // @forgeax/engine-scene — scene instantiation and instance-state subsystem.
2
+
3
+ import {
4
+ type Component,
5
+ type ComponentData,
6
+ type ComponentSchema,
7
+ type EcsError,
8
+ ENTITY_NULL_RAW,
9
+ type EntityHandle,
10
+ type InputShapeOf,
11
+ type ShapeOf,
12
+ type World,
13
+ } from '@forgeax/engine-ecs';
14
+ import { classifyEntityField, remapEntityFieldValue } from '@forgeax/engine-ecs/externalization';
15
+ import { componentSchema } from '@forgeax/engine-ecs/internal';
16
+ import { fillComponentDefaults, StaleEntityError } from '@forgeax/engine-ecs/projection';
17
+ import type {
18
+ Handle,
19
+ LocalEntityId,
20
+ MountOverride,
21
+ PackErrorCode,
22
+ PackErrorDetail,
23
+ SceneAsset,
24
+ SceneInstanceMount,
25
+ } from '@forgeax/engine-types';
26
+ import {
27
+ err,
28
+ ok,
29
+ PACK_ERROR_HINTS,
30
+ type Result,
31
+ toUnique,
32
+ unwrapHandle,
33
+ } from '@forgeax/engine-types';
34
+ import { ComponentNotDefinedError } from '../errors';
35
+
36
+ const entityIndex = (entity: EntityHandle): number => (entity as number) & 0x00ffffff;
37
+ const entityGeneration = (entity: EntityHandle): number => ((entity as number) >>> 24) & 0xff;
38
+
39
+ /**
40
+ * C-R2 (feat-20260622-s5 / studio-issues): one structured, non-fatal record of
41
+ * a SceneAsset payload field that did NOT match the target component's schema.
42
+ *
43
+ * Scene data is loader-fed and may carry a stale / deprecated / typo'd field
44
+ * (an editor renames a field, an old `.pack.json` lags). `worldInstantiateScene`
45
+ * does NOT blank the whole scene over one such field (#478 lesson: a
46
+ * prod-silent strip re-introduced an invisible-entity class) and does NOT abort
47
+ * fatally. Instead it skips the unknown key (no write, no input mutation) and
48
+ * surfaces this record on the success value's `diagnostics[]` — observable in
49
+ * production (NOT NODE_ENV-gated), consumed by property access (no string parse):
50
+ *
51
+ * const r = worldInstantiateScene(world, handle);
52
+ * if (r.ok) for (const d of r.value.diagnostics)
53
+ * console.warn(`unknown field ${d.component}.${d.field} on localId ${d.localId}`);
54
+ *
55
+ * Direct `world.spawn` / `world.addComponent` / `Commands.spawn` stay fail-fast
56
+ * with `SpawnDataUnknownFieldError` — those are explicit API calls where a typo
57
+ * is a programming error, not loader-fed data.
58
+ */
59
+ export type SceneInstantiateDiagnostic = {
60
+ /** Component name (schema key) the unknown field appeared under. */
61
+ readonly component: string;
62
+ /** The offending field name not declared in the component schema. */
63
+ readonly field: string;
64
+ /** LocalEntityId (within its owning SceneAsset) of the carrying entity. */
65
+ readonly localId: number;
66
+ };
67
+
68
+ /**
69
+ * Success value of `worldInstantiateScene`. `root` is the synthetic scene-root
70
+ * EntityHandle (carries `SceneInstance`); `diagnostics` is the (possibly empty)
71
+ * list of non-fatal unknown-field records aggregated across this scene and every
72
+ * recursively mounted sub-scene (C-R2). Empty array = no diagnostics.
73
+ */
74
+ export type SceneInstantiateOk = {
75
+ readonly root: EntityHandle;
76
+ readonly diagnostics: readonly SceneInstantiateDiagnostic[];
77
+ };
78
+
79
+ /**
80
+ * Success value of `worldInstantiateSceneFlat` — the "edit the scene itself"
81
+ * primitive. Unlike `instantiateScene`, NO synthetic SceneInstance root is
82
+ * minted and NO `ChildOf` is forced onto top-level members: the scene's own
83
+ * entities become plain top-level world entities whose hierarchy is exactly
84
+ * their authored `ChildOf` (an entity with no `ChildOf` stays a root). `roots`
85
+ * is the set of those top-level handles (own rootless entities + top-level
86
+ * mount carriers). Nested prefabs inside the scene STILL materialise as their
87
+ * own SceneInstance anchors (charter P4: instance == entity-with-SceneInstance)
88
+ * — only THIS scene is flat.
89
+ */
90
+ export type SceneInstantiateFlatOk = {
91
+ readonly roots: EntityHandle[];
92
+ /**
93
+ * All mount carrier entities spawned while flattening this scene. These are
94
+ * separate from `roots`: carriers with an authored parent are not roots,
95
+ * but still delimit a nested prefab subtree for post-spawn hooks.
96
+ */
97
+ readonly mountEntities: EntityHandle[];
98
+ readonly diagnostics: readonly SceneInstantiateDiagnostic[];
99
+ };
100
+
101
+ /**
102
+ * @internal Intermediate produced by `_spawnSceneMembers` and consumed by both
103
+ * the anchor finisher (`_instantiateSceneAsset`) and the flat finisher
104
+ * (`_instantiateSceneAssetFlat`). Holds everything the shared member-spawn
105
+ * (mounts recursion + own-entity spawn + deferred owned-parent wiring) computes,
106
+ * before either finisher decides whether to wrap the members in a synthetic
107
+ * SceneInstance root.
108
+ */
109
+ export interface SceneMembersSpawn {
110
+ /** LocalEntityId → live Entity u32 (ENTITY_NULL_RAW for unspawned slots). */
111
+ readonly mapping: Uint32Array;
112
+ /** Reverse map live Entity → LocalEntityId for override / detach bookkeeping. */
113
+ readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
114
+ /** Own entities that carried no `ChildOf` — the scene's authored top-level roots. */
115
+ readonly rootEntities: EntityHandle[];
116
+ /** Mount carriers whose `mount.parent === undefined` (default-parented). */
117
+ readonly mountEntitiesNeedingRootParent: EntityHandle[];
118
+ /** Every mount carrier spawned by this scene, including explicitly parented carriers. */
119
+ readonly mountEntities: EntityHandle[];
120
+ /**
121
+ * The child anchor and mapping for each mount. Flat scene opening has no
122
+ * outer SceneInstance state to own parent-namespace mount overrides, so it
123
+ * records those overrides on this child anchor after the shared spawn pass.
124
+ */
125
+ readonly mountInstances: readonly {
126
+ readonly mount: SceneInstanceMount;
127
+ readonly root: EntityHandle;
128
+ readonly mapping: Uint32Array;
129
+ }[];
130
+ /** `entities.length + mounts + Σ memberCount`, captured at instantiate-time. */
131
+ readonly totalSlots: number;
132
+ }
133
+
134
+ export type SceneAssetResolver = (
135
+ source: number | string,
136
+ parentHandle: Handle<'SceneAsset', 'shared'>,
137
+ ) => Result<Handle<'SceneAsset', 'shared'>, unknown>;
138
+
139
+ interface SceneWorldState {
140
+ resolver: SceneAssetResolver | null;
141
+ readonly statePayloads: Map<number, unknown>;
142
+ }
143
+
144
+ const sceneWorldStates = new WeakMap<World, SceneWorldState>();
145
+
146
+ function sceneWorldState(world: World): SceneWorldState {
147
+ const current = sceneWorldStates.get(world);
148
+ if (current !== undefined) return current;
149
+ const created = { resolver: null, statePayloads: new Map<number, unknown>() };
150
+ sceneWorldStates.set(world, created);
151
+ return created;
152
+ }
153
+
154
+ /** @internal */
155
+ export function worldSetSceneAssetResolver(world: World, resolver: SceneAssetResolver): void {
156
+ sceneWorldState(world).resolver = resolver;
157
+ }
158
+
159
+ /** @internal */
160
+ export function worldGetSceneAssetResolver(world: World): SceneAssetResolver | null {
161
+ return sceneWorldState(world).resolver;
162
+ }
163
+
164
+ /**
165
+ * Materialise a SceneAsset (and any nested SceneAsset references via
166
+ * `mounts[]`) into live entities. Returns the synthetic root Entity that
167
+ * carries the `SceneInstance` ECS component (charter P4: instance ==
168
+ * entity-with-SceneInstance).
169
+ *
170
+ * Recursion path is closed inside `_instantiateSceneRec(handle, parent,
171
+ * stack)` (D-3); cycle detection is fail-fast `pack-cyclic-reference +
172
+ * detail.kind:'mount-asset'` (D-1 mirror, plan-strategy §D-3). The
173
+ * caller-supplied `parent` flows to the synthetic root's `ChildOf` so the
174
+ * full sub-tree attaches under the AI user's host entity.
175
+ *
176
+ * @example
177
+ * const r = worldInstantiateScene(world, handle);
178
+ * if (!r.ok) return r;
179
+ * const { root, diagnostics } = r.value;
180
+ * for (const d of diagnostics) // C-R2: unknown-field records, non-fatal
181
+ * console.warn(`unknown field ${d.component}.${d.field} on localId ${d.localId}`);
182
+ * const inst = world.get(root, SceneInstance).value;
183
+ * const member = inst.mapping[0]; // first member entity
184
+ */
185
+ export function worldInstantiateScene(
186
+ world: World,
187
+ handle: Handle<'SceneAsset', 'shared'>,
188
+ parent?: EntityHandle,
189
+ ): Result<SceneInstantiateOk, EcsError> {
190
+ const stack = new Set<number>();
191
+ // C-R2: collect non-fatal unknown-field diagnostics across this scene and
192
+ // every recursively mounted sub-scene. The internal recursion writes into
193
+ // this accumulator; only the public entry packages it onto the success value.
194
+ const diagnostics: SceneInstantiateDiagnostic[] = [];
195
+ const r = worldInstantiateSceneRec(world, handle, parent, stack, diagnostics);
196
+ if (!r.ok) return r;
197
+ return ok({ root: r.value, diagnostics });
198
+ }
199
+
200
+ /**
201
+ * Materialise a projected `SceneAsset` payload without exposing the temporary
202
+ * shared-ref handle to the caller. The World remains the owner of both the
203
+ * handle and the instantiated SceneInstance: the producer grant is released
204
+ * after the SceneInstance retains its source, including when instantiation
205
+ * fails. This is the payload-shaped counterpart to `worldInstantiateScene` for
206
+ * hosts that load a SceneAsset directly from the Engine AssetRegistry.
207
+ */
208
+ export function worldInstantiateScenePayload(
209
+ world: World,
210
+ asset: SceneAsset,
211
+ parent?: EntityHandle,
212
+ ): Result<SceneInstantiateOk, EcsError> {
213
+ const handle = world.allocSharedRef('SceneAsset', asset);
214
+ try {
215
+ return worldInstantiateScene(world, handle, parent);
216
+ } finally {
217
+ // The SceneInstance source column retains the handle on success. On a
218
+ // failed materialisation there should be no remaining holder; either way
219
+ // release the producer grant owned by this convenience entrypoint.
220
+ world.sharedRefs.release(handle);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Materialise a SceneAsset FLAT — the "edit the scene itself" primitive.
226
+ * Unlike `instantiateScene`, this mints NO synthetic SceneInstance root and
227
+ * forces NO `ChildOf` onto top-level members: the scene's own entities become
228
+ * plain top-level world entities whose hierarchy is exactly their authored
229
+ * `ChildOf` (an entity with no `ChildOf` is a root). Use this to OPEN a scene
230
+ * for editing; use `instantiateScene` (anchor) at runtime / for nested
231
+ * prefabs where an instance boundary + override isolation is wanted.
232
+ *
233
+ * Nested prefabs referenced via `mounts[]` STILL materialise as their own
234
+ * SceneInstance anchors (charter P4 preserved) — only THIS top scene is flat.
235
+ *
236
+ * @example
237
+ * const r = worldInstantiateSceneFlat(world, handle);
238
+ * if (!r.ok) return r;
239
+ * const { roots, diagnostics } = r.value; // roots = top-level handles
240
+ */
241
+ export function worldInstantiateSceneFlat(
242
+ world: World,
243
+ handle: Handle<'SceneAsset', 'shared'>,
244
+ ): Result<SceneInstantiateFlatOk, EcsError> {
245
+ const stack = new Set<number>();
246
+ const diagnostics: SceneInstantiateDiagnostic[] = [];
247
+ const handleKey = unwrapHandle(handle);
248
+ const resolved = worldResolveSceneAsset(world, handle);
249
+ if (!resolved.ok) return resolved;
250
+ stack.add(handleKey);
251
+ let r: Result<{ roots: EntityHandle[]; mountEntities: EntityHandle[] }, EcsError>;
252
+ try {
253
+ r = worldInstantiateSceneAssetFlat(world, handle, resolved.value, stack, diagnostics);
254
+ } finally {
255
+ stack.delete(handleKey);
256
+ }
257
+ if (!r.ok) return r;
258
+ return ok({ ...r.value, diagnostics });
259
+ }
260
+ /**
261
+ * @internal Recursive helper carrying the cycle-detection stack. Sugar /
262
+ * other public callers must not see this mechanic — use `instantiateScene`
263
+ * (D-3 / charter P1).
264
+ */
265
+ export function worldInstantiateSceneRec(
266
+ world: World,
267
+ handle: Handle<'SceneAsset', 'shared'>,
268
+ parent: EntityHandle | undefined,
269
+ stack: Set<number>,
270
+ diagnostics: SceneInstantiateDiagnostic[],
271
+ ): Result<EntityHandle, EcsError> {
272
+ const handleKey = unwrapHandle(handle);
273
+ if (stack.has(handleKey)) {
274
+ const cycleArr: string[] = [];
275
+ for (const k of stack) cycleArr.push(String(k));
276
+ cycleArr.push(String(handleKey));
277
+ const detail: PackErrorDetail = {
278
+ code: 'pack-cyclic-reference',
279
+ kind: 'mount-asset',
280
+ cycle: cycleArr,
281
+ };
282
+ return err({
283
+ code: 'pack-cyclic-reference' as PackErrorCode,
284
+ expected: 'acyclic SceneAsset mount graph',
285
+ hint: PACK_ERROR_HINTS['pack-cyclic-reference'],
286
+ detail,
287
+ } as unknown as EcsError);
288
+ }
289
+ const resolved = worldResolveSceneAsset(world, handle);
290
+ if (!resolved.ok) return resolved;
291
+ const asset = resolved.value;
292
+ stack.add(handleKey);
293
+ try {
294
+ return worldInstantiateSceneAsset(world, handle, asset, parent, stack, diagnostics);
295
+ } finally {
296
+ stack.delete(handleKey);
297
+ }
298
+ }
299
+ /**
300
+ * @internal Resolve a SceneAsset handle through the SharedRefStore.
301
+ * The handle u32 is the SharedRefStore slot id (`world.allocSharedRef
302
+ * ('SceneAsset', asset)` is the producer; rc starts at 1, the SceneInstance
303
+ * spawn retains to rc=2 in M4 / w13). Errors propagate as EcsError so the
304
+ * instantiateScene chain returns a single closed union.
305
+ */
306
+ export function worldResolveSceneAsset(
307
+ world: World,
308
+ handle: Handle<'SceneAsset', 'shared'>,
309
+ ): Result<SceneAsset, EcsError> {
310
+ const r = world.sharedRefs.resolve(handle);
311
+ if (!r.ok) {
312
+ return err(r.error as unknown as EcsError);
313
+ }
314
+ return ok(r.value as SceneAsset);
315
+ }
316
+ /**
317
+ * @internal Spawn one SceneAsset's members — the shared body of both scene
318
+ * finishers. Recurses into `mounts[]` (each nested prefab becomes its own
319
+ * SceneInstance anchor), spawns `entities[]` honouring their authored
320
+ * `ChildOf`, and wires deferred owned-parent mount edges. Does NOT create a
321
+ * synthetic root or force any `ChildOf` — that is the caller's (finisher's)
322
+ * job. `_instantiateSceneRec` owns cycle bookkeeping.
323
+ */
324
+ export function worldSpawnSceneMembers(
325
+ world: World,
326
+ handle: Handle<'SceneAsset', 'shared'>,
327
+ asset: SceneAsset,
328
+ stack: Set<number>,
329
+ diagnostics: SceneInstantiateDiagnostic[],
330
+ ): Result<SceneMembersSpawn, EcsError> {
331
+ const sceneInstanceToken = world.components.resolve('SceneInstance');
332
+ if (sceneInstanceToken === undefined) {
333
+ return err(new ComponentNotDefinedError('SceneInstance'));
334
+ }
335
+ const childOfToken = world.components.resolve('ChildOf');
336
+ // ChildOf is optional — only needed if the asset declares ChildOf or a
337
+ // caller-supplied parent must be wired. If absent and we need it, we
338
+ // fail-fast at the wiring site below.
339
+
340
+ const ownEntities = asset.entities;
341
+ const ownMounts = asset.mounts ?? [];
342
+ const memberSum = ownMounts.reduce((s, m) => s + m.memberCount, 0);
343
+ const countBaseline = ownEntities.length + ownMounts.length + memberSum;
344
+ // C-R1 (studio-issues #6): mapping table must be sized to maxLocalId+1,
345
+ // not to the entity count. An editor scene may have non-contiguous
346
+ // localIds (deleted entities leave gaps); sizing to count means any
347
+ // localId >= count is a silent Uint32Array OOB no-op -> entity spawns
348
+ // but is unreachable by localId -> users report "character can't move".
349
+ // Take the max of count-baseline and id-range so both packed and
350
+ // sparse scenes work without over-allocation in the common case.
351
+ let maxLocalId = ownEntities.reduce((m, e) => Math.max(m, e.localId as unknown as number), -1);
352
+ for (const mount of ownMounts) {
353
+ maxLocalId = Math.max(maxLocalId, mount.localId as unknown as number);
354
+ const last = (mount.memberFirst as unknown as number) + mount.memberCount - 1;
355
+ maxLocalId = Math.max(maxLocalId, last);
356
+ }
357
+ const totalSlots = Math.max(countBaseline, maxLocalId + 1);
358
+
359
+ // R2/Bonus: namespace-overlap fail-fast (AC-05 /
360
+ // pack-mount-localid-overlap). Each LocalEntityId in
361
+ // [0, totalSlots) must be claimed by exactly one of:
362
+ // - entities[i].localId
363
+ // - mounts[i].localId
364
+ // - mounts[i] window slot (memberFirst .. memberFirst+memberCount-1)
365
+ // Overlap or duplicate claim => fail-fast with the offending localIds
366
+ // and human-readable origin labels.
367
+ {
368
+ const claims = new Map<number, string>();
369
+ const overlapLids = new Set<number>();
370
+ const overlapSources: string[] = [];
371
+ const claim = (lid: number, src: string): void => {
372
+ const prior = claims.get(lid);
373
+ if (prior !== undefined) {
374
+ if (!overlapLids.has(lid)) {
375
+ overlapLids.add(lid);
376
+ overlapSources.push(prior);
377
+ overlapSources.push(src);
378
+ } else {
379
+ overlapSources.push(src);
380
+ }
381
+ return;
382
+ }
383
+ claims.set(lid, src);
384
+ };
385
+ for (const ent of ownEntities) {
386
+ claim(ent.localId as unknown as number, `entities[${ent.localId as unknown as number}]`);
387
+ }
388
+ for (const mount of ownMounts) {
389
+ const mLid = mount.localId as unknown as number;
390
+ claim(mLid, `mount[${mLid}]`);
391
+ const first = mount.memberFirst as unknown as number;
392
+ for (let k = 0; k < mount.memberCount; k += 1) {
393
+ claim(first + k, `mount[${mLid}].member[${k}]`);
394
+ }
395
+ }
396
+ if (overlapLids.size > 0) {
397
+ const overlapping = Array.from(overlapLids).sort((a, b) => a - b);
398
+ return err({
399
+ code: 'pack-mount-localid-overlap' as PackErrorCode,
400
+ expected: 'each LocalEntityId claimed by exactly one entity or mount slot',
401
+ hint: PACK_ERROR_HINTS['pack-mount-localid-overlap'],
402
+ detail: {
403
+ code: 'pack-mount-localid-overlap',
404
+ overlapping,
405
+ sources: overlapSources,
406
+ } as PackErrorDetail,
407
+ } as unknown as EcsError);
408
+ }
409
+ }
410
+
411
+ // Slot table: indexed by LocalEntityId; populated as entities / mounts /
412
+ // members are spawned. mapping[localId] = encoded Entity u32. Unspawned
413
+ // slots hold ENTITY_NULL_RAW (0xffffffff) — NOT 0, because a fresh World's
414
+ // first spawn encodes to gen=0+idx=0=raw 0, which is a valid Entity. The
415
+ // remap path in `_buildSceneEntityComponentDatas` distinguishes the two
416
+ // (live=ENTITY_NULL_RAW => parent unspawned at remap time => surface as
417
+ // null sentinel; live=any other u32 => valid live Entity, including 0).
418
+ const mapping = new Uint32Array(totalSlots).fill(ENTITY_NULL_RAW);
419
+ const entityToLocalId = new Map<EntityHandle, LocalEntityId>();
420
+ const rootEntities: EntityHandle[] = [];
421
+ const mountEntities: EntityHandle[] = [];
422
+ // R2/B-1: mount entities whose `mount.parent === undefined` need their
423
+ // ChildOf wired to the outer synthetic root (this scene's root). Step 5
424
+ // does the wiring once the synthetic root entity is materialised; we
425
+ // collect them here in step 1.
426
+ const mountEntitiesNeedingRootParent: EntityHandle[] = [];
427
+ // D-8 (feat-20260707): mount entities whose `mount.parent` points at an
428
+ // OWNED entity slot are wired AFTER step 2 spawns the owned entities —
429
+ // mounts are processed first (step 1), so the owned parent slot is still
430
+ // ENTITY_NULL_RAW at mount-processing time. Same deferred-wiring shape as
431
+ // mountEntitiesNeedingRootParent: register [mountEntity, parentSlot] here,
432
+ // wire ChildOf once the slot is live. Without this the edge was silently
433
+ // dropped, and the mount carrier stayed unreachable from its owned parent.
434
+ const mountEntitiesNeedingDeferredParent: Array<[EntityHandle, number]> = [];
435
+ const mountInstances: Array<{
436
+ readonly mount: SceneInstanceMount;
437
+ readonly root: EntityHandle;
438
+ readonly mapping: Uint32Array;
439
+ }> = [];
440
+
441
+ // 1. Recurse into mounts[] FIRST so the mount-window slots
442
+ // (`mount.localId` + `[memberFirst, memberFirst+memberCount)`) are
443
+ // populated before any owned entity tries to remap a LocalEntityId
444
+ // pointing into the mount window (AC-24 cross-boundary reference).
445
+ for (const mount of ownMounts) {
446
+ // R2/B-3 + R2/B-4: validate overrides BEFORE child resolution so a
447
+ // malformed override fails fast without observable side-effects.
448
+ const overrideValidationRes = worldValidateMountOverrides(world, mount);
449
+ if (!overrideValidationRes.ok) {
450
+ return overrideValidationRes;
451
+ }
452
+
453
+ // Spawn the mount entity (carries mount.components).
454
+ const mountLid = mount.localId as unknown as number;
455
+ const mountSpawnRes = worldSpawnMountEntity(world, mount, mapping, diagnostics);
456
+ if (!mountSpawnRes.ok) return mountSpawnRes;
457
+ const mountEntity = mountSpawnRes.value;
458
+ mountEntities.push(mountEntity);
459
+ mapping[mountLid] = mountEntity as unknown as number;
460
+
461
+ // Resolve mount.source -> child SceneAsset handle.
462
+ const childHandleRes = worldResolveMountSource(world, mount.source, handle);
463
+ if (!childHandleRes.ok) return childHandleRes;
464
+ const childHandle = childHandleRes.value;
465
+
466
+ // Recursively instantiate the child. Its synthetic root attaches as a
467
+ // child of the mount entity. The child writes its own unknown-field
468
+ // diagnostics into the SAME accumulator, so they bubble to the top-level
469
+ // instantiateScene result (C-R2 recursive aggregation).
470
+ const childRes = worldInstantiateSceneRec(world, childHandle, mountEntity, stack, diagnostics);
471
+ if (!childRes.ok) return childRes;
472
+
473
+ // R2/B-2: cross-check mount.memberCount === child.totalSlots BEFORE
474
+ // copying the mount window. The child SceneInstance.mapping length is
475
+ // the authoritative `totalSlots` of the child. AC-04 / requirements
476
+ // S-5 mandate fail-fast at runtime for this disagreement.
477
+ const childInstRes = world.get(childRes.value, sceneInstanceToken);
478
+ if (!childInstRes.ok) return childInstRes;
479
+ const childMapping = (childInstRes.value as unknown as { mapping: Uint32Array }).mapping;
480
+ mountInstances.push({ mount, root: childRes.value, mapping: childMapping });
481
+ if (childMapping.length !== mount.memberCount) {
482
+ return err({
483
+ code: 'pack-mount-count-mismatch' as PackErrorCode,
484
+ expected: 'mount.memberCount === child SceneAsset totalSlots',
485
+ hint: PACK_ERROR_HINTS['pack-mount-count-mismatch'],
486
+ detail: {
487
+ code: 'pack-mount-count-mismatch',
488
+ mountLocalId: mountLid,
489
+ declared: mount.memberCount,
490
+ actual: childMapping.length,
491
+ } as PackErrorDetail,
492
+ } as unknown as EcsError);
493
+ }
494
+
495
+ // Pull the child's mapping into our parent window. Default unset slots
496
+ // to ENTITY_NULL_RAW so downstream "live" checks distinguish them from
497
+ // the first Entity (gen=0+idx=0 encodes to raw u32 0).
498
+ const window = mount.memberCount;
499
+ for (let k = 0; k < window; k += 1) {
500
+ mapping[(mount.memberFirst as unknown as number) + k] = childMapping[k] ?? ENTITY_NULL_RAW;
501
+ }
502
+
503
+ // Apply mount.overrides at instantiate-time (AC-19).
504
+ // Each override.localId addresses a slot in *this* (parent) namespace
505
+ // (R2/F-8 cement: parent-namespace + memberFirst+offset addressing).
506
+ // The state map will be populated below with these overrides — but we
507
+ // must also write the value through to the live entity column so the
508
+ // readback invariant holds.
509
+ // Mount-entity itself never has children attached by the caller other
510
+ // than via the recursive child; nothing else to wire here.
511
+ if (childOfToken !== undefined) {
512
+ if (mount.parent !== undefined) {
513
+ // Reparent the mount-entity ChildOf to the caller-specified parent.
514
+ const parentSlot = mount.parent as unknown as number;
515
+ const parentEntity = mapping[parentSlot];
516
+ if (parentEntity !== undefined && parentEntity !== ENTITY_NULL_RAW) {
517
+ const r = world.addComponent(mountEntity, {
518
+ component: childOfToken,
519
+ data: { parent: parentEntity } as never,
520
+ });
521
+ if (!r.ok) {
522
+ // ChildOf may already be present from layer-1; reparent via set.
523
+ const set = world.set(mountEntity, childOfToken, {
524
+ parent: parentEntity,
525
+ } as never);
526
+ if (!set.ok) return set as Result<SceneMembersSpawn, EcsError>;
527
+ }
528
+ } else {
529
+ // D-8: the owned parent slot is not spawned yet (owned entities
530
+ // spawn in step 2, after this mount loop). Defer the ChildOf wire
531
+ // to step 2's tail once mapping[parentSlot] is live.
532
+ mountEntitiesNeedingDeferredParent.push([mountEntity, parentSlot]);
533
+ }
534
+ } else {
535
+ // R2/B-1: default semantic — mount.parent === undefined wires the
536
+ // mount entity ChildOf to *this* scene's synthetic root (created
537
+ // in step 3 below). Defer the actual wire to step 5 after the
538
+ // synthetic root spawn; record the mount entity here.
539
+ mountEntitiesNeedingRootParent.push(mountEntity);
540
+ }
541
+ }
542
+ }
543
+
544
+ // 2. Spawn entities[] entities. Topo-sort by ChildOf so parents are
545
+ // spawned before children (so localId remap can read mapping live).
546
+ // This runs AFTER mount processing (step 1) so cross-boundary
547
+ // `ChildOf {parent: <mount-window-localId>}` references resolve
548
+ // correctly (AC-24).
549
+ const order = sceneTopoSort(ownEntities);
550
+ for (const idx of order) {
551
+ const node = ownEntities[idx];
552
+ if (node === undefined) continue;
553
+ const lid = node.localId as unknown as number;
554
+ const compDataRes = worldBuildSceneEntityComponentDatas(world, node, mapping, diagnostics);
555
+ if (!compDataRes.ok) return compDataRes;
556
+ const sp = (world.spawn as (...c: ComponentData[]) => Result<EntityHandle, EcsError>)(
557
+ ...compDataRes.value,
558
+ );
559
+ if (!sp.ok) return sp as Result<SceneMembersSpawn, EcsError>;
560
+ const e = sp.value;
561
+ mapping[lid] = e as unknown as number;
562
+ entityToLocalId.set(e, lid as unknown as LocalEntityId);
563
+ if (node.components.ChildOf === undefined) {
564
+ rootEntities.push(e);
565
+ }
566
+ }
567
+
568
+ // 2b. D-8 (feat-20260707): wire deferred owned-parent mount ChildOf edges.
569
+ // Owned entities are now live (step 2 above), so mapping[parentSlot]
570
+ // resolves. Same shape as the mountEntitiesNeedingRootParent wiring in
571
+ // step 5. The relationship mirror hook (relationshipOnInsert) pushes the
572
+ // carrier into the owned parent's Children mirror automatically.
573
+ if (childOfToken !== undefined) {
574
+ for (const [mountEntity, parentSlot] of mountEntitiesNeedingDeferredParent) {
575
+ const parentEntity = mapping[parentSlot];
576
+ if (parentEntity === undefined || parentEntity === ENTITY_NULL_RAW) continue;
577
+ const set = world.set(mountEntity, childOfToken, { parent: parentEntity } as never);
578
+ if (!set.ok) {
579
+ const r = world.addComponent(mountEntity, {
580
+ component: childOfToken,
581
+ data: { parent: parentEntity } as never,
582
+ });
583
+ if (!r.ok) return r as Result<SceneMembersSpawn, EcsError>;
584
+ }
585
+ }
586
+ }
587
+
588
+ return ok({
589
+ mapping,
590
+ entityToLocalId,
591
+ rootEntities,
592
+ mountEntitiesNeedingRootParent,
593
+ mountEntities,
594
+ mountInstances,
595
+ totalSlots,
596
+ });
597
+ }
598
+ /**
599
+ * @internal Spawn one SceneAsset's entities + apply mounts recursively, then
600
+ * wrap them in a synthetic SceneInstance root (the anchor). This is the
601
+ * runtime / Play / nested-mount finisher (charter P4: instance ==
602
+ * entity-with-SceneInstance). Caller (`_instantiateSceneRec`) owns cycle
603
+ * bookkeeping.
604
+ */
605
+ export function worldInstantiateSceneAsset(
606
+ world: World,
607
+ handle: Handle<'SceneAsset', 'shared'>,
608
+ asset: SceneAsset,
609
+ parent: EntityHandle | undefined,
610
+ stack: Set<number>,
611
+ diagnostics: SceneInstantiateDiagnostic[],
612
+ ): Result<EntityHandle, EcsError> {
613
+ const sceneInstanceToken = world.components.resolve('SceneInstance');
614
+ if (sceneInstanceToken === undefined) {
615
+ return err(new ComponentNotDefinedError('SceneInstance'));
616
+ }
617
+ const childOfToken = world.components.resolve('ChildOf');
618
+
619
+ const membersRes = worldSpawnSceneMembers(world, handle, asset, stack, diagnostics);
620
+ if (!membersRes.ok) return membersRes;
621
+ const { mapping, entityToLocalId, rootEntities, mountEntitiesNeedingRootParent, totalSlots } =
622
+ membersRes.value;
623
+ const { mountInstances } = membersRes.value;
624
+ const ownMounts = asset.mounts ?? [];
625
+
626
+ // 3. Spawn the synthetic root entity carrying SceneInstance.
627
+ // First alloc the state ref so the SceneInstance.state column has a
628
+ // live u32; then attach SceneInstance to a fresh entity.
629
+ let stateRef: Handle<'SceneInstanceState', 'unique'>;
630
+ stateRef = world.allocUniqueRef('SceneInstanceState', null, () => {
631
+ sceneWorldState(world).statePayloads.delete(Number(stateRef));
632
+ });
633
+ // Spawn the root with SceneInstance component, mapping snapshot, and
634
+ // state ref. The mapping is a Uint32Array (array<entity> field shape).
635
+ // Convert mapping Uint32Array to plain number[] for spawn write — the
636
+ // ECS array<entity> arm copies element-by-element and accepts both, but
637
+ // the plain-array form sidesteps a Uint32Array.length=0 corner case
638
+ // observed during M2 testing where a non-empty Uint32Array was written
639
+ // as if empty (suspect: archetype write-array dispatch on instanceof
640
+ // Array vs TypedArray).
641
+ const mappingPlain: number[] = Array.from(mapping);
642
+ // The synthetic root is the ChildOf parent of every owned root entity
643
+ // (step 5 below) and may itself become a ChildOf parent of a caller-
644
+ // supplied `parent` chain. propagateTransforms walks ChildOf parents
645
+ // through the Transform liveMap and treats a parent missing Transform
646
+ // as `hierarchy-broken`, so the synthetic root must carry Transform
647
+ // (identity TRS via layer-2 defaults) when Transform is defined.
648
+ const rootComponents: ComponentData[] = [
649
+ {
650
+ component: sceneInstanceToken,
651
+ data: {
652
+ source: handle,
653
+ mapping: mappingPlain,
654
+ state: stateRef,
655
+ } as never,
656
+ },
657
+ ];
658
+ const transformToken = world.components.resolve('Transform');
659
+ if (transformToken !== undefined) {
660
+ rootComponents.push({
661
+ component: transformToken,
662
+ data: {} as never,
663
+ });
664
+ }
665
+ const rootSpawn = (world.spawn as (...c: ComponentData[]) => Result<EntityHandle, EcsError>)(
666
+ ...rootComponents,
667
+ );
668
+ if (!rootSpawn.ok) {
669
+ return rootSpawn;
670
+ }
671
+ const rootEntity = rootSpawn.value;
672
+
673
+ // 4. Build SceneInstanceState payload + register it in the UniqueRefStore
674
+ // under the same handle. We use the public `_setUniqueRefPayload`
675
+ // helper (added below) so the alloc -> populate sequence stays atomic.
676
+ const overrides = new Map<LocalEntityId, Map<string, MountOverride>>();
677
+ for (const mount of ownMounts) {
678
+ for (const ov of mount.overrides ?? []) {
679
+ // feat-20260713 M2 / w8: `MountOverride.field` is optional (add-or-patch
680
+ // discriminant carried by the shape itself). Record the override into
681
+ // the SceneInstanceState map keyed by comp (no field) or comp:field
682
+ // (field-patch), then apply it to the live member column via the shared
683
+ // add-or-patch helper.
684
+ const lid = ov.localId as unknown as LocalEntityId;
685
+ let fieldMap = overrides.get(lid);
686
+ if (fieldMap === undefined) {
687
+ fieldMap = new Map();
688
+ overrides.set(lid, fieldMap);
689
+ }
690
+ fieldMap.set(mountOverrideStateKey(ov), ov);
691
+ // Apply override to the live member entity column.
692
+ const memberEntityRaw = mapping[lid as unknown as number];
693
+ if (memberEntityRaw !== undefined && memberEntityRaw !== ENTITY_NULL_RAW) {
694
+ const memberEntity = memberEntityRaw as unknown as EntityHandle;
695
+ const applyRes = worldApplyMountOverride(world, memberEntity, ov);
696
+ if (!applyRes.ok) {
697
+ return applyRes as Result<EntityHandle, EcsError>;
698
+ }
699
+ }
700
+ }
701
+ }
702
+
703
+ const detached = new Set<LocalEntityId>();
704
+ const state: Record<string, unknown> = {
705
+ source: handle,
706
+ entityToLocalId,
707
+ detachedLocalIds: detached,
708
+ // Convert overrides Map<LocalEntityId, Map<string, MountOverride>>
709
+ // into Map<LocalEntityId, Map<string, SceneInstanceOverrideRecord>>
710
+ overrides: worldMountOverridesToStateMap(overrides),
711
+ rootEntities,
712
+ mountRoots: mountInstances.map(({ root }) => root),
713
+ totalSlots,
714
+ mountTimeOverrides: ownMounts.flatMap((m) => m.overrides ?? []),
715
+ };
716
+ // Stuff the state into the UniqueRefStore under the existing slot. We
717
+ // re-use the slot we allocated above by writing directly into the
718
+ // payloads map via a `_setUniqueRefPayload` shim.
719
+ worldSetUniqueRefPayload(world, stateRef, state);
720
+
721
+ // 5. Wire ChildOf for every owned root entity (no ChildOf at layer-1)
722
+ // to the synthetic root.
723
+ if (childOfToken !== undefined) {
724
+ for (const rootE of rootEntities) {
725
+ const has = world.get(rootE, childOfToken);
726
+ if (!has.ok) {
727
+ // No ChildOf yet — attach to synthetic root.
728
+ const r = world.addComponent(rootE, {
729
+ component: childOfToken,
730
+ data: { parent: rootEntity } as never,
731
+ });
732
+ if (!r.ok) return r as Result<EntityHandle, EcsError>;
733
+ }
734
+ }
735
+ // R2/B-1: wire mount entities with default `mount.parent === undefined`
736
+ // to this scene's synthetic root. _spawnMountEntity may have attached a
737
+ // placeholder ChildOf {parent: ENTITY_NULL_RAW} when mount.components
738
+ // was empty; overwrite via set so the ChildOf chain meshRenderer ->
739
+ // childSyntheticRoot -> mountEntity -> outerSyntheticRoot resolves
740
+ // through Transform-bearing parents (AC-16 / requirements S-7).
741
+ for (const mountE of mountEntitiesNeedingRootParent) {
742
+ const set = world.set(mountE, childOfToken, { parent: rootEntity } as never);
743
+ if (!set.ok) {
744
+ const r = world.addComponent(mountE, {
745
+ component: childOfToken,
746
+ data: { parent: rootEntity } as never,
747
+ });
748
+ if (!r.ok) return r as Result<EntityHandle, EcsError>;
749
+ }
750
+ }
751
+ // Caller-supplied parent: synthetic root's ChildOf -> parent.
752
+ if (parent !== undefined) {
753
+ const r = world.addComponent(rootEntity, {
754
+ component: childOfToken,
755
+ data: { parent } as never,
756
+ });
757
+ if (!r.ok) return r as Result<EntityHandle, EcsError>;
758
+ }
759
+ }
760
+
761
+ return ok(rootEntity);
762
+ }
763
+ /**
764
+ * @internal Flat finisher — spawn one SceneAsset's members WITHOUT wrapping
765
+ * them in a synthetic SceneInstance root and WITHOUT forcing `ChildOf` onto
766
+ * top-level members. Used for "opening a scene to edit": the scene's own
767
+ * entities become plain top-level world entities whose hierarchy is exactly
768
+ * their authored `ChildOf`. Nested prefabs inside still materialise as their
769
+ * own SceneInstance anchors (the mount recursion in `_spawnSceneMembers` is
770
+ * always anchored). Returns the top-level handles (own rootless entities +
771
+ * top-level mount carriers).
772
+ */
773
+ export function worldInstantiateSceneAssetFlat(
774
+ world: World,
775
+ handle: Handle<'SceneAsset', 'shared'>,
776
+ asset: SceneAsset,
777
+ stack: Set<number>,
778
+ diagnostics: SceneInstantiateDiagnostic[],
779
+ ): Result<{ roots: EntityHandle[]; mountEntities: EntityHandle[] }, EcsError> {
780
+ const membersRes = worldSpawnSceneMembers(world, handle, asset, stack, diagnostics);
781
+ if (!membersRes.ok) return membersRes;
782
+ const { rootEntities, mountEntitiesNeedingRootParent, mountEntities, mountInstances } =
783
+ membersRes.value;
784
+ const childOfToken = world.components.resolve('ChildOf');
785
+
786
+ // Apply parent mount overrides to the live columns and record them on the
787
+ // nested child anchor. Flat mode has no outer SceneInstance state; without
788
+ // this hand-authored mounts[].overrides affect the live value but disappear
789
+ // from the child state, so Gateway re-open cannot discover or revert them.
790
+ for (const { mount, root, mapping: childMapping } of mountInstances) {
791
+ const childStateRes = worldGetSceneInstanceState(world, root);
792
+ if (!childStateRes.ok) return childStateRes;
793
+ for (const ov of mount.overrides ?? []) {
794
+ const childLocalId =
795
+ (ov.localId as unknown as number) - (mount.memberFirst as unknown as number);
796
+ const memberEntityRaw = childMapping[childLocalId];
797
+ if (memberEntityRaw === undefined || memberEntityRaw === ENTITY_NULL_RAW) continue;
798
+ const memberEntity = memberEntityRaw as unknown as EntityHandle;
799
+ const applyRes = worldApplyMountOverride(world, memberEntity, ov);
800
+ if (!applyRes.ok) {
801
+ return applyRes as Result<
802
+ { roots: EntityHandle[]; mountEntities: EntityHandle[] },
803
+ EcsError
804
+ >;
805
+ }
806
+ let fieldMap = childStateRes.value.overrides.get(childLocalId as LocalEntityId);
807
+ if (fieldMap === undefined) {
808
+ fieldMap = new Map();
809
+ childStateRes.value.overrides.set(childLocalId as LocalEntityId, fieldMap);
810
+ }
811
+ fieldMap.set(mountOverrideStateKey(ov), {
812
+ comp: ov.comp,
813
+ ...(ov.field === undefined ? {} : { field: ov.field }),
814
+ value: ov.value,
815
+ });
816
+ }
817
+ }
818
+
819
+ // Default-parented mount carriers (`mount.parent === undefined`) would, in
820
+ // anchor mode, attach to the synthetic root. Flat mode has none, so they
821
+ // stay top-level. `_spawnMountEntity` may have left a placeholder
822
+ // `ChildOf {parent: ENTITY_NULL_RAW}` (rare: mount with no components AND
823
+ // Transform unregistered) — strip it so the carrier is a genuine root.
824
+ if (childOfToken !== undefined) {
825
+ for (const mountE of mountEntitiesNeedingRootParent) {
826
+ const co = world.get(mountE, childOfToken);
827
+ if (co.ok && (co.value as { parent: number }).parent === ENTITY_NULL_RAW) {
828
+ world.removeComponent(mountE, childOfToken);
829
+ }
830
+ }
831
+ }
832
+
833
+ return ok({ roots: [...rootEntities, ...mountEntitiesNeedingRootParent], mountEntities });
834
+ }
835
+ /** @internal Build ComponentData[] for one SceneEntity, remapping localIds.
836
+ *
837
+ * C-R2 (feat-20260622-s5 M6): unknown fields on a SceneAsset payload are NOT
838
+ * fatal. Unlike `world.spawn` (an explicit API call where a typo is a
839
+ * programming error -> `SpawnDataUnknownFieldError`), scene data is loader-fed
840
+ * and may carry a stale / deprecated / typo'd field. The remap below builds a
841
+ * fresh `remappedRaw` and simply SKIPS keys absent from the schema (no input
842
+ * mutation — the source `raw` is never deleted-from), recording each skipped
843
+ * key as a non-fatal `SceneInstantiateDiagnostic` into the passed accumulator.
844
+ * All known fields still write through, so one bad field cannot blank the
845
+ * entity or the scene (C-AC-02/03/04).
846
+ */
847
+ export function worldBuildSceneEntityComponentDatas(
848
+ world: World,
849
+ node: import('@forgeax/engine-types').SceneEntity,
850
+ mapping: Uint32Array,
851
+ diagnostics: SceneInstantiateDiagnostic[],
852
+ ): Result<ComponentData[], EcsError> {
853
+ const out: ComponentData[] = [];
854
+ const nodeLocalId = node.localId as unknown as number;
855
+ for (const compName of Object.keys(node.components)) {
856
+ const token = world.components.resolve(compName);
857
+ if (token === undefined) {
858
+ return err(new ComponentNotDefinedError(compName));
859
+ }
860
+ const raw = node.components[compName] ?? {};
861
+ const schema = componentSchema(token) as Record<string, string>;
862
+ const remappedRaw: Record<string, unknown> = {};
863
+ for (const fieldName of Object.keys(raw)) {
864
+ const fieldType = schema[fieldName];
865
+ // C-R2: unknown key -> skip (do not copy into remappedRaw, do not
866
+ // mutate the source `raw`) and record a structured diagnostic. The
867
+ // downstream `spawn` only sees schema-valid keys, so its own
868
+ // validateComponentDataKeys gate stays green.
869
+ if (fieldType === undefined) {
870
+ diagnostics.push({ component: compName, field: fieldName, localId: nodeLocalId });
871
+ continue;
872
+ }
873
+ const value = (raw as Record<string, unknown>)[fieldName];
874
+ const kind = classifyEntityField(token, fieldName);
875
+ if (kind !== null) {
876
+ // Entity / array<entity> field — remap through the shared kernel.
877
+ // localId -> live Entity. Slots not yet spawned hold ENTITY_NULL_RAW.
878
+ const sceneRemap = (localId: number): number => {
879
+ if (localId < 0 || localId >= mapping.length) return ENTITY_NULL_RAW;
880
+ const live = mapping[localId];
881
+ return live === undefined || live === ENTITY_NULL_RAW ? ENTITY_NULL_RAW : live;
882
+ };
883
+ remappedRaw[fieldName] = remapEntityFieldValue(value, kind, sceneRemap);
884
+ } else {
885
+ remappedRaw[fieldName] = value;
886
+ }
887
+ }
888
+ const filled = fillComponentDefaults(token, remappedRaw);
889
+ out.push({ component: token, data: filled as never });
890
+ }
891
+ return ok(out);
892
+ }
893
+ /**
894
+ * @internal feat-20260713 M2 / w8: apply one MountOverride to a live member
895
+ * entity column. The `field?` shape is the add-or-patch discriminant:
896
+ *
897
+ * - `field` present -> PATCH one field: `world.set(member, comp, {[field]:
898
+ * value})`. Omitted fields keep their authored / existing values.
899
+ * - `field` absent -> ADD/UPSERT the whole component: `value` is the
900
+ * per-field value map for `comp`. When the member already carries `comp`
901
+ * it is upserted (set-over each supplied field + schema defaults for the
902
+ * omitted ones — the whole component is rewritten from the value map +
903
+ * defaults, never a `component-already-present` error). When absent it is
904
+ * added fresh via `addComponent` (fillComponentDefaults fills omitted
905
+ * fields). The value-map is fed through `fillComponentDefaults` so the
906
+ * add and upsert paths write byte-identical rows.
907
+ *
908
+ * Component registration + value-key validation happened at
909
+ * `_validateMountOverrides` (fail-fast before any spawn); by this point the
910
+ * comp resolves through the World-local catalog and the value keys are schema-valid.
911
+ * still guards defensively (an unregistered comp is a no-op skip, matching
912
+ * the prior field-patch behaviour). Returns the underlying set / addComponent
913
+ * Result so a shared-field value gate (D-4) or any other write error
914
+ * propagates unchanged.
915
+ */
916
+ export function worldApplyMountOverride(
917
+ world: World,
918
+ member: EntityHandle,
919
+ ov: MountOverride,
920
+ ): Result<void, EcsError> {
921
+ const ovToken = world.components.resolve(ov.comp);
922
+ if (ovToken === undefined) return ok(undefined);
923
+ if (ov.field !== undefined) {
924
+ // PATCH one field.
925
+ return world.set(member, ovToken, { [ov.field]: ov.value } as never);
926
+ }
927
+ // ADD/UPSERT the whole component. Fill omitted fields from the schema so
928
+ // add and upsert produce identical rows (upsert = full rewrite from the
929
+ // value map + defaults).
930
+ const rawValue = (ov.value ?? {}) as Record<string, unknown>;
931
+ const filled = fillComponentDefaults(ovToken as Component, rawValue);
932
+ const has = world.get(member, ovToken);
933
+ if (has.ok) {
934
+ // Already present -> upsert (set every filled field, no duplicate error).
935
+ return world.set(member, ovToken, filled as never);
936
+ }
937
+ return world.addComponent(member, { component: ovToken, data: filled as never });
938
+ }
939
+ /**
940
+ * @internal R2/B-3 + R2/B-4: validate `mount.overrides[]` BEFORE any
941
+ * spawn so a malformed override fails fast with no observable side
942
+ * effects (charter P3 explicit-failure). Two checks:
943
+ *
944
+ * 1. `override.localId` must address a slot inside the parent-namespace
945
+ * member window `[memberFirst, memberFirst + memberCount)` (AC-06).
946
+ * 2. `override.field` must exist in the resolved component schema
947
+ * (AC-07). When the component is unregistered we cannot validate the
948
+ * field shape; let the existing fall-through path proceed (the
949
+ * catalog guard inside the override-application loop
950
+ * will skip the write).
951
+ */
952
+ export function worldValidateMountOverrides(
953
+ world: World,
954
+ mount: SceneInstanceMount,
955
+ ): Result<void, EcsError> {
956
+ const overrides = mount.overrides;
957
+ if (overrides === undefined) return ok(undefined);
958
+ const memberFirst = mount.memberFirst as unknown as number;
959
+ const memberCount = mount.memberCount;
960
+ const memberLast = memberFirst + memberCount;
961
+ const mountLid = mount.localId as unknown as number;
962
+ for (const ov of overrides) {
963
+ const ovLid = ov.localId as unknown as number;
964
+ // R2/B-3: parent-namespace check — override.localId must lie in the
965
+ // member window [memberFirst, memberFirst + memberCount).
966
+ if (ovLid < memberFirst || ovLid >= memberLast) {
967
+ return err({
968
+ code: 'pack-mount-override-localid-out-of-range' as PackErrorCode,
969
+ expected: `override.localId in [${memberFirst}, ${memberLast})`,
970
+ hint: PACK_ERROR_HINTS['pack-mount-override-localid-out-of-range'],
971
+ detail: {
972
+ code: 'pack-mount-override-localid-out-of-range',
973
+ overrideLocalId: ovLid,
974
+ mountLocalId: mountLid,
975
+ memberCount,
976
+ } as PackErrorDetail,
977
+ } as unknown as EcsError);
978
+ }
979
+ // feat-20260713 M2 / w8: double-branch schema check.
980
+ // - field-patch form (field present): the component (when registered)
981
+ // must declare `override.field` in its schema (R2/B-4, unchanged).
982
+ // - component-add form (field absent): the component MUST be registered
983
+ // (component-not-defined otherwise) AND every key in the value map
984
+ // must be a schema field (pack-mount-override-unknown-field).
985
+ const ovToken = world.components.resolve(ov.comp);
986
+ if (ov.field !== undefined) {
987
+ if (ovToken !== undefined) {
988
+ const schema = componentSchema(ovToken) as Record<string, unknown>;
989
+ if (!(ov.field in schema)) {
990
+ return err({
991
+ code: 'pack-mount-override-unknown-field' as PackErrorCode,
992
+ expected: `override.field defined on component '${ov.comp}'`,
993
+ hint: PACK_ERROR_HINTS['pack-mount-override-unknown-field'],
994
+ detail: {
995
+ code: 'pack-mount-override-unknown-field',
996
+ comp: ov.comp,
997
+ field: ov.field,
998
+ mountLocalId: mountLid,
999
+ } as PackErrorDetail,
1000
+ } as unknown as EcsError);
1001
+ }
1002
+ }
1003
+ } else {
1004
+ // component-add form: comp must be registered so we can validate + apply
1005
+ // the whole component (add/upsert needs the schema).
1006
+ if (ovToken === undefined) {
1007
+ return err(new ComponentNotDefinedError(ov.comp));
1008
+ }
1009
+ const schema = componentSchema(ovToken) as Record<string, unknown>;
1010
+ const valueMap = (ov.value ?? {}) as Record<string, unknown>;
1011
+ for (const key of Object.keys(valueMap)) {
1012
+ if (!(key in schema)) {
1013
+ return err({
1014
+ code: 'pack-mount-override-unknown-field' as PackErrorCode,
1015
+ expected: `override.value keys defined on component '${ov.comp}'`,
1016
+ hint: PACK_ERROR_HINTS['pack-mount-override-unknown-field'],
1017
+ detail: {
1018
+ code: 'pack-mount-override-unknown-field',
1019
+ comp: ov.comp,
1020
+ field: key,
1021
+ mountLocalId: mountLid,
1022
+ } as PackErrorDetail,
1023
+ } as unknown as EcsError);
1024
+ }
1025
+ }
1026
+ }
1027
+ }
1028
+ return ok(undefined);
1029
+ }
1030
+ /** @internal Spawn the mount-entity slot carrying mount.components (if any).
1031
+ *
1032
+ * R2/B-1: the mount entity is a structural intermediate in the ChildOf
1033
+ * chain `cube -> innerSyntheticRoot -> mountEntity -> outerSyntheticRoot`,
1034
+ * so it MUST carry Transform whenever Transform is registered (mirrors
1035
+ * the D-V-0 synthetic-root invariant). Otherwise propagateTransforms
1036
+ * walking the chain hits a Transform-less parent and emits per-frame
1037
+ * `RhiError(hierarchy-broken)` (verify R1 root cause of the
1038
+ * hello-scene-nesting demo black frames).
1039
+ */
1040
+ export function worldSpawnMountEntity(
1041
+ world: World,
1042
+ mount: SceneInstanceMount,
1043
+ mapping: Uint32Array,
1044
+ diagnostics: SceneInstantiateDiagnostic[],
1045
+ ): Result<EntityHandle, EcsError> {
1046
+ const fakeNode: import('@forgeax/engine-types').SceneEntity = {
1047
+ localId: mount.localId,
1048
+ components: mount.components ?? {},
1049
+ };
1050
+ const cdRes = worldBuildSceneEntityComponentDatas(world, fakeNode, mapping, diagnostics);
1051
+ if (!cdRes.ok) return cdRes;
1052
+ // R2/B-1: ensure Transform is attached so propagateTransforms can walk
1053
+ // through this entity. Layer-2 defaults supply identity TRS; the
1054
+ // mount.components overlay (when present and including Transform) takes
1055
+ // precedence and is already in cdRes.value.
1056
+ const transformToken = world.components.resolve('Transform');
1057
+ if (transformToken !== undefined) {
1058
+ const hasTransform = cdRes.value.some((c) => c.component === transformToken);
1059
+ if (!hasTransform) {
1060
+ cdRes.value.push({ component: transformToken, data: {} as never });
1061
+ }
1062
+ }
1063
+ if (cdRes.value.length === 0) {
1064
+ // Mount has no components AND Transform is unregistered (rare unit-
1065
+ // test path). Fall back to the placeholder ChildOf so the spawn has
1066
+ // a real archetype. Step 5 overwrites this placeholder.
1067
+ const childOfToken = world.components.resolve('ChildOf');
1068
+ if (childOfToken === undefined) {
1069
+ return err(new ComponentNotDefinedError('ChildOf'));
1070
+ }
1071
+ cdRes.value.push({
1072
+ component: childOfToken,
1073
+ data: { parent: ENTITY_NULL_RAW } as never,
1074
+ });
1075
+ }
1076
+ return (world.spawn as (...c: ComponentData[]) => Result<EntityHandle, EcsError>)(...cdRes.value);
1077
+ }
1078
+ /** @internal Resolve mount.source through the wired SceneAssetResolver. */
1079
+ export function worldResolveMountSource(
1080
+ world: World,
1081
+ source: number | string,
1082
+ parentHandle: Handle<'SceneAsset', 'shared'>,
1083
+ ): Result<Handle<'SceneAsset', 'shared'>, EcsError> {
1084
+ const resolver = worldGetSceneAssetResolver(world);
1085
+ if (resolver === null) {
1086
+ return err({
1087
+ code: 'stale-entity' as const,
1088
+ expected: 'wired SceneAssetResolver (auto-wired by engine.assets.instantiate)',
1089
+ hint:
1090
+ 'engine.assets.instantiate sugar wires this for you; ' +
1091
+ 'call worldSetSceneAssetResolver before nested scene expansion.',
1092
+ detail: { entity: 0, slot: 0, generation: 0 },
1093
+ } as unknown as EcsError);
1094
+ }
1095
+ const r = resolver(source, parentHandle);
1096
+ if (!r.ok) {
1097
+ // Resolver carries `unknown` err (loose contract — engine-runtime may
1098
+ // wire any shape); narrow back to EcsError here at the boundary.
1099
+ return err(r.error as EcsError);
1100
+ }
1101
+ return ok(r.value);
1102
+ }
1103
+ /** @internal Convert mount.overrides Map shape to the SceneInstanceState shape.
1104
+ *
1105
+ * feat-20260713 M1 / w4: `field` is optional (add-or-patch discriminant). In
1106
+ * M1 only the field-patch form reaches this builder (the component-add form
1107
+ * fails fast in the apply loops); the record type stays `field?: string` so
1108
+ * the M2 add path can flow through untouched. `exactOptionalPropertyTypes`
1109
+ * forbids writing an explicit `field: undefined`, so omit the key when absent.
1110
+ */
1111
+ export function worldMountOverridesToStateMap(
1112
+ src: Map<LocalEntityId, Map<string, MountOverride>>,
1113
+ ): Map<LocalEntityId, Map<string, { comp: string; field?: string; value: unknown }>> {
1114
+ const out = new Map<
1115
+ LocalEntityId,
1116
+ Map<string, { comp: string; field?: string; value: unknown }>
1117
+ >();
1118
+ for (const [lid, fields] of src) {
1119
+ const m = new Map<string, { comp: string; field?: string; value: unknown }>();
1120
+ for (const [k, v] of fields) {
1121
+ m.set(k, {
1122
+ comp: v.comp,
1123
+ value: v.value,
1124
+ ...(v.field !== undefined ? { field: v.field } : {}),
1125
+ });
1126
+ }
1127
+ out.set(lid, m);
1128
+ }
1129
+ return out;
1130
+ }
1131
+ /** @internal Set the payload of an already-allocated SceneInstance state ref. */
1132
+ export function worldSetUniqueRefPayload<T>(
1133
+ world: World,
1134
+ handle: Handle<string, 'unique'>,
1135
+ payload: T,
1136
+ ): void {
1137
+ sceneWorldState(world).statePayloads.set(Number(handle), payload);
1138
+ }
1139
+
1140
+ /**
1141
+ * @internal Resolve the SceneInstanceState payload behind the
1142
+ * `SceneInstance.state` ref column on `root`. Returns Err when `root`
1143
+ * does not carry SceneInstance or the ref slot is dead.
1144
+ */
1145
+ export function worldResolveSceneInstanceStatePayload(
1146
+ world: World,
1147
+ root: EntityHandle,
1148
+ ): Result<SceneInstanceStatePayload, EcsError> {
1149
+ const sceneInstanceToken = world.components.resolve('SceneInstance');
1150
+ if (sceneInstanceToken === undefined) {
1151
+ return err(new ComponentNotDefinedError('SceneInstance'));
1152
+ }
1153
+ const r = world.get(root, sceneInstanceToken);
1154
+ if (!r.ok) return r;
1155
+ const stateRefRaw = (r.value as unknown as { state: number }).state;
1156
+ const stateRefHandle = toUnique<'SceneInstanceState'>(stateRefRaw);
1157
+ const payload = sceneWorldState(world).statePayloads.get(Number(stateRefHandle));
1158
+ if (payload === undefined) {
1159
+ return err(
1160
+ new StaleEntityError(root as unknown as number, entityIndex(root), entityGeneration(root), {
1161
+ operation: 'resolveSceneInstanceState',
1162
+ component: 'SceneInstance',
1163
+ expectedGeneration: entityGeneration(root),
1164
+ actualGeneration: entityGeneration(root),
1165
+ }),
1166
+ );
1167
+ }
1168
+ return ok(payload as SceneInstanceStatePayload);
1169
+ }
1170
+ /**
1171
+ * Public sugar — get the SceneInstanceState payload (Map / Set view) for
1172
+ * `root`. Equivalent to `world.get(root, SceneInstance)` followed by a
1173
+ * managed-ref resolution; provided so AI users do not have to learn the
1174
+ * `ref<T>` slot resolution mechanic for the common read path.
1175
+ */
1176
+ export function worldGetSceneInstanceState(
1177
+ world: World,
1178
+ root: EntityHandle,
1179
+ ): Result<SceneInstanceStatePayload, EcsError> {
1180
+ return worldResolveSceneInstanceStatePayload(world, root);
1181
+ }
1182
+ /**
1183
+ * Despawn a SceneInstance root + all its members. `opts.keepDetached`
1184
+ * preserves members marked via `worldDetachSceneMember` (plan-strategy
1185
+ * §D-5). Returns the count of entities actually despawned (root + each
1186
+ * non-detached member).
1187
+ *
1188
+ * For a plain entity (no SceneInstance), behaviour matches
1189
+ * `world.despawn(entity)` followed by `despawnDescendants(entity)` — i.e.
1190
+ * `keepDetached` is a no-op.
1191
+ */
1192
+ export function worldDespawnScene(
1193
+ world: World,
1194
+ root: EntityHandle,
1195
+ opts?: { keepDetached?: boolean },
1196
+ ): Result<number, EcsError> {
1197
+ const dRes = worldDespawnDescendants(world, root, opts);
1198
+ if (!dRes.ok) return dRes;
1199
+ const drop = world.despawn(root);
1200
+ if (!drop.ok) return drop;
1201
+ return ok(dRes.value + 1);
1202
+ }
1203
+ /**
1204
+ * Despawn every descendant of `root` reachable through Children mirror /
1205
+ * SceneInstance.mapping. `opts.keepDetached` is honoured only when `root`
1206
+ * carries a SceneInstance (otherwise the option is ignored — there is no
1207
+ * detached set on a plain entity).
1208
+ *
1209
+ * Returns the count of entities despawned. The `root` itself is NOT
1210
+ * despawned (that is `despawnScene`'s extra step).
1211
+ */
1212
+ export function worldDespawnDescendants(
1213
+ world: World,
1214
+ root: EntityHandle,
1215
+ opts?: { keepDetached?: boolean },
1216
+ ): Result<number, EcsError> {
1217
+ let detached: Set<LocalEntityId> | null = null;
1218
+ let entityToLocalId: Map<EntityHandle, LocalEntityId> | null = null;
1219
+ if (opts?.keepDetached === true) {
1220
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1221
+ if (stateRes.ok) {
1222
+ detached = stateRes.value.detachedLocalIds;
1223
+ entityToLocalId = stateRes.value.entityToLocalId;
1224
+ }
1225
+ }
1226
+ let count = 0;
1227
+ // Collect descendants first (DFS via iterDescendants) to avoid mutating
1228
+ // while iterating. SceneInstance.mapping also owns members that may not be
1229
+ // reachable through a Children mirror in a partially registered host. The
1230
+ // nested anchor list closes that same ownership boundary for mounted scenes.
1231
+ const list: EntityHandle[] = [];
1232
+ const seen = new Set<number>();
1233
+ const collect = (anchor: EntityHandle): void => {
1234
+ for (const e of world.iterDescendants(anchor)) {
1235
+ const raw = e as unknown as number;
1236
+ if (!seen.has(raw)) {
1237
+ seen.add(raw);
1238
+ list.push(e);
1239
+ }
1240
+ }
1241
+ const stateRes = worldResolveSceneInstanceStatePayload(world, anchor);
1242
+ if (!stateRes.ok) return;
1243
+ for (const e of stateRes.value.entityToLocalId.keys()) {
1244
+ const raw = e as unknown as number;
1245
+ if (!seen.has(raw)) {
1246
+ seen.add(raw);
1247
+ list.push(e);
1248
+ }
1249
+ }
1250
+ for (const nestedRoot of stateRes.value.mountRoots) {
1251
+ const raw = nestedRoot as unknown as number;
1252
+ if (seen.has(raw)) continue;
1253
+ seen.add(raw);
1254
+ list.push(nestedRoot);
1255
+ collect(nestedRoot);
1256
+ }
1257
+ };
1258
+ collect(root);
1259
+ const childOfToken = world.components.resolve('ChildOf');
1260
+ // ChildOf uses linkedSpawn, so a parent-first pass would recursively retire
1261
+ // its children before this function can count them. Sort the ownership set
1262
+ // by its live ChildOf depth instead of relying on mirror traversal order;
1263
+ // nested SceneInstance roots are siblings of their mount carrier in the
1264
+ // flattened traversal but parents of the mounted members.
1265
+ const owned = new Set(list.map((entity) => Number(entity)));
1266
+ const ownedDepth = (entity: EntityHandle): number => {
1267
+ if (childOfToken === undefined) return 0;
1268
+ let current = entity;
1269
+ let depth = 0;
1270
+ const visited = new Set<number>();
1271
+ while (!visited.has(Number(current))) {
1272
+ visited.add(Number(current));
1273
+ const parentRes = world.get(current, childOfToken);
1274
+ if (!parentRes.ok) break;
1275
+ const parent = (parentRes.value as { parent: EntityHandle }).parent;
1276
+ if (!owned.has(Number(parent))) break;
1277
+ depth += 1;
1278
+ current = parent;
1279
+ }
1280
+ return depth;
1281
+ };
1282
+ list.sort((a, b) => ownedDepth(b) - ownedDepth(a));
1283
+ for (const e of list) {
1284
+ if (detached !== null) {
1285
+ const lid = entityToLocalId?.get(e);
1286
+ if (lid !== undefined && detached.has(lid)) {
1287
+ if (childOfToken !== undefined) {
1288
+ world.removeComponent(e, childOfToken);
1289
+ }
1290
+ continue;
1291
+ }
1292
+ }
1293
+ const r = world.despawn(e);
1294
+ if (!r.ok) {
1295
+ if (r.error.code === 'stale-entity') continue;
1296
+ return r;
1297
+ }
1298
+ count += 1;
1299
+ }
1300
+ return ok(count);
1301
+ }
1302
+ /**
1303
+ * Write a runtime override to a member entity belonging to `root`. Routes
1304
+ * through `world.set(member, comp, { [field]: value })` after an entity-
1305
+ * scope guard so cross-instance writes fail-fast. Type-mismatch surfaces
1306
+ * `EcsErrorCode = 'scene-override-type-mismatch'` (D-9).
1307
+ */
1308
+ export function worldSetSceneOverride<S extends ComponentSchema>(
1309
+ world: World,
1310
+ root: EntityHandle,
1311
+ member: EntityHandle,
1312
+ component: Component<string, S>,
1313
+ field: keyof ShapeOf<S> & string,
1314
+ value: unknown,
1315
+ ): Result<void, EcsError> {
1316
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1317
+ if (!stateRes.ok) return stateRes;
1318
+ const state = stateRes.value;
1319
+ const lid = state.entityToLocalId.get(member);
1320
+ if (lid === undefined) {
1321
+ return err(
1322
+ new StaleEntityError(
1323
+ member as unknown as number,
1324
+ entityIndex(member),
1325
+ entityGeneration(member),
1326
+ {
1327
+ operation: 'setSceneOverride',
1328
+ component: component.name,
1329
+ expectedGeneration: entityGeneration(member),
1330
+ actualGeneration: entityGeneration(member),
1331
+ },
1332
+ ),
1333
+ );
1334
+ }
1335
+ // Type guard: only check primitive scalar field types where we can
1336
+ // narrow `typeof`; ref / handle / entity / array / buffer fields skip
1337
+ // (write would surface a deeper error from set).
1338
+ const schemaType = (componentSchema(component) as Record<string, string>)[field];
1339
+ if (schemaType !== undefined && isPrimitiveScalarFieldType(schemaType)) {
1340
+ const expectJsType = primitiveJsType(schemaType);
1341
+ const actualJsType = typeof value;
1342
+ if (expectJsType !== actualJsType) {
1343
+ return err({
1344
+ code: 'scene-override-type-mismatch' as const,
1345
+ expected: `value typeof === ${expectJsType}`,
1346
+ hint:
1347
+ `setSceneOverride(${component.name}.${field}) expected ${expectJsType}, ` +
1348
+ `got ${actualJsType}; coerce or pick a different override path.`,
1349
+ detail: {
1350
+ code: 'scene-override-type-mismatch' as const,
1351
+ comp: component.name,
1352
+ field: field as string,
1353
+ expectedType: schemaType,
1354
+ actualType: actualJsType,
1355
+ },
1356
+ } as unknown as EcsError);
1357
+ }
1358
+ }
1359
+ const setRes = world.set(member, component, { [field]: value } as Partial<InputShapeOf<S>>);
1360
+ if (!setRes.ok) return setRes;
1361
+ // Record into state.overrides
1362
+ let fieldMap = state.overrides.get(lid);
1363
+ if (fieldMap === undefined) {
1364
+ fieldMap = new Map();
1365
+ state.overrides.set(lid, fieldMap);
1366
+ }
1367
+ fieldMap.set(`${component.name}:${field}`, {
1368
+ comp: component.name,
1369
+ field: field as string,
1370
+ value,
1371
+ });
1372
+ return ok(undefined);
1373
+ }
1374
+ /**
1375
+ * Drop a runtime override (and any mount-time override for the same
1376
+ * (member, comp, field) triple); roll the live column value back to the
1377
+ * source SceneAsset's layer-1 explicit value (M2 v1 — M3+ widens to layer
1378
+ * 2/3 defaults via fillComponentDefaults).
1379
+ */
1380
+ export function worldRemoveSceneOverride<S extends ComponentSchema>(
1381
+ world: World,
1382
+ root: EntityHandle,
1383
+ member: EntityHandle,
1384
+ component: Component<string, S>,
1385
+ field: keyof ShapeOf<S> & string,
1386
+ ): Result<void, EcsError> {
1387
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1388
+ if (!stateRes.ok) return stateRes;
1389
+ const state = stateRes.value;
1390
+ const lid = state.entityToLocalId.get(member);
1391
+ if (lid === undefined) return ok(undefined);
1392
+ const fieldMap = state.overrides.get(lid);
1393
+ if (fieldMap !== undefined) {
1394
+ fieldMap.delete(`${component.name}:${field}`);
1395
+ if (fieldMap.size === 0) state.overrides.delete(lid);
1396
+ }
1397
+ // Look up the source SceneAsset layer-1 value.
1398
+ const assetRes = worldResolveSceneAsset(world, state.source);
1399
+ if (!assetRes.ok) return assetRes;
1400
+ const node = assetRes.value.entities.find(
1401
+ (n) => (n.localId as unknown as number) === (lid as unknown as number),
1402
+ );
1403
+ const layer1 = node?.components[component.name] as Record<string, unknown> | undefined;
1404
+ if (layer1 !== undefined && field in layer1) {
1405
+ const r = world.set(member, component, { [field]: layer1[field] } as Partial<InputShapeOf<S>>);
1406
+ if (!r.ok) return r;
1407
+ }
1408
+ return ok(undefined);
1409
+ }
1410
+ /** Mark a member entity detached. Idempotent (set semantics). */
1411
+ export function worldDetachSceneMember(
1412
+ world: World,
1413
+ root: EntityHandle,
1414
+ member: EntityHandle,
1415
+ ): Result<void, EcsError> {
1416
+ const sceneInstanceToken = world.components.resolve('SceneInstance');
1417
+ if (sceneInstanceToken === undefined) {
1418
+ return err(new ComponentNotDefinedError('SceneInstance'));
1419
+ }
1420
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1421
+ if (!stateRes.ok) return stateRes;
1422
+ const state = stateRes.value;
1423
+ const lid = state.entityToLocalId.get(member);
1424
+ if (lid === undefined) return ok(undefined);
1425
+ state.detachedLocalIds.add(lid);
1426
+ return ok(undefined);
1427
+ }
1428
+ /** Clear a detached mark. Idempotent (set semantics). */
1429
+ export function worldReattachSceneMember(
1430
+ world: World,
1431
+ root: EntityHandle,
1432
+ member: EntityHandle,
1433
+ ): Result<void, EcsError> {
1434
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1435
+ if (!stateRes.ok) return stateRes;
1436
+ const state = stateRes.value;
1437
+ const lid = state.entityToLocalId.get(member);
1438
+ if (lid === undefined) return ok(undefined);
1439
+ state.detachedLocalIds.delete(lid);
1440
+ return ok(undefined);
1441
+ }
1442
+ /**
1443
+ * Get the SceneAsset handle a SceneInstance root was instantiated from.
1444
+ * Returns Err on a plain entity (no SceneInstance component).
1445
+ */
1446
+ export function worldGetSceneAssetForInstance(
1447
+ world: World,
1448
+ root: EntityHandle,
1449
+ ): Result<Handle<'SceneAsset', 'shared'>, EcsError> {
1450
+ const stateRes = worldResolveSceneInstanceStatePayload(world, root);
1451
+ if (!stateRes.ok) return stateRes;
1452
+ return ok(stateRes.value.source);
1453
+ }
1454
+
1455
+ // SceneInstanceStatePayload — internal echo of the runtime
1456
+ // `SceneInstanceState` interface for ECS-side consumption (engine-ecs cannot
1457
+ // value-import engine-runtime by AC-29; structural shape only).
1458
+ // ────────────────────────────────────────────────────────────────────────────
1459
+
1460
+ /** @internal Structural payload behind `SceneInstance.state` ref column. */
1461
+ export interface SceneInstanceStatePayload {
1462
+ readonly source: Handle<'SceneAsset', 'shared'>;
1463
+ readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
1464
+ readonly detachedLocalIds: Set<LocalEntityId>;
1465
+ readonly overrides: Map<
1466
+ LocalEntityId,
1467
+ Map<string, { readonly comp: string; readonly field?: string; readonly value: unknown }>
1468
+ >;
1469
+ readonly rootEntities: EntityHandle[];
1470
+ /** Synthetic roots of recursively mounted SceneAssets owned by this instance. */
1471
+ readonly mountRoots: EntityHandle[];
1472
+ readonly totalSlots: number;
1473
+ readonly mountTimeOverrides: readonly MountOverride[];
1474
+ }
1475
+
1476
+ /**
1477
+ * Topological sort over the implicit ChildOf graph (parents before children).
1478
+ * Cycle-free input always covers all n nodes; cyclic input emits whatever was
1479
+ * reachable from indegree-0 (the fallback caller handles cycle reporting via
1480
+ * `pack-cyclic-reference` at the upstream scanner / runtime path).
1481
+ */
1482
+ function sceneTopoSort(
1483
+ nodes: readonly import('@forgeax/engine-types').SceneEntity[],
1484
+ ): readonly number[] {
1485
+ const n = nodes.length;
1486
+ const childrenOf: number[][] = Array.from({ length: n }, () => []);
1487
+ const indeg = new Uint32Array(n);
1488
+ const localIdToIdx = new Map<number, number>();
1489
+ for (let i = 0; i < n; i += 1) {
1490
+ const node = nodes[i];
1491
+ if (node === undefined) continue;
1492
+ localIdToIdx.set(node.localId as unknown as number, i);
1493
+ }
1494
+ for (let i = 0; i < n; i += 1) {
1495
+ const node = nodes[i];
1496
+ if (node === undefined) continue;
1497
+ const child = node.components.ChildOf;
1498
+ if (child === undefined) continue;
1499
+ const p = (child as Record<string, unknown>).parent;
1500
+ if (typeof p === 'number') {
1501
+ const parentIdx = localIdToIdx.get(p);
1502
+ if (parentIdx !== undefined && parentIdx !== i) {
1503
+ childrenOf[parentIdx]?.push(i);
1504
+ indeg[i] = (indeg[i] ?? 0) + 1;
1505
+ }
1506
+ }
1507
+ }
1508
+ const order: number[] = [];
1509
+ const queue: number[] = [];
1510
+ for (let i = 0; i < n; i += 1) if ((indeg[i] ?? 0) === 0) queue.push(i);
1511
+ while (queue.length > 0) {
1512
+ const head = queue.shift();
1513
+ if (head === undefined) break;
1514
+ order.push(head);
1515
+ for (const c of childrenOf[head] ?? []) {
1516
+ indeg[c] = (indeg[c] ?? 0) - 1;
1517
+ if ((indeg[c] ?? 0) === 0) queue.push(c);
1518
+ }
1519
+ }
1520
+ // Append any nodes left unvisited (defensive — cycle would surface here).
1521
+ for (let i = 0; i < n; i += 1) {
1522
+ if (!order.includes(i) && nodes[i] !== undefined) order.push(i);
1523
+ }
1524
+ return order;
1525
+ }
1526
+
1527
+ /**
1528
+ * @internal feat-20260713 M2 / w8: SceneInstanceState map key for a
1529
+ * MountOverride. Field-patch form keys by `comp:field` (one entry per patched
1530
+ * field); component-add form keys by `comp` (one entry per added component). The
1531
+ * two key shapes cannot collide because a field-patch always carries a `:field`
1532
+ * suffix. Later array entries for the same key overwrite earlier ones, matching
1533
+ * the array-order apply semantics.
1534
+ */
1535
+ function mountOverrideStateKey(ov: MountOverride): string {
1536
+ return ov.field !== undefined ? `${ov.comp}:${ov.field}` : ov.comp;
1537
+ }
1538
+
1539
+ /** Schema field types that are JS primitives (typeof checkable). */
1540
+ function isPrimitiveScalarFieldType(fieldType: string): boolean {
1541
+ if (
1542
+ fieldType === 'f32' ||
1543
+ fieldType === 'f64' ||
1544
+ fieldType === 'u32' ||
1545
+ fieldType === 'i32' ||
1546
+ fieldType === 'u8' ||
1547
+ fieldType === 'i8' ||
1548
+ fieldType === 'u16' ||
1549
+ fieldType === 'i16' ||
1550
+ fieldType === 'bool' ||
1551
+ fieldType === 'string'
1552
+ ) {
1553
+ return true;
1554
+ }
1555
+ if (fieldType.startsWith('enum<')) return true;
1556
+ return false;
1557
+ }
1558
+
1559
+ /** Map a primitive scalar field type to the runtime `typeof` it should narrow to. */
1560
+ function primitiveJsType(fieldType: string): string {
1561
+ if (fieldType === 'bool') return 'boolean';
1562
+ if (fieldType === 'string') return 'string';
1563
+ return 'number';
1564
+ }