@forgeax/engine-runtime 0.1.28 → 0.1.29

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 (113) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -32
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  101. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  102. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  103. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  104. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  105. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  106. package/src/__tests__/world-scene-nested.test.ts +90 -163
  107. package/src/__tests__/world-scene-override.test.ts +17 -16
  108. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  109. package/src/collect-scene-asset.ts +637 -19
  110. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  111. package/src/index.ts +1 -0
  112. package/src/scene-utils/mount-override-fold.ts +5 -14
  113. package/src/__tests__/node-shims.d.ts +0 -31
@@ -60,6 +60,7 @@ import { classifyEntityField } from '@forgeax/engine-ecs/externalization';
60
60
  import { componentSchema } from '@forgeax/engine-ecs/internal';
61
61
  import { SceneInstance } from '@forgeax/engine-render';
62
62
  import { err, ok, type Result } from '@forgeax/engine-rhi';
63
+ import type { MountOverride, SceneInstanceMount } from '@forgeax/engine-scene';
63
64
  import {
64
65
  collectSubtree,
65
66
  externalizeSceneAsset,
@@ -67,15 +68,7 @@ import {
67
68
  worldGetSceneAssetForInstance,
68
69
  worldGetSceneInstanceState,
69
70
  } from '@forgeax/engine-scene';
70
- import type {
71
- Asset,
72
- Handle,
73
- LocalEntityId,
74
- MountOverride,
75
- SceneAsset,
76
- SceneEntity,
77
- SceneInstanceMount,
78
- } from '@forgeax/engine-types';
71
+ import type { Asset, Handle, LocalEntityId, SceneAsset } from '@forgeax/engine-types';
79
72
  import { foldMountOverrides } from './scene-utils/mount-override-fold';
80
73
 
81
74
  /** The collector needs identity lookup, not an authoring registry implementation. */
@@ -213,6 +206,562 @@ function _serializeOverrideValueHandles(
213
206
  return ok(out);
214
207
  }
215
208
 
209
+ type LegacyCollectedEntity = {
210
+ readonly localId: number;
211
+ readonly components: Record<string, Record<string, unknown>>;
212
+ };
213
+
214
+ type SceneEntityAddress = string | readonly [string, ...string[]];
215
+ type SceneInstanceOverride = {
216
+ readonly target: readonly [string, ...string[]];
217
+ readonly components: Record<string, Record<string, unknown>>;
218
+ };
219
+ type CollectorSceneState = {
220
+ readonly keyByLocalId: Map<number, string>;
221
+ readonly instanceKey?: string;
222
+ readonly bindings: Map<string, EntityHandle>;
223
+ readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
224
+ readonly overrides: Map<
225
+ LocalEntityId,
226
+ Map<string, { readonly comp: string; readonly field?: string; readonly value: unknown }>
227
+ >;
228
+ readonly mountTimeOverrides: readonly MountOverride[];
229
+ };
230
+
231
+ type CollectedKeyedEntity = {
232
+ readonly components: Record<string, Record<string, unknown>>;
233
+ readonly instance?: {
234
+ readonly source: string;
235
+ readonly overrides?: readonly SceneInstanceOverride[];
236
+ };
237
+ };
238
+
239
+ type SceneStateInfo = {
240
+ readonly root: number;
241
+ readonly state: CollectorSceneState;
242
+ readonly mapping: Uint32Array;
243
+ };
244
+
245
+ type SceneStateRelation = {
246
+ readonly parent: SceneStateInfo;
247
+ readonly child: SceneStateInfo;
248
+ readonly key: string;
249
+ readonly memberFirst: number;
250
+ };
251
+
252
+ type KeyedCollectError =
253
+ | SceneCollectEntityRefOutOfClosureError
254
+ | SceneCollectAssetGuidUnresolvedError;
255
+
256
+ function isSceneEntityAddress(value: string | readonly string[]): value is SceneEntityAddress {
257
+ return typeof value === 'string' || value.length > 0;
258
+ }
259
+
260
+ /**
261
+ * Convert the old private numeric collector result to the keyed author model.
262
+ * Numeric slots remain an implementation detail of the live SceneInstance;
263
+ * this function uses the retained instance key maps and child mapping windows
264
+ * to reconstruct addresses before the public SceneAsset leaves Engine.
265
+ */
266
+ function collectKeyedSceneAsset(
267
+ world: World,
268
+ registry: SceneAssetGuidLookup,
269
+ _roots: readonly EntityHandle[],
270
+ visited: ReadonlySet<number>,
271
+ ownedEntities: readonly number[],
272
+ legacyEntities: readonly LegacyCollectedEntity[],
273
+ mounts: readonly SceneInstanceMount[],
274
+ anchors: ReadonlyArray<{ entityRaw: number; sourceGuid: string; totalSlots: number }>,
275
+ memberOrigin: ReadonlyMap<number, { anchorRaw: number; memberLocalId: number }>,
276
+ ): Result<SceneAsset, KeyedCollectError> {
277
+ const sceneInstanceToken = world.components.resolve('SceneInstance');
278
+ const childOfToken = world.components.resolve('ChildOf');
279
+ const stateInfos: SceneStateInfo[] = [];
280
+ if (sceneInstanceToken !== undefined) {
281
+ const queryResult = world.query({ read: [SceneInstance] });
282
+ if (queryResult.ok) {
283
+ for (const row of queryResult.value) {
284
+ const stateResult = worldGetSceneInstanceState(world, row.entity);
285
+ if (!stateResult.ok) continue;
286
+ const rowValue = row.get(SceneInstance) as unknown as { mapping: ArrayLike<number> };
287
+ stateInfos.push({
288
+ root: row.entity as number,
289
+ state: stateResult.value as unknown as CollectorSceneState,
290
+ mapping: Uint32Array.from(rowValue.mapping as ArrayLike<number>),
291
+ });
292
+ }
293
+ }
294
+ }
295
+ const stateByRoot = new Map<number, SceneStateInfo>();
296
+ for (const info of stateInfos) stateByRoot.set(info.root, info);
297
+
298
+ const parentByChildRoot = new Map<number, SceneStateInfo>();
299
+ const relationCandidates: Array<{ child: SceneStateInfo; parent: SceneStateInfo; key: string }> =
300
+ [];
301
+ for (const child of stateInfos) {
302
+ if (child.state.instanceKey === undefined || childOfToken === undefined) continue;
303
+ const parentResult = world.get(
304
+ child.root as EntityHandle,
305
+ childOfToken as EcsComponent<string>,
306
+ );
307
+ if (!parentResult.ok) continue;
308
+ const carrier = (parentResult.value as Record<string, unknown>).parent as number | undefined;
309
+ if (carrier === undefined) continue;
310
+ for (const parent of stateInfos) {
311
+ const bound = parent.state.bindings.get(child.state.instanceKey);
312
+ if ((bound as number | undefined) === carrier) {
313
+ parentByChildRoot.set(child.root, parent);
314
+ relationCandidates.push({ child, parent, key: child.state.instanceKey });
315
+ break;
316
+ }
317
+ }
318
+ }
319
+
320
+ const findSequence = (
321
+ haystack: Uint32Array,
322
+ needle: Uint32Array,
323
+ start: number,
324
+ ): number | undefined => {
325
+ if (needle.length === 0) return start;
326
+ for (let index = Math.max(0, start); index + needle.length <= haystack.length; index += 1) {
327
+ let match = true;
328
+ for (let offset = 0; offset < needle.length; offset += 1) {
329
+ if (haystack[index + offset] !== needle[offset]) {
330
+ match = false;
331
+ break;
332
+ }
333
+ }
334
+ if (match) return index;
335
+ }
336
+ return undefined;
337
+ };
338
+
339
+ const relations: SceneStateRelation[] = [];
340
+ for (const candidate of relationCandidates) {
341
+ const slot = [...candidate.parent.state.keyByLocalId.entries()].find(
342
+ ([, value]) => value === candidate.key,
343
+ )?.[0];
344
+ if (slot === undefined) continue;
345
+ const memberFirst = findSequence(candidate.parent.mapping, candidate.child.mapping, slot + 1);
346
+ if (memberFirst === undefined) continue;
347
+ relations.push({ ...candidate, memberFirst });
348
+ }
349
+ const relationsByParent = new Map<number, SceneStateRelation[]>();
350
+ for (const relation of relations) {
351
+ const list = relationsByParent.get(relation.parent.root);
352
+ if (list === undefined) relationsByParent.set(relation.parent.root, [relation]);
353
+ else list.push(relation);
354
+ }
355
+
356
+ const statePrefixMemo = new Map<number, readonly string[]>();
357
+ const statePrefix = (info: SceneStateInfo, stack = new Set<number>()): readonly string[] => {
358
+ const prior = statePrefixMemo.get(info.root);
359
+ if (prior !== undefined) return prior;
360
+ if (stack.has(info.root)) return [];
361
+ if (info.state.instanceKey === undefined) {
362
+ statePrefixMemo.set(info.root, []);
363
+ return [];
364
+ }
365
+ const next = new Set(stack);
366
+ next.add(info.root);
367
+ const parent = parentByChildRoot.get(info.root);
368
+ const prefix =
369
+ parent === undefined
370
+ ? [info.state.instanceKey]
371
+ : [...statePrefix(parent, next), info.state.instanceKey];
372
+ statePrefixMemo.set(info.root, prefix);
373
+ return prefix;
374
+ };
375
+
376
+ const stateSlotAddress = (
377
+ info: SceneStateInfo,
378
+ slot: number,
379
+ prefix: readonly string[],
380
+ stack = new Set<number>(),
381
+ ): readonly string[] | undefined => {
382
+ if (slot < 0 || stack.has(info.root)) return undefined;
383
+ const key = info.state.keyByLocalId.get(slot);
384
+ if (key !== undefined) return [...prefix, key];
385
+ const next = new Set(stack);
386
+ next.add(info.root);
387
+ for (const relation of relationsByParent.get(info.root) ?? []) {
388
+ const end = relation.memberFirst + relation.child.mapping.length;
389
+ if (slot >= relation.memberFirst && slot < end) {
390
+ return stateSlotAddress(
391
+ relation.child,
392
+ slot - relation.memberFirst,
393
+ [...prefix, relation.key],
394
+ next,
395
+ );
396
+ }
397
+ }
398
+ return undefined;
399
+ };
400
+
401
+ const rawToAddress = new Map<number, readonly string[]>();
402
+ const orderedStates = [...stateInfos].sort(
403
+ (a, b) => statePrefix(a).length - statePrefix(b).length,
404
+ );
405
+ for (const info of orderedStates) {
406
+ const prefix = statePrefix(info);
407
+ for (let slot = 0; slot < info.mapping.length; slot += 1) {
408
+ const raw = info.mapping[slot];
409
+ if (raw === undefined || raw === 0xffffffff || !visited.has(raw)) continue;
410
+ const address = stateSlotAddress(info, slot, prefix);
411
+ if (address !== undefined && !rawToAddress.has(raw)) rawToAddress.set(raw, address);
412
+ }
413
+ }
414
+ for (const [raw, origin] of memberOrigin) {
415
+ if (!visited.has(raw) || rawToAddress.has(raw)) continue;
416
+ const child = stateByRoot.get(origin.anchorRaw);
417
+ if (child === undefined) continue;
418
+ const address = stateSlotAddress(child, origin.memberLocalId, statePrefix(child));
419
+ if (address !== undefined) rawToAddress.set(raw, address);
420
+ }
421
+
422
+ const generatedKeys = new Set<string>();
423
+ const keyForOwnedRaw = new Map<number, string>();
424
+ for (let index = 0; index < ownedEntities.length; index += 1) {
425
+ const raw = ownedEntities[index] as number;
426
+ const known = rawToAddress.get(raw);
427
+ let key = known?.length === 1 ? known[0] : undefined;
428
+ if (key === undefined || key.length === 0 || generatedKeys.has(key)) {
429
+ const base = `entity-${index}`;
430
+ key = base;
431
+ let suffix = 1;
432
+ while (generatedKeys.has(key)) key = `${base}-${suffix++}`;
433
+ }
434
+ generatedKeys.add(key);
435
+ keyForOwnedRaw.set(raw, key);
436
+ rawToAddress.set(raw, [key]);
437
+ }
438
+
439
+ const slotAddress = (slot: number): SceneEntityAddress | undefined => {
440
+ if (slot >= 0 && slot < ownedEntities.length) {
441
+ const raw = ownedEntities[slot];
442
+ const key = raw === undefined ? undefined : keyForOwnedRaw.get(raw);
443
+ return key;
444
+ }
445
+ const mountIndex = mounts.findIndex((mount) => (mount.localId as unknown as number) === slot);
446
+ if (mountIndex >= 0) {
447
+ const anchor = anchors[mountIndex];
448
+ const child = anchor === undefined ? undefined : stateByRoot.get(anchor.entityRaw);
449
+ const key = child?.state.instanceKey;
450
+ if (key !== undefined) return key;
451
+ }
452
+ for (let index = 0; index < mounts.length; index += 1) {
453
+ const mount = mounts[index];
454
+ if (mount === undefined) continue;
455
+ const first = mount.memberFirst as unknown as number;
456
+ if (slot < first || slot >= first + mount.memberCount) continue;
457
+ const anchor = anchors[index];
458
+ const child = anchor === undefined ? undefined : stateByRoot.get(anchor.entityRaw);
459
+ const mountKey = child?.state.instanceKey;
460
+ if (child === undefined || mountKey === undefined) return undefined;
461
+ const address = stateSlotAddress(child, slot - first, []);
462
+ if (address === undefined) return undefined;
463
+ return [mountKey, ...address] as readonly [string, ...string[]];
464
+ }
465
+ const top = orderedStates.find((info) => statePrefix(info).length === 0);
466
+ if (top !== undefined) {
467
+ const address = stateSlotAddress(top, slot, []);
468
+ if (address !== undefined) {
469
+ return address.length === 1 ? address[0] : (address as readonly [string, ...string[]]);
470
+ }
471
+ }
472
+ return undefined;
473
+ };
474
+
475
+ const convertComponents = (
476
+ entityRaw: number,
477
+ source: Record<string, Record<string, unknown>>,
478
+ ): Result<Record<string, Record<string, unknown>>, KeyedCollectError> => {
479
+ const out: Record<string, Record<string, unknown>> = {};
480
+ for (const [componentName, rawFields] of Object.entries(source)) {
481
+ const token = world.components.resolve(componentName);
482
+ if (token === undefined) {
483
+ out[componentName] = { ...rawFields };
484
+ continue;
485
+ }
486
+ const converted: Record<string, unknown> = {};
487
+ for (const [fieldName, value] of Object.entries(rawFields)) {
488
+ const kind = classifyEntityField(token as EcsComponent, fieldName);
489
+ if (kind === null) {
490
+ converted[fieldName] = value;
491
+ continue;
492
+ }
493
+ const convertSlot = (localSlot: unknown): SceneEntityAddress | undefined => {
494
+ if (typeof localSlot !== 'number') return undefined;
495
+ return slotAddress(localSlot);
496
+ };
497
+ if (kind.isArray) {
498
+ if (!Array.isArray(value)) {
499
+ return err(
500
+ new SceneCollectEntityRefOutOfClosureError(
501
+ entityRaw,
502
+ `${componentName}.${fieldName}`,
503
+ -1,
504
+ ),
505
+ );
506
+ }
507
+ const addresses: SceneEntityAddress[] = [];
508
+ for (const item of value) {
509
+ const address = convertSlot(item);
510
+ if (address === undefined || !isSceneEntityAddress(address)) {
511
+ return err(
512
+ new SceneCollectEntityRefOutOfClosureError(
513
+ entityRaw,
514
+ `${componentName}.${fieldName}`,
515
+ Number(item),
516
+ ),
517
+ );
518
+ }
519
+ addresses.push(address);
520
+ }
521
+ converted[fieldName] = addresses;
522
+ } else if (value === null) {
523
+ converted[fieldName] = null;
524
+ } else {
525
+ const address = convertSlot(value);
526
+ if (address === undefined || !isSceneEntityAddress(address)) {
527
+ return err(
528
+ new SceneCollectEntityRefOutOfClosureError(
529
+ entityRaw,
530
+ `${componentName}.${fieldName}`,
531
+ Number(value),
532
+ ),
533
+ );
534
+ }
535
+ converted[fieldName] = address;
536
+ }
537
+ }
538
+ out[componentName] = converted;
539
+ }
540
+ return ok(out);
541
+ };
542
+
543
+ const entities: Record<string, CollectedKeyedEntity> = {};
544
+ for (const legacy of legacyEntities) {
545
+ const raw = ownedEntities[legacy.localId];
546
+ if (raw === undefined) continue;
547
+ const key = keyForOwnedRaw.get(raw) as string;
548
+ const converted = convertComponents(raw, legacy.components);
549
+ if (!converted.ok) return converted;
550
+ entities[key] = { components: converted.value };
551
+ }
552
+
553
+ const resolveOverrideTarget = (
554
+ child: SceneStateInfo | undefined,
555
+ memberFirst: number,
556
+ localId: number,
557
+ ): readonly [string, ...string[]] | undefined => {
558
+ if (child === undefined) return undefined;
559
+ const target = stateSlotAddress(child, localId - memberFirst, []);
560
+ if (target === undefined || target.length === 0) return undefined;
561
+ return target as readonly [string, ...string[]];
562
+ };
563
+
564
+ const convertOverrideValue = (
565
+ entityRaw: number,
566
+ componentName: string,
567
+ fieldName: string | undefined,
568
+ value: unknown,
569
+ ): Result<unknown, KeyedCollectError> => {
570
+ const token = world.components.resolve(componentName);
571
+ if (token === undefined) return ok(value);
572
+ const schema = componentSchema(token) as Record<string, string>;
573
+ const convertFields = (
574
+ field: string,
575
+ fieldValue: unknown,
576
+ ): Result<unknown, KeyedCollectError> => {
577
+ const kind = classifyEntityField(token as EcsComponent, field);
578
+ if (kind === null) {
579
+ const sharedType = schema[field];
580
+ const shared: SchemaFieldClass | undefined = sharedType?.startsWith('shared<')
581
+ ? { kind: 'shared', scalar: true }
582
+ : sharedType?.startsWith('array<shared<')
583
+ ? { kind: 'shared', scalar: false }
584
+ : undefined;
585
+ if (shared === undefined) return ok(fieldValue);
586
+ return _serializeSharedFieldValue(world, registry, shared, fieldValue, field);
587
+ }
588
+ const resolveLiveOrSlot = (item: unknown): SceneEntityAddress | undefined => {
589
+ if (typeof item !== 'number') return undefined;
590
+ const live = rawToAddress.get(item);
591
+ if (live !== undefined) {
592
+ return live.length === 1 ? live[0] : (live as readonly [string, ...string[]]);
593
+ }
594
+ return slotAddress(item);
595
+ };
596
+ if (kind.isArray) {
597
+ if (!Array.isArray(fieldValue)) return ok(fieldValue);
598
+ const mapped: SceneEntityAddress[] = [];
599
+ for (const item of fieldValue) {
600
+ const address = resolveLiveOrSlot(item);
601
+ if (address === undefined || !isSceneEntityAddress(address)) {
602
+ return err(
603
+ new SceneCollectEntityRefOutOfClosureError(
604
+ entityRaw,
605
+ `${componentName}.${field}`,
606
+ Number(item),
607
+ ),
608
+ );
609
+ }
610
+ mapped.push(address);
611
+ }
612
+ return ok(mapped);
613
+ }
614
+ if (fieldValue === null) return ok(null);
615
+ const address = resolveLiveOrSlot(fieldValue);
616
+ if (address === undefined || !isSceneEntityAddress(address)) {
617
+ return err(
618
+ new SceneCollectEntityRefOutOfClosureError(
619
+ entityRaw,
620
+ `${componentName}.${field}`,
621
+ Number(fieldValue),
622
+ ),
623
+ );
624
+ }
625
+ return ok(address);
626
+ };
627
+ if (fieldName !== undefined) return convertFields(fieldName, value);
628
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return ok(value);
629
+ const result: Record<string, unknown> = {};
630
+ for (const [field, fieldValue] of Object.entries(value as Record<string, unknown>)) {
631
+ const converted = convertFields(field, fieldValue);
632
+ if (!converted.ok) return converted;
633
+ result[field] = converted.value;
634
+ }
635
+ return ok(result);
636
+ };
637
+
638
+ const declarationOverrides = (
639
+ parent: SceneStateInfo | undefined,
640
+ child: SceneStateInfo | undefined,
641
+ mount: SceneInstanceMount,
642
+ anchor: { entityRaw: number },
643
+ ): Result<readonly SceneInstanceOverride[] | undefined, KeyedCollectError> => {
644
+ const memberFirst = mount.memberFirst as unknown as number;
645
+ const candidates: MountOverride[] = [];
646
+ if (parent !== undefined) {
647
+ for (const override of parent.state.mountTimeOverrides) {
648
+ const localId = override.localId as unknown as number;
649
+ if (localId >= memberFirst && localId < memberFirst + mount.memberCount)
650
+ candidates.push(override);
651
+ }
652
+ for (const [localId, records] of parent.state.overrides) {
653
+ const numeric = localId as unknown as number;
654
+ if (numeric < memberFirst || numeric >= memberFirst + mount.memberCount) continue;
655
+ for (const record of records.values()) {
656
+ candidates.push({
657
+ localId,
658
+ comp: record.comp,
659
+ ...(record.field === undefined ? {} : { field: record.field }),
660
+ value: record.value,
661
+ });
662
+ }
663
+ }
664
+ }
665
+ if (candidates.length === 0) candidates.push(...(mount.overrides ?? []));
666
+ if (candidates.length === 0 || child === undefined) return ok(undefined);
667
+ const byTarget = new Map<string, SceneInstanceOverride>();
668
+ for (const override of candidates) {
669
+ const localId = override.localId as unknown as number;
670
+ const target = resolveOverrideTarget(child, memberFirst, localId);
671
+ if (target === undefined) {
672
+ return err(
673
+ new SceneCollectEntityRefOutOfClosureError(
674
+ anchor.entityRaw,
675
+ 'instance.override.target',
676
+ localId,
677
+ ),
678
+ );
679
+ }
680
+ const converted = convertOverrideValue(
681
+ anchor.entityRaw,
682
+ override.comp,
683
+ override.field,
684
+ override.value,
685
+ );
686
+ if (!converted.ok) return converted;
687
+ const targetKey = JSON.stringify(target);
688
+ const prior = byTarget.get(targetKey);
689
+ const components: Record<string, Record<string, unknown>> = {};
690
+ if (prior !== undefined) {
691
+ for (const [name, fields] of Object.entries(
692
+ prior.components as Record<string, Record<string, unknown>>,
693
+ )) {
694
+ components[name] = { ...fields };
695
+ }
696
+ }
697
+ const priorFields = components[override.comp] as Record<string, unknown> | undefined;
698
+ const existing: Record<string, unknown> = { ...(priorFields ?? {}) };
699
+ if (
700
+ override.field === undefined &&
701
+ typeof converted.value === 'object' &&
702
+ converted.value !== null
703
+ ) {
704
+ Object.assign(existing, converted.value as Record<string, unknown>);
705
+ } else if (override.field !== undefined) {
706
+ existing[override.field] = converted.value;
707
+ }
708
+ components[override.comp] = existing;
709
+ byTarget.set(targetKey, { target, components });
710
+ }
711
+ return ok([...byTarget.values()]);
712
+ };
713
+
714
+ for (let index = 0; index < mounts.length; index += 1) {
715
+ const mount = mounts[index];
716
+ const anchor = anchors[index];
717
+ if (mount === undefined || anchor === undefined) continue;
718
+ const child = stateByRoot.get(anchor.entityRaw);
719
+ const parent = child === undefined ? undefined : parentByChildRoot.get(child.root);
720
+ const instanceKey = child?.state.instanceKey ?? `instance-${index}`;
721
+ const carrierRaw =
722
+ childOfToken === undefined
723
+ ? undefined
724
+ : (() => {
725
+ const parentResult = world.get(
726
+ anchor.entityRaw as EntityHandle,
727
+ childOfToken as EcsComponent<string>,
728
+ );
729
+ return parentResult.ok
730
+ ? ((parentResult.value as Record<string, unknown>).parent as number | undefined)
731
+ : undefined;
732
+ })();
733
+ const converted = convertComponents(
734
+ carrierRaw ?? anchor.entityRaw,
735
+ (mount.components ?? {}) as Record<string, Record<string, unknown>>,
736
+ );
737
+ if (!converted.ok) return converted;
738
+ if (mount.parent !== undefined) {
739
+ const parentAddress = slotAddress(mount.parent as unknown as number);
740
+ if (parentAddress === undefined) {
741
+ return err(
742
+ new SceneCollectEntityRefOutOfClosureError(
743
+ carrierRaw ?? anchor.entityRaw,
744
+ 'ChildOf.parent',
745
+ mount.parent as unknown as number,
746
+ ),
747
+ );
748
+ }
749
+ converted.value.ChildOf = { parent: parentAddress };
750
+ }
751
+ const overrides = declarationOverrides(parent, child, mount, anchor);
752
+ if (!overrides.ok) return overrides;
753
+ entities[instanceKey] = {
754
+ components: converted.value,
755
+ instance: {
756
+ source: String(mount.source),
757
+ ...(overrides.value === undefined ? {} : { overrides: overrides.value }),
758
+ },
759
+ };
760
+ }
761
+
762
+ return ok({ kind: 'scene', entities });
763
+ }
764
+
216
765
  // serializeSceneAssetToPack — emits the current Pack v2/local-artifact envelope.
217
766
  export function serializeSceneAssetToPack(
218
767
  sceneAsset: SceneAsset,
@@ -271,7 +820,7 @@ export function rootsToSceneAsset(
271
820
  // ── Step 1: BFS closure ──
272
821
  const visited = new Set<number>();
273
822
  for (const root of roots) collectSubtree(world, root, visited);
274
- if (visited.size === 0) return ok({ kind: 'scene', entities: [] });
823
+ if (visited.size === 0) return ok({ kind: 'scene', entities: {} });
275
824
 
276
825
  const rootRawSet = new Set<number>();
277
826
  for (const r of roots) rootRawSet.add(r as number);
@@ -298,13 +847,55 @@ export function rootsToSceneAsset(
298
847
  if (rootRawSet.has(er)) continue; // root anchor: don't classify members
299
848
  const sr = worldGetSceneInstanceState(world, er as EntityHandle);
300
849
  if (!sr.ok) continue;
301
- for (const [me, lid] of sr.value.entityToLocalId) {
850
+ const retainedState = sr.value as unknown as CollectorSceneState;
851
+ for (const [me, lid] of retainedState.entityToLocalId) {
302
852
  const mr = me as number;
303
853
  if (visited.has(mr) && !anchorEntities.has(mr) && !memberEntities.has(mr)) {
304
854
  memberEntities.add(mr);
305
855
  memberOrigin.set(mr, { anchorRaw: er, memberLocalId: lid as unknown as number });
306
856
  }
307
857
  }
858
+ // Mount carriers are structural and therefore absent from
859
+ // `entityToLocalId`, but the parent instance still exposes them through
860
+ // `bindings` and its keyed slot map. Include those slots so a nested
861
+ // instance is folded into its owning child scene rather than emitted as a
862
+ // second top-level instance.
863
+ for (const [key, member] of retainedState.bindings) {
864
+ const mr = member as number;
865
+ const localId = [...retainedState.keyByLocalId.entries()].find(
866
+ ([, value]) => value === key,
867
+ )?.[0];
868
+ if (
869
+ localId !== undefined &&
870
+ visited.has(mr) &&
871
+ !anchorEntities.has(mr) &&
872
+ !memberEntities.has(mr)
873
+ ) {
874
+ memberEntities.add(mr);
875
+ memberOrigin.set(mr, { anchorRaw: er, memberLocalId: localId });
876
+ }
877
+ }
878
+ }
879
+
880
+ // A nested synthetic SceneInstance root is structural too. Its carrier is
881
+ // already claimed by the parent instance above; fold the anchor into that
882
+ // same member origin before allocating parent mounts.
883
+ const childOfForAnchor = world.components.resolve('ChildOf');
884
+ if (childOfForAnchor !== undefined) {
885
+ for (const anchorRaw of [...anchorEntities]) {
886
+ if (rootRawSet.has(anchorRaw) || memberEntities.has(anchorRaw)) continue;
887
+ const parentRes = world.get(
888
+ anchorRaw as EntityHandle,
889
+ childOfForAnchor as EcsComponent<string>,
890
+ );
891
+ if (!parentRes.ok) continue;
892
+ const carrierRaw = (parentRes.value as Record<string, unknown>).parent as number | undefined;
893
+ const origin = carrierRaw === undefined ? undefined : memberOrigin.get(carrierRaw);
894
+ if (origin !== undefined) {
895
+ memberEntities.add(anchorRaw);
896
+ memberOrigin.set(anchorRaw, origin);
897
+ }
898
+ }
308
899
  }
309
900
 
310
901
  // Remove inner anchors (members of outer anchors).
@@ -388,7 +979,12 @@ export function rootsToSceneAsset(
388
979
  const ownedEntities: number[] = [];
389
980
  for (const er of orderedEntities) {
390
981
  if (carrierToAnchor.has(er)) continue; // absorbed into its mount
391
- if ((!anchorEntities.has(er) || rootRawSet.has(er)) && !memberEntities.has(er)) {
982
+ // A root SceneInstance is the transient synthetic anchor returned by
983
+ // instantiate(). Its mapped members are authored entities; the anchor
984
+ // itself has no durable declaration and must not be minted as `entity-0`
985
+ // during collect, otherwise each collect/reload cycle adds another root.
986
+ if (rootRawSet.has(er) && anchorEntities.has(er)) continue;
987
+ if (!anchorEntities.has(er) && !memberEntities.has(er)) {
392
988
  ownedEntities.push(er);
393
989
  }
394
990
  }
@@ -555,7 +1151,10 @@ export function rootsToSceneAsset(
555
1151
 
556
1152
  // ── Step 4: Build SceneEntity rows ──
557
1153
  const registeredComps = world.components.entries();
558
- const entities: SceneEntity[] = [];
1154
+ const legacyEntities: Array<{
1155
+ localId: number;
1156
+ components: Record<string, Record<string, unknown>>;
1157
+ }> = [];
559
1158
 
560
1159
  for (let lid = 0; lid < ownedEntities.length; lid++) {
561
1160
  const entityRaw = ownedEntities[lid];
@@ -626,6 +1225,19 @@ export function rootsToSceneAsset(
626
1225
  }
627
1226
 
628
1227
  if (entityKind !== null) {
1228
+ // The synthetic root anchor is transient and is intentionally omitted
1229
+ // from the authored entity map. A member's implicit ChildOf edge to
1230
+ // that anchor has no authored counterpart, so omit that one edge
1231
+ // before the keyed remap instead of reporting a false out-of-closure
1232
+ // reference.
1233
+ if (
1234
+ !entityKind.isArray &&
1235
+ typeof rawValue === 'number' &&
1236
+ rootRawSet.has(rawValue) &&
1237
+ anchorEntities.has(rawValue)
1238
+ ) {
1239
+ continue;
1240
+ }
629
1241
  // Entity / array<entity> field — use shared kernel for remap
630
1242
  if (entityKind.isArray) {
631
1243
  const arr = _isArrayLike(rawValue)
@@ -690,12 +1302,18 @@ export function rootsToSceneAsset(
690
1302
  if (Object.keys(fieldValues).length > 0) components[compName] = fieldValues;
691
1303
  }
692
1304
 
693
- entities.push({ localId: lid as LocalEntityId, components } as SceneEntity);
1305
+ legacyEntities.push({ localId: lid, components });
694
1306
  }
695
1307
 
696
- return ok({
697
- kind: 'scene',
698
- entities,
699
- ...(outMounts.length > 0 ? { mounts: outMounts } : {}),
700
- });
1308
+ return collectKeyedSceneAsset(
1309
+ world,
1310
+ registry,
1311
+ roots,
1312
+ visited,
1313
+ ownedEntities,
1314
+ legacyEntities,
1315
+ outMounts,
1316
+ nonRootAnchors,
1317
+ memberOrigin,
1318
+ );
701
1319
  }