@vgai/engine 0.4.1 → 0.5.0-canary.20260719.0

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 (103) hide show
  1. package/README.md +48 -15
  2. package/package.json +11 -25
  3. package/schemas/engine-capabilities.json +10 -10
  4. package/schemas/entity2d.schema.json +468 -0
  5. package/schemas/mat.schema.json +2 -33
  6. package/schemas/prefab.schema.json +16 -172
  7. package/schemas/scn2d.schema.json +42 -23
  8. package/schemas/vgai-game.schema.json +34 -0
  9. package/schemas/vscn.schema.json +16 -172
  10. package/src/adapter/authoring.ts +152 -2
  11. package/src/adapter/colyseus-networking-adapter.ts +35 -1
  12. package/src/adapter/first-party-systems.ts +7 -1
  13. package/src/adapter/game-adapter.ts +13 -0
  14. package/src/adapter/index.ts +25 -0
  15. package/src/adapter/rapier-physics-adapter.ts +55 -2
  16. package/src/adapter/system-adapter.ts +249 -2
  17. package/src/adapter/vgai-scene-game-adapter.ts +149 -25
  18. package/src/ai/navigation.ts +28 -0
  19. package/src/animation/clip-map.ts +1 -8
  20. package/src/animation/theatre-director.ts +50 -0
  21. package/src/animation/xstate-animation-binding.ts +6 -0
  22. package/src/audio/audio-introspection.ts +290 -0
  23. package/src/audio/tone-context.ts +46 -0
  24. package/src/dev/chrome-trace.ts +153 -0
  25. package/src/dev/performance-profiler.ts +93 -6
  26. package/src/dev/render-debug-adapter.ts +199 -0
  27. package/src/dev/render-memory.ts +243 -0
  28. package/src/dev/webgl-frame-capture.ts +424 -0
  29. package/src/ecs/component-manager.ts +43 -10
  30. package/src/ecs/game-component.ts +39 -10
  31. package/src/input/input-manager.ts +24 -19
  32. package/src/input/input-types.ts +1 -1
  33. package/src/loader.ts +7 -0
  34. package/src/manifest/load.ts +14 -0
  35. package/src/manifest/schema.ts +65 -0
  36. package/src/react/game-state.tsx +1 -1
  37. package/src/render/render-batch-system.ts +26 -12
  38. package/src/render/spark-renderer-lifecycle.ts +64 -0
  39. package/src/runtime/create-runtime.ts +33 -5
  40. package/src/runtime/debug-bridge.ts +5 -5
  41. package/src/runtime/game.ts +102 -20
  42. package/src/runtime/mount-manifest.ts +1 -1
  43. package/src/runtime/render-control.ts +121 -0
  44. package/src/runtime/types.ts +1 -1
  45. package/src/scene/asset-loaders.ts +77 -3
  46. package/src/scene/instance-mesh.ts +25 -0
  47. package/src/scene/material-factory.ts +4 -15
  48. package/src/scene/mesh-shadow.ts +18 -0
  49. package/src/scene/particles-factory.ts +59 -0
  50. package/src/scene/scene-loader.ts +41 -16
  51. package/src/scene/schema/instances.ts +1 -2
  52. package/src/scene/schema/material.ts +83 -94
  53. package/src/scene/schema/mesh.ts +76 -90
  54. package/src/scene/schema/scene-file.ts +1 -2
  55. package/src/scene/user-data.ts +30 -14
  56. package/src/setup/setup-renderer.ts +6 -1
  57. package/src/world2d/asset-paths2d.ts +44 -0
  58. package/src/world2d/collision-2d.ts +7 -14
  59. package/src/world2d/entity2d-asset.ts +22 -0
  60. package/src/world2d/index.ts +27 -2
  61. package/src/world2d/physics2d-transform.ts +173 -0
  62. package/src/world2d/physics2d-units.ts +10 -0
  63. package/src/world2d/pixi-game-adapter.ts +148 -36
  64. package/src/world2d/scene2d-identity.ts +49 -0
  65. package/src/world2d/scene2d-loader.ts +243 -119
  66. package/src/world2d/schema/entity2d.ts +51 -33
  67. package/src/world2d/schema/physics2d.ts +14 -3
  68. package/src/world2d/schema/sprite.ts +32 -4
  69. package/src/world2d/schema/tilemap.ts +26 -9
  70. package/src/world2d/transform-writer-2d.ts +29 -11
  71. package/src/world2d/types.ts +21 -8
  72. package/src/world3d-react/behavior.tsx +138 -0
  73. package/src/world3d-react/engine-bridge.ts +48 -0
  74. package/src/world3d-react/index.ts +44 -0
  75. package/src/world3d-react/r3f-adapter.tsx +303 -0
  76. package/src/world3d-react/world-context.ts +294 -0
  77. package/vendor/realism-effects/LICENSE.md +21 -0
  78. package/vendor/realism-effects/UPSTREAM.md +19 -0
  79. package/vendor/realism-effects/dist/index.cjs +3447 -0
  80. package/vendor/realism-effects/dist/index.d.ts +59 -0
  81. package/vendor/realism-effects/dist/index.js +3434 -0
  82. package/vendor/realism-effects/package.json +23 -0
  83. package/src/character/cloth-sim.ts +0 -533
  84. package/src/character/spring-chain.ts +0 -307
  85. package/src/humanoid/body.ts +0 -663
  86. package/src/humanoid/clips.ts +0 -149
  87. package/src/humanoid/compose.ts +0 -209
  88. package/src/humanoid/generate.ts +0 -189
  89. package/src/humanoid/index.ts +0 -36
  90. package/src/humanoid/schema.ts +0 -108
  91. package/src/humanoid/skeleton.ts +0 -345
  92. package/src/react/humanoid-bake.document.tsx +0 -337
  93. package/src/scene/geometries/index.ts +0 -7
  94. package/src/scene/geometries/terrain.ts +0 -42
  95. package/src/scene/geometry-registry.ts +0 -42
  96. package/src/scene/instance-registry.ts +0 -84
  97. package/src/scene/instancers/grid.ts +0 -38
  98. package/src/scene/instancers/index.ts +0 -7
  99. package/src/scene/material-registry.ts +0 -73
  100. package/src/scene/materials/index.ts +0 -7
  101. package/src/scene/materials/water.ts +0 -56
  102. package/src/world2d/components-2d.ts +0 -86
  103. package/tools/humanoid-bake.tool.ts +0 -274
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import RAPIER from '@dimforge/rapier3d-compat';
16
+ import type { SparkRenderer } from '@sparkjsdev/spark';
16
17
  import type { EffectComposer } from 'postprocessing';
17
18
  import * as THREE from 'three';
18
19
  import {
@@ -22,6 +23,14 @@ import {
22
23
  } from '../core/seeded-random';
23
24
  import { createSystemRunner } from '../core/system-runner';
24
25
  import { createDebugDraw } from '../dev/debug-draw';
26
+ import { log } from '../dev/logger';
27
+ import {
28
+ createRenderDebugAdapter,
29
+ frameCaptureContextFor,
30
+ type RenderDebugWiring,
31
+ } from '../dev/render-debug-adapter';
32
+ import { collectRenderMemory } from '../dev/render-memory';
33
+ import { createWebGLFrameCapture } from '../dev/webgl-frame-capture';
25
34
  import { createWebGLGpuTimer } from '../dev/webgl-gpu-timer';
26
35
  import { createComponentManager } from '../ecs/component-manager';
27
36
  import { InputManager } from '../input/input-manager';
@@ -32,6 +41,12 @@ import { createTransformWriter } from '../physics/transform-writer';
32
41
  import { createTriggerDispatch } from '../physics/trigger-dispatch';
33
42
  import { RenderBatchSystem } from '../render/render-batch-system';
34
43
  import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
44
+ import {
45
+ disposeSparkRendererWhenIdle,
46
+ SPARK_DISCOVERY_INTERVAL_MS,
47
+ sceneHasGaussianSplat,
48
+ shouldDiscoverGaussianSplat,
49
+ } from '../render/spark-renderer-lifecycle';
35
50
  import { type ViewportShadingMode, ViewportShadingRenderer } from '../render/viewport-shading';
36
51
  import {
37
52
  createDebugRegistry,
@@ -41,7 +56,6 @@ import {
41
56
  import type { WorldFrameHooks } from '../runtime/game';
42
57
  import type { EditorPreview, GameCleanup, GameContext, GameSetupFn } from '../runtime/types';
43
58
  import type { ComponentRegistry } from '../scene/component-registry';
44
- import { tickCustomMaterials } from '../scene/material-registry';
45
59
  import {
46
60
  clearAssetCaches,
47
61
  loadScene,
@@ -206,8 +220,12 @@ export class VgaiSceneGameAdapter implements GameAdapter {
206
220
  const writeTransforms = createTransformWriter(physicsRegistry, scene);
207
221
 
208
222
  // --- Input (InputManager attaches window listeners; stub it headlessly) ---
209
- const input = headless ? headlessInput() : new InputManager();
210
- if (!headless) await input.loadMap(inputMapPath);
223
+ const ownsInput = !host.game;
224
+ const input = host.game?.input ?? (headless ? headlessInput() : new InputManager());
225
+ if (!headless) {
226
+ if (host.game) await host.game.loadInputMap(inputMapPath);
227
+ else await input.loadMap(inputMapPath);
228
+ }
211
229
 
212
230
  // --- Collision system ---
213
231
  const collisions = createCollisionSystem(physics.rapierWorld, physics.eventQueue);
@@ -230,6 +248,22 @@ export class VgaiSceneGameAdapter implements GameAdapter {
230
248
  !headless && rendererContext && typeof rendererContext.getExtension === 'function'
231
249
  ? createWebGLGpuTimer(rendererContext)
232
250
  : null;
251
+ // W4b (F11 frame debugger): first-party WebGL2 frame capture, constructed
252
+ // under the SAME real-context guard as gpuTimer — a headless/stub mount
253
+ // (`frameCaptureContextFor` returns null) never builds it and never
254
+ // registers `renderDebug`, so the capability is honestly absent there. The
255
+ // capture only patches the context WHILE a `captureFrame()` is armed (see
256
+ // beginPass/endPass in the render system below); every non-capture frame
257
+ // is zero-overhead.
258
+ const frameCaptureContext = frameCaptureContextFor(headless, rendererContext);
259
+ const frameCapture = frameCaptureContext ? createWebGLFrameCapture(frameCaptureContext) : null;
260
+ const renderDebugWiring: RenderDebugWiring | null = frameCapture
261
+ ? createRenderDebugAdapter({
262
+ capture: frameCapture,
263
+ scene,
264
+ memory: () => collectRenderMemory(scene, renderer.info),
265
+ })
266
+ : null;
233
267
  const viewportShading = new ViewportShadingRenderer();
234
268
  let viewportShadingMode: ViewportShadingMode = 'solid';
235
269
 
@@ -246,9 +280,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
246
280
  // key off, so `InputManager.poll()` falls back to its own internal
247
281
  // self-incrementing counter instead (unchanged pre-D15 behavior) rather
248
282
  // than being pinned to a constant `0` forever.
249
- systems.add('input', () => input.poll(host.game ? debugRegistry.getGameTick() : undefined), {
250
- name: 'input.poll',
251
- });
283
+ if (ownsInput) systems.add('input', () => input.poll(), { name: 'input.poll' });
252
284
  systems.add('physics', () => physics.rapierWorld.step(physics.eventQueue), {
253
285
  name: 'physics.step',
254
286
  });
@@ -264,8 +296,28 @@ export class VgaiSceneGameAdapter implements GameAdapter {
264
296
  'preRender',
265
297
  (dt) => {
266
298
  particles.batchedRenderer.update(dt);
267
- tickCustomMaterials(scene, dt);
268
299
  updateSceneLODs(scene, camera);
300
+ sparkDiscoveryElapsedMs += dt * 1_000;
301
+ const shouldDiscover = shouldDiscoverGaussianSplat(0, sparkDiscoveryElapsedMs);
302
+ if (!sparkRenderer && !sparkRendererLoading && !sparkRendererFailed && shouldDiscover) {
303
+ sparkDiscoveryElapsedMs = 0;
304
+ if (!sceneHasGaussianSplat(scene)) return;
305
+ sparkRendererLoading = true;
306
+ void import('@sparkjsdev/spark')
307
+ .then(({ SparkRenderer }) => {
308
+ if (disposed || sparkRenderer) return;
309
+ sparkRenderer = new SparkRenderer({ renderer, enableLod: false });
310
+ sparkRenderer.traverse((node) => setUserData(node, 'engineInternal', true));
311
+ })
312
+ .catch((error: unknown) => {
313
+ sparkRendererFailed = true;
314
+ // biome-ignore lint/suspicious/noConsole: A missing renderer chunk must fail once and loudly, not retry every frame.
315
+ console.error('Failed to install SparkRenderer for a live Gaussian splat.', error);
316
+ })
317
+ .finally(() => {
318
+ sparkRendererLoading = false;
319
+ });
320
+ }
269
321
  },
270
322
  { name: 'materials-and-particles' },
271
323
  );
@@ -274,6 +326,13 @@ export class VgaiSceneGameAdapter implements GameAdapter {
274
326
  'render',
275
327
  (dt) => {
276
328
  if (host.game?.profiler.enabled) gpuTimer?.begin();
329
+ if (sparkRenderer) scene.add(sparkRenderer);
330
+ // W4b: arm-gated frame capture wraps the real draw calls. beginPass
331
+ // is a no-op unless a captureFrame() armed it; afterRender delivers
332
+ // the assembled capture to a pending captureFrame() and restores the
333
+ // patched context — in the SAME finally as gpuTimer.end(). Spark is
334
+ // added before this so its draws fall inside the captured frame.
335
+ renderDebugWiring?.beforeRender();
277
336
  try {
278
337
  viewportShading.render(
279
338
  scene,
@@ -285,6 +344,8 @@ export class VgaiSceneGameAdapter implements GameAdapter {
285
344
  (mesh) => camera.layers.test(mesh.layers),
286
345
  );
287
346
  } finally {
347
+ renderDebugWiring?.afterRender();
348
+ sparkRenderer?.removeFromParent();
288
349
  gpuTimer?.end();
289
350
  }
290
351
  if (physics.debugEnabled) updatePhysicsDebug(physics.rapierWorld, physics.debugMesh);
@@ -303,7 +364,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
303
364
  if (!headless) window.addEventListener('keydown', onDebugToggle);
304
365
 
305
366
  const postFrame = () => {
306
- input.endFrame();
367
+ if (ownsInput) input.endFrame();
307
368
  if (host.game?.profiler.enabled) {
308
369
  host.game.profiler.reportRender({
309
370
  gpuMs: gpuTimer?.poll() ?? null,
@@ -377,8 +438,8 @@ export class VgaiSceneGameAdapter implements GameAdapter {
377
438
  // resolves, not here (no NavMeshManager exists yet at this point in the
378
439
  // mount). `networking` remains the one game-owned capability with no
379
440
  // first-party implementer; it simply starts absent here.
380
- systemAdapters.physics = createRapierPhysicsAdapter(physicsRegistry, physics.debugMesh);
381
- systemAdapters.input = createInputManagerAdapter(input);
441
+ systemAdapters.physics = createRapierPhysicsAdapter(physicsRegistry, physics);
442
+ if (ownsInput) systemAdapters.input = createInputManagerAdapter(input);
382
443
  systemAdapters.assets = createVgaiAssetAdapter();
383
444
  // D10/T7.6: the audio seam `Game.play.pause()` silences on pause. Works
384
445
  // against the headless stand-in too (`headlessAudio()`'s plain
@@ -396,6 +457,11 @@ export class VgaiSceneGameAdapter implements GameAdapter {
396
457
  (host.game ? getDebugRegistry(host.game) : null) ??
397
458
  createDebugRegistry({ getTick: () => 0, getSimT: () => 0 });
398
459
  systemAdapters.debug = debugRegistry.adapter;
460
+ // W4b: the render-debug capability is engine-owned and first-party, seeded
461
+ // here (like physics/input/assets) BEFORE the engineAdapterKinds snapshot
462
+ // below so a warm restart / stop never mistakes it for game content. Only
463
+ // present when a real WebGL2 context backed the mount (renderDebugWiring).
464
+ if (renderDebugWiring) systemAdapters.renderDebug = renderDebugWiring.adapter;
399
465
  ctx.debug = debugRegistry.forWorld(this.id);
400
466
  // T1.2: the InputManager is a first-party per-world handle the registry
401
467
  // has no other way to reach — wired as a lazy supplier (not a direct
@@ -449,7 +515,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
449
515
  // to THIS world's debug registry, same seed spot/precedent as
450
516
  // `setInputActionsSource`/`setVirtualInputTarget` above (typeof-guarded:
451
517
  // a headless `InputManager` stand-in may have no `setDebugEmit`).
452
- if (typeof input.setDebugEmit === 'function') {
518
+ if (!host.game && typeof input.setDebugEmit === 'function') {
453
519
  input.setDebugEmit((event, detail) => ctx.debug?.emit(event, detail));
454
520
  }
455
521
  // D15 (T-D15.1) — same "ONE per Game root, shared across every world"
@@ -490,11 +556,18 @@ export class VgaiSceneGameAdapter implements GameAdapter {
490
556
  }
491
557
  let currentCleanup: GameCleanup | null = null;
492
558
  let batchSystem: RenderBatchSystem | null = null;
559
+ let sparkRenderer: SparkRenderer | null = null;
560
+ let sparkRendererLoading = false;
561
+ let sparkRendererFailed = false;
562
+ // Discovery is render-loop scheduling, so advance it from the loop's `dt`
563
+ // instead of introducing a second wall-clock frontier into engine code.
564
+ // Start at the threshold so the first rendered frame discovers eager splats.
565
+ let sparkDiscoveryElapsedMs = SPARK_DISCOVERY_INTERVAL_MS;
493
566
 
494
567
  // Apply scene-level render settings + transparent auto-batching after a scene
495
568
  // loads (GPU host only). Gated on the resolved `environment.rendering` cascade
496
569
  // (defaults preserve prior behavior when a scene omits `rendering`).
497
- const applySceneRendering = (inst: SceneInstance): void => {
570
+ const applySceneRendering = async (inst: SceneInstance): Promise<void> => {
498
571
  if (headless) return;
499
572
  const scope = inst.environment?.rendering as RenderScope | undefined;
500
573
  const settings = resolveRenderSettings(scope);
@@ -509,6 +582,11 @@ export class VgaiSceneGameAdapter implements GameAdapter {
509
582
  if (import.meta.env?.DEV)
510
583
  (window as unknown as { __vgaiBatch?: unknown }).__vgaiBatch = batchSystem;
511
584
  }
585
+ if (inst.splatCount > 0 && !sparkRenderer) {
586
+ const { SparkRenderer } = await import('@sparkjsdev/spark');
587
+ sparkRenderer = new SparkRenderer({ renderer, enableLod: false });
588
+ sparkRenderer.traverse((node) => setUserData(node, 'engineInternal', true));
589
+ }
512
590
  };
513
591
 
514
592
  // Wire the (first-party) navigation system adapter to whichever
@@ -536,7 +614,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
536
614
  particleRenderer: ctx.particles.batchedRenderer,
537
615
  ...(headless ? {} : { renderer }),
538
616
  });
539
- applySceneRendering(inst);
617
+ await applySceneRendering(inst);
540
618
  applySceneNavigation(inst);
541
619
  ctx.systems.add('postPhysics', (dt) => inst.update(dt));
542
620
  return inst;
@@ -546,8 +624,12 @@ export class VgaiSceneGameAdapter implements GameAdapter {
546
624
  if (headless) throw new Error('VgaiSceneGameAdapter: setup-based games require a GPU host');
547
625
  currentCleanup = await setup(ctx, editorPreview);
548
626
  } else if (scenePath) {
549
- // #139: captured BEFORE load so adoptSceneCamera can tell whether a
550
- // component (e.g. SceneCamera.init) already framed the camera.
627
+ // The editor viewport is only the fallback/initial play viewpoint. Apply
628
+ // it BEFORE capturing the load baseline so authored scene cameras and
629
+ // camera GameComponents can override it during/after scene load.
630
+ applyEditorCamera(editorPreview, camera);
631
+ // #139: captured immediately BEFORE load so adoptSceneCamera can tell
632
+ // whether a component (e.g. SceneCamera.init) framed the camera.
551
633
  const preLoadPose = captureCameraPose(camera);
552
634
  const inst = await loadScene(scenePath, {
553
635
  scene,
@@ -560,8 +642,8 @@ export class VgaiSceneGameAdapter implements GameAdapter {
560
642
  particleRenderer: ctx.particles.batchedRenderer,
561
643
  ...(headless ? {} : { renderer }),
562
644
  });
563
- applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera, preLoadPose);
564
- applySceneRendering(inst);
645
+ adoptSceneCamera(inst, camera, preLoadPose);
646
+ await applySceneRendering(inst);
565
647
  applySceneNavigation(inst);
566
648
  ctx.systems.add('postPhysics', (dt) => inst.update(dt));
567
649
  // A pre-baked navmesh (sibling .navmesh file — see scene-loader.ts) holds
@@ -574,9 +656,10 @@ export class VgaiSceneGameAdapter implements GameAdapter {
574
656
  },
575
657
  };
576
658
  } else if (sceneData) {
659
+ applyEditorCamera(editorPreview, camera);
577
660
  const preLoadPose = captureCameraPose(camera);
578
661
  const inst = await loadFromData(sceneData);
579
- applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera, preLoadPose);
662
+ adoptSceneCamera(inst, camera, preLoadPose);
580
663
  currentCleanup = {
581
664
  dispose: () => {
582
665
  inst.navMesh?.dispose(scene);
@@ -590,6 +673,30 @@ export class VgaiSceneGameAdapter implements GameAdapter {
590
673
  // adapter.
591
674
  let disposed = false;
592
675
 
676
+ // W3c: attach the read-only Audio-debugger introspection capabilities
677
+ // (graph/transport/meters/events) onto the SAME first-party audio adapter
678
+ // seeded above. Dynamically imported so `tone` stays out of the
679
+ // mount-critical chunk (the Colyseus dynamic-import idiom); the editor
680
+ // panel polls, so a few-ms-late attachment is invisible. If the game
681
+ // replaced `systems.audio` with its OWN adapter during setup(), the
682
+ // first-party augmentation is skipped — the game's adapter is the truth
683
+ // the editor sees.
684
+ let disposeAudioIntrospection: (() => void) | null = null;
685
+ const firstPartyAudioAdapter = systemAdapters.audio;
686
+ if (firstPartyAudioAdapter) {
687
+ void import('../audio/audio-introspection').then(
688
+ (m) => {
689
+ if (disposed || systemAdapters.audio !== firstPartyAudioAdapter) return;
690
+ disposeAudioIntrospection = m.attachAudioIntrospection(firstPartyAudioAdapter, audio);
691
+ },
692
+ (err) => {
693
+ log.audio.warn('VgaiSceneGameAdapter: audio introspection unavailable', {
694
+ error: String(err),
695
+ });
696
+ },
697
+ );
698
+ }
699
+
593
700
  // Run one teardown step in isolation: a throwing step (e.g. a
594
701
  // GameComponent's dispose() misbehaving, or a WASM free() panicking) must
595
702
  // not abort the remaining steps — otherwise a single bad component leaks
@@ -618,6 +725,12 @@ export class VgaiSceneGameAdapter implements GameAdapter {
618
725
  currentCleanup?.dispose();
619
726
  currentCleanup = null;
620
727
  });
728
+ safeStep('sparkRenderer.dispose', () => {
729
+ if (!sparkRenderer) return;
730
+ sparkRenderer.removeFromParent();
731
+ disposeSparkRendererWhenIdle(sparkRenderer);
732
+ sparkRenderer = null;
733
+ });
621
734
  // The scene instance's NavMeshManager (if any) was just disposed above
622
735
  // (currentCleanup.dispose calls `inst.navMesh?.dispose(scene)`) — clear
623
736
  // the wrapper too, or `mounted.systems.navigation` would keep pointing
@@ -689,12 +802,20 @@ export class VgaiSceneGameAdapter implements GameAdapter {
689
802
  if (!engineAdapterKinds.has(kind)) delete systemAdapters[kind as keyof SystemAdapters];
690
803
  }
691
804
  });
692
- // Defect 2 fix: a full Stop means the whole GAME is going away (every
693
- // world it mounted, plus any react-door registrations from its HUDs)
694
- // — unlike hotReload below (a SINGLE mount warm-restarting), so this is
695
- // the one call site that legitimately wants strip()'s global, no-id
696
- // form rather than scoping to `this.id`.
697
- safeStep('debugRegistry.strip', () => debugRegistry.strip());
805
+ // A game-hosted mount owns only its world-scoped registrations; sibling
806
+ // roots remain alive during root replacement. A bare standalone adapter
807
+ // owns the registry and can clear it globally.
808
+ safeStep('debugRegistry.strip', () =>
809
+ host.game ? debugRegistry.strip(this.id) : debugRegistry.strip(),
810
+ );
811
+
812
+ // W3c: detach introspection BEFORE the audio teardown below — releases
813
+ // Tone transport listeners (the transport singleton outlives this
814
+ // world), the context statechange hook, and any meter taps still alive.
815
+ safeStep('audio introspection dispose', () => {
816
+ disposeAudioIntrospection?.();
817
+ disposeAudioIntrospection = null;
818
+ });
698
819
 
699
820
  if (!headless) {
700
821
  safeStep('audio teardown', () => {
@@ -704,7 +825,10 @@ export class VgaiSceneGameAdapter implements GameAdapter {
704
825
  composer.dispose();
705
826
  });
706
827
  }
707
- safeStep('input.dispose', () => input.dispose());
828
+ if (ownsInput) safeStep('input.dispose', () => input.dispose());
829
+ // W4b: restore any pending capture wrappers and reject a capture in
830
+ // flight (a Stop mid-capture must not leave the context patched).
831
+ safeStep('renderDebug.dispose', () => renderDebugWiring?.dispose());
708
832
  safeStep('gpuTimer.dispose', () => gpuTimer?.dispose());
709
833
  safeStep('viewportShading.dispose', () => viewportShading.dispose());
710
834
  };
@@ -24,6 +24,15 @@ export interface Vector3Like {
24
24
  z: number;
25
25
  }
26
26
 
27
+ /** One crowd agent's live state — a plain-data snapshot safe to hand across
28
+ * the adapter seam (no WASM handles), for editor debug draw. */
29
+ export interface CrowdAgentSnapshot {
30
+ position: Vector3Like;
31
+ velocity: Vector3Like;
32
+ radius: number;
33
+ height: number;
34
+ }
35
+
27
36
  export interface NavMeshBuildParams {
28
37
  cellSize?: number | undefined;
29
38
  cellHeight?: number | undefined;
@@ -130,6 +139,25 @@ export class NavMeshManager {
130
139
  this.crowd?.update(dt);
131
140
  }
132
141
 
142
+ /**
143
+ * Plain-data snapshots of every active crowd agent (interpolated position,
144
+ * velocity, radius, height) — the introspection feed for the editor's
145
+ * play-mode crowd debug draw. Empty when no crowd exists.
146
+ */
147
+ getCrowdAgents(): CrowdAgentSnapshot[] {
148
+ if (!this.crowd) return [];
149
+ return this.crowd.getAgents().map((a) => {
150
+ const p = a.interpolatedPosition;
151
+ const v = a.velocity();
152
+ return {
153
+ position: { x: p.x, y: p.y, z: p.z },
154
+ velocity: { x: v.x, y: v.y, z: v.z },
155
+ radius: a.radius,
156
+ height: a.height,
157
+ };
158
+ });
159
+ }
160
+
133
161
  /** Get or create the wireframe debug mesh. Add to scene once, call update() after rebuilding. */
134
162
  getDebugMesh(scene: Scene): NavMeshHelper | null {
135
163
  if (!this.navMesh) return null;
@@ -1,11 +1,5 @@
1
1
  import type * as THREE from 'three';
2
2
 
3
- /** Common aliases used by many free humanoid packs. Scene-authored aliases override these. */
4
- const DEFAULT_CLIP_ALIASES: Record<string, string> = {
5
- walking: 'walk',
6
- running: 'run',
7
- };
8
-
9
3
  /**
10
4
  * Build the clip lookup shared by runtime and editor.
11
5
  *
@@ -29,8 +23,7 @@ export function buildClipMap(
29
23
  if (!clips.has(raw.toLowerCase())) clips.set(raw.toLowerCase(), clip);
30
24
  }
31
25
 
32
- const mergedAliases = { ...DEFAULT_CLIP_ALIASES, ...aliases };
33
- for (const [source, alias] of Object.entries(mergedAliases)) {
26
+ for (const [source, alias] of Object.entries(aliases)) {
34
27
  const clip = canonical.get(source.toLowerCase());
35
28
  if (!clip) continue;
36
29
  if (!clips.has(alias)) clips.set(alias, clip);
@@ -256,6 +256,29 @@ export interface CameraOwnershipChangeEvent {
256
256
  */
257
257
  export type CameraOwnershipHook = (event: CameraOwnershipChangeEvent) => void;
258
258
 
259
+ /**
260
+ * WF — one detected event-marker transition on the Director's `event` extra
261
+ * track (authored by the editor's Sequencer as HOLD string keyframes on the
262
+ * SAME Director object; see the sequencer's `EVENT_MARKER_PROP`).
263
+ */
264
+ export interface EventMarkerEvent {
265
+ /** The marker name now active (the hold keyframe's string value). */
266
+ readonly name: string;
267
+ /** The previously active marker name, or `null` before the first marker. */
268
+ readonly previousName: string | null;
269
+ }
270
+
271
+ /**
272
+ * WF seam — fired once per DETECTED change of the Director's event-marker
273
+ * value, exactly the `activeCamera` detection semantics: markers are
274
+ * *directorial* state, so the hook reflects whatever value the clock's
275
+ * current time holds, however the clock got there (a seek across several
276
+ * markers fires only the one now active; a seek BACK re-fires the older
277
+ * marker as a change). Gameplay consequences that must distinguish seek from
278
+ * advance belong in a D5 `CueRunner` (`cinematic-cues.ts`), not here.
279
+ */
280
+ export type EventMarkerHook = (event: EventMarkerEvent) => void;
281
+
259
282
  export interface TheatreDirectorBindingOptions {
260
283
  /**
261
284
  * Which `AnimationClock` phase to evaluate the Director in. Defaults to
@@ -273,6 +296,20 @@ export interface TheatreDirectorBindingOptions {
273
296
  * non-positive at the moment of a transition reports `mode: 'cut'`.
274
297
  */
275
298
  blendSecondsKey?: string;
299
+ /**
300
+ * WF seam — event-marker hook (see {@link EventMarkerHook}). Fired when
301
+ * the string value of the Director's `eventMarkerKey` extra prop changes
302
+ * between evaluations (markers are authored as HOLD keyframes, so each
303
+ * marker's name holds until the next one). Optional; omit to ignore
304
+ * markers entirely.
305
+ */
306
+ onEventMarker?: EventMarkerHook;
307
+ /**
308
+ * Name of the extra Director prop carrying event-marker names. Defaults to
309
+ * `'event'` — the track the editor's Sequencer writes. An empty string or
310
+ * a non-string value means "no marker active".
311
+ */
312
+ eventMarkerKey?: string;
276
313
  }
277
314
 
278
315
  export interface TheatreDirectorBinding {
@@ -301,13 +338,26 @@ export function bindDirectorToClock<Props extends UnknownShorthandCompoundProps>
301
338
  ): TheatreDirectorBinding {
302
339
  const phase = options.phase ?? 'preRender';
303
340
  const blendSecondsKey = options.blendSecondsKey ?? 'blendSeconds';
341
+ const eventMarkerKey = options.eventMarkerKey ?? 'event';
304
342
 
305
343
  let state: DirectorState = readDirectorState(sheetObject);
306
344
  let previousCameraKey: string | null = null;
345
+ let previousMarker: string | null = null;
307
346
  let hasEvaluatedOnClock = false;
308
347
 
348
+ function evaluateEventMarker(): void {
349
+ if (!options.onEventMarker) return;
350
+ const markerRaw = state.extras[eventMarkerKey];
351
+ const marker = typeof markerRaw === 'string' && markerRaw !== '' ? markerRaw : null;
352
+ if (marker !== null && marker !== previousMarker) {
353
+ options.onEventMarker({ name: marker, previousName: previousMarker });
354
+ }
355
+ previousMarker = marker ?? previousMarker;
356
+ }
357
+
309
358
  function evaluate(): void {
310
359
  state = readDirectorState(sheetObject);
360
+ evaluateEventMarker();
311
361
 
312
362
  const changed = !hasEvaluatedOnClock || state.activeCamera !== previousCameraKey;
313
363
  if (changed) {
@@ -44,6 +44,8 @@ export interface XStateAnimationLayerState {
44
44
  readonly layer: string;
45
45
  readonly stateId: string;
46
46
  readonly clips: readonly string[];
47
+ /** Live native AnimationAction weights, including an in-progress crossfade. */
48
+ readonly clipWeights: readonly { readonly clip: string; readonly weight: number }[];
47
49
  readonly weight: number;
48
50
  readonly blendMode: 'override' | 'additive';
49
51
  readonly boneMask?: AnimationBoneMask;
@@ -408,6 +410,10 @@ export function bindXStateAnimation(
408
410
  layer: active.layer,
409
411
  stateId: active.stateId,
410
412
  clips: active.natural.map((weighted) => weighted.clip),
413
+ clipWeights: active.natural.map((weighted) => ({
414
+ clip: weighted.clip,
415
+ weight: actionCache.get(weighted.actionKey)?.action.getEffectiveWeight() ?? 0,
416
+ })),
411
417
  weight: layerWeightOf(active.meta),
412
418
  blendMode: blendModeOf(active.meta),
413
419
  ...(active.meta.boneMask ? { boneMask: active.meta.boneMask } : {}),