@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
@@ -40,7 +40,7 @@ function isTextEntryFocused(): boolean {
40
40
 
41
41
  /**
42
42
  * Thrown by `InputManager.setVirtualAction`/`tapVirtualAction` (Task 1.4,
43
- * ACCEPTANCE-DRIVER-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2) for an
43
+ * E2E-TESTING-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2) for an
44
44
  * action name that was never `registerAction`/`loadMap`-ed. Carries a
45
45
  * machine-readable `code` and `data.registered` (every declared action name)
46
46
  * rather than requiring a caller to parse the message.
@@ -360,7 +360,7 @@ export class InputManager {
360
360
  private static isStickActive(v: Vector2): boolean {
361
361
  return Math.hypot(v.x, v.y) > 0.3;
362
362
  }
363
- // Task 1.4 (ACCEPTANCE-DRIVER-BUILD-PLAN.md) — action-level virtual input
363
+ // Task 1.4 (E2E-TESTING-BUILD-PLAN.md) — action-level virtual input
364
364
  // for a synthetic player, promoted from hollowstone's `VirtualInput`. Held
365
365
  // digitals OR into isPressed alongside binding contributions (like
366
366
  // touchButtonsDown above); tap queues, then is promoted to "active" for
@@ -468,7 +468,7 @@ export class InputManager {
468
468
  * Window focus deliberately does not participate: a synthetic player
469
469
  * drives the shared editor tab precisely while the human's focus is
470
470
  * elsewhere (their terminal, another window) — the first live in-editor
471
- * probe run froze mid-suite the moment the owner started typing to their
471
+ * E2E run froze mid-suite the moment the owner started typing to their
472
472
  * agent, which is the bug this gate split fixes. Real-device input keeps
473
473
  * the stricter `inputActive` (enabled AND focused) gate above. */
474
474
  private get machineInputActive(): boolean {
@@ -614,13 +614,15 @@ export class InputManager {
614
614
  }
615
615
 
616
616
  constructor() {
617
- window.addEventListener('keydown', this.onKeyDown);
618
- window.addEventListener('keyup', this.onKeyUp);
619
- window.addEventListener('mousedown', this.onMouseDown);
620
- window.addEventListener('mouseup', this.onMouseUp);
621
- window.addEventListener('mousemove', this.onMouseMove);
622
- window.addEventListener('blur', this.onWindowBlur);
623
- window.addEventListener('focus', this.onWindowFocus);
617
+ if (typeof window !== 'undefined') {
618
+ window.addEventListener('keydown', this.onKeyDown);
619
+ window.addEventListener('keyup', this.onKeyUp);
620
+ window.addEventListener('mousedown', this.onMouseDown);
621
+ window.addEventListener('mouseup', this.onMouseUp);
622
+ window.addEventListener('mousemove', this.onMouseMove);
623
+ window.addEventListener('blur', this.onWindowBlur);
624
+ window.addEventListener('focus', this.onWindowFocus);
625
+ }
624
626
  // `document` isn't touched by any pre-F2 code path (the headless vitest
625
627
  // env stubs `window` only) — guard the same way `isTextEntryFocused` does
626
628
  // so tests that don't need pointer-lock coverage need no `document` stub.
@@ -830,7 +832,8 @@ export class InputManager {
830
832
  return;
831
833
  }
832
834
 
833
- this.gamepads = navigator.getGamepads ? [...navigator.getGamepads()] : [];
835
+ this.gamepads =
836
+ typeof navigator !== 'undefined' && navigator.getGamepads ? [...navigator.getGamepads()] : [];
834
837
 
835
838
  // Gamepad button edge detection: diff this frame's pressed set against the
836
839
  // previous frame's so isJustPressed/isJustReleased work for gamepads too.
@@ -2004,7 +2007,7 @@ export class InputManager {
2004
2007
  }
2005
2008
 
2006
2009
  // ==========================================================================
2007
- // Task 1.4 (ACCEPTANCE-DRIVER-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2)
2010
+ // Task 1.4 (E2E-TESTING-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2)
2008
2011
  // — action-level virtual input for a synthetic player. Promoted from
2009
2012
  // hollowstone's proven `VirtualInput` (`bot/virtual-input.ts`): a bot drives
2010
2013
  // NAMED ACTIONS (not raw devices), and its contributions join action
@@ -2274,13 +2277,15 @@ export class InputManager {
2274
2277
  dispose() {
2275
2278
  if (this.disposed) return;
2276
2279
  this.disposed = true;
2277
- window.removeEventListener('keydown', this.onKeyDown);
2278
- window.removeEventListener('keyup', this.onKeyUp);
2279
- window.removeEventListener('mousedown', this.onMouseDown);
2280
- window.removeEventListener('mouseup', this.onMouseUp);
2281
- window.removeEventListener('mousemove', this.onMouseMove);
2282
- window.removeEventListener('blur', this.onWindowBlur);
2283
- window.removeEventListener('focus', this.onWindowFocus);
2280
+ if (typeof window !== 'undefined') {
2281
+ window.removeEventListener('keydown', this.onKeyDown);
2282
+ window.removeEventListener('keyup', this.onKeyUp);
2283
+ window.removeEventListener('mousedown', this.onMouseDown);
2284
+ window.removeEventListener('mouseup', this.onMouseUp);
2285
+ window.removeEventListener('mousemove', this.onMouseMove);
2286
+ window.removeEventListener('blur', this.onWindowBlur);
2287
+ window.removeEventListener('focus', this.onWindowFocus);
2288
+ }
2284
2289
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
2285
2290
  document.removeEventListener('pointerlockchange', this.onPointerLockChange);
2286
2291
  }
@@ -91,7 +91,7 @@ export type ActionValueOf<T extends ActionValueType> = T extends 'digital'
91
91
  */
92
92
  export interface ActionValueSource {
93
93
  /** The binding kind that produced the current value. `'virtual'` (Task 1.4,
94
- * ACCEPTANCE-DRIVER-BUILD-PLAN.md) is not a real `InputBinding` — it marks a
94
+ * E2E-TESTING-BUILD-PLAN.md) is not a real `InputBinding` — it marks a
95
95
  * value written by `InputManager.setVirtualAction` (the synthetic-player
96
96
  * seat), not a device binding. */
97
97
  bindingType: InputBinding['type'] | 'virtual';
package/src/loader.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import * as THREE from 'three';
14
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
14
15
  import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
15
16
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
16
17
 
@@ -51,6 +52,12 @@ dracoLoader.setDecoderPath('/jsm/libs/draco/gltf/');
51
52
  /** Pre-configured GLTFLoader using the shared manager, with DRACO decoding wired in. */
52
53
  export const gltfLoader = new GLTFLoader(loadingManager);
53
54
  gltfLoader.setDRACOLoader(dracoLoader);
55
+ // EXT_meshopt_compression decoding (three's own bundled decoder — inline
56
+ // wasm, no fetch). Without this, a meshopt-compressed GLB — including the
57
+ // output of the editor's Asset Budget "Compress · meshopt" action (W4a M3) —
58
+ // throws at load time in every runtime and the editor viewport. Symmetric
59
+ // with the DRACO wiring above (CB2): the ONE shared loader owns codec setup.
60
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder);
54
61
 
55
62
  /**
56
63
  * Set the URL prefix prepended to relative asset paths.
@@ -119,6 +119,19 @@ export interface ResolvedGameManifest {
119
119
  readonly determinism:
120
120
  | { readonly seededRandom: boolean; readonly defaultSeed?: number | undefined }
121
121
  | undefined;
122
+ /** W3d (F11 perf gates) — read by `vgai perf --assert-budget`
123
+ * (packages/vgai-cli/src/perf.ts, assertBudget): each set metric is
124
+ * compared against the seeded headless perf run's measured value and any
125
+ * exceedance fails the gate with a per-metric table + nonzero exit. */
126
+ readonly budget:
127
+ | {
128
+ readonly frameCpuMsP95?: number | undefined;
129
+ readonly physicsMsP95?: number | undefined;
130
+ readonly maxEntities?: number | undefined;
131
+ readonly maxDrawCalls?: number | undefined;
132
+ readonly maxTriangles?: number | undefined;
133
+ }
134
+ | undefined;
122
135
  }
123
136
 
124
137
  // ---------------------------------------------------------------------------
@@ -427,5 +440,6 @@ export function loadGameManifest(raw: unknown): ResolvedGameManifest {
427
440
  authoring: manifest.authoring,
428
441
  debug: manifest.debug,
429
442
  determinism: manifest.determinism,
443
+ budget: manifest.budget,
430
444
  };
431
445
  }
@@ -475,6 +475,71 @@ export const GameManifestSchema = z
475
475
  'whether ctx.random is boot-seeded from a reproducible seed and whether the ' +
476
476
  'gameplay-rng-ban scan / dev-mode Math.random phase trap are active for this project.',
477
477
  ),
478
+ budget: z
479
+ .object({
480
+ frameCpuMsP95: z
481
+ .number()
482
+ .positive()
483
+ .optional()
484
+ .describe(
485
+ 'Maximum allowed p95 of per-frame CPU time (ms) across the measured frames of a ' +
486
+ '`vgai perf` seeded headless run. CPU-side only: headless runs rasterize under ' +
487
+ 'SwiftShader, so this is main-thread frame cost, NOT real GPU frame time. ' +
488
+ 'Machine-dependent — prefer the baseline-diff tolerance band for regression ' +
489
+ 'gating and use this as a coarse absolute ceiling.',
490
+ ),
491
+ physicsMsP95: z
492
+ .number()
493
+ .positive()
494
+ .optional()
495
+ .describe(
496
+ "Maximum allowed p95 of the profiler's `physics` phase time (ms) per fixed-step " +
497
+ 'frame in a `vgai perf` run. Same CPU-timing caveats as frameCpuMsP95.',
498
+ ),
499
+ maxEntities: z
500
+ .number()
501
+ .int()
502
+ .nonnegative()
503
+ .optional()
504
+ .describe(
505
+ 'Maximum allowed entity-tagged scene-graph nodes (nodes carrying ' +
506
+ "userData.entityId) summed across the game's threejs/pixijs worlds at the end " +
507
+ 'of a `vgai perf` run. Exact and deterministic under a seed — no tolerance band.',
508
+ ),
509
+ maxDrawCalls: z
510
+ .number()
511
+ .int()
512
+ .positive()
513
+ .optional()
514
+ .describe(
515
+ 'Maximum allowed renderer draw calls in any measured frame of a `vgai perf` run ' +
516
+ "(the profiler's per-frame render.drawCalls, from renderer.info). Exact and " +
517
+ 'deterministic under a seed.',
518
+ ),
519
+ maxTriangles: z
520
+ .number()
521
+ .int()
522
+ .positive()
523
+ .optional()
524
+ .describe(
525
+ 'Maximum allowed rendered triangles in any measured frame of a `vgai perf` run ' +
526
+ "(the profiler's per-frame render.triangles, from renderer.info). Exact and " +
527
+ 'deterministic under a seed.',
528
+ ),
529
+ })
530
+ .refine((budget) => Object.values(budget).some((value) => value !== undefined), {
531
+ message:
532
+ 'Game manifest: `budget` must set at least one metric (frameCpuMsP95, physicsMsP95, ' +
533
+ 'maxEntities, maxDrawCalls, maxTriangles) — an empty budget block gates nothing ' +
534
+ '(T4.1 no-dead-fields policy).',
535
+ })
536
+ .optional()
537
+ .describe(
538
+ 'Performance budget (W3d, F11 perf regression gates) asserted by ' +
539
+ '`vgai perf --assert-budget`: a seeded, fixed-step headless run samples the engine ' +
540
+ 'profiler and fails (nonzero exit) when any metric here is exceeded. Only ' +
541
+ 'headlessly-measurable metrics are budgetable; real GPU frame time is not (SwiftShader).',
542
+ ),
478
543
  })
479
544
  .strict()
480
545
  .describe('Game manifest (vgai.game.json) — the complete v2 project configuration');
@@ -223,7 +223,7 @@ export function useDebugProvider(
223
223
  *
224
224
  * Generic over the declared `args` Zod tuple, same as
225
225
  * `DebugCtxSurface.registerCommand` ({@link DebugCommandArgs}, dry-run
226
- * finding — `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md` Wave 6 ledger): declaring
226
+ * finding — `docs/E2E-TESTING-BUILD-PLAN.md` Wave 6 ledger): declaring
227
227
  * `args: z.tuple([z.number(), z.string()])` types `fn`'s parameters as
228
228
  * `(n: number, s: string) => ...` with no `unknown[]` cast; omitting `args`
229
229
  * keeps `fn` typed `(...args: unknown[]) => ...` as before.
@@ -84,6 +84,29 @@ function entityOptOut(obj: THREE.Object3D): boolean {
84
84
  return nearestEntity(obj)?.render?.autoBatch === false;
85
85
  }
86
86
 
87
+ /** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
88
+ function isEffectivelyVisible(obj: THREE.Object3D): boolean {
89
+ let cur: THREE.Object3D | null = obj;
90
+ while (cur) {
91
+ if (!cur.visible) return false;
92
+ cur = cur.parent;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function isBatchCandidate(object: THREE.Object3D, batchRoot: THREE.Group): object is THREE.Mesh {
98
+ const mesh = object as THREE.Mesh;
99
+ if (!mesh.isMesh) return false;
100
+ if ((mesh as THREE.InstancedMesh).isInstancedMesh) return false;
101
+ if ((mesh as THREE.SkinnedMesh).isSkinnedMesh) return false;
102
+ if ((mesh as unknown as THREE.BatchedMesh).isBatchedMesh) return false;
103
+ if (!isEffectivelyVisible(mesh)) return false;
104
+ if (!mesh.geometry || !mesh.material || Array.isArray(mesh.material)) return false;
105
+ if ((mesh.material as THREE.Material).transparent) return false;
106
+ if (batchRoot === mesh.parent) return false;
107
+ return !entityOptOut(mesh);
108
+ }
109
+
87
110
  /**
88
111
  * Static-batching eligibility: only entities that won't move are safe to freeze
89
112
  * and instance. An entity with physics, behavior components, or its own animation
@@ -126,19 +149,10 @@ export class RenderBatchSystem {
126
149
  // collect eligible candidates, grouped by (geometry, material)
127
150
  const buckets = new Map<string, THREE.Mesh[]>();
128
151
  this.scene.traverse((obj) => {
129
- const m = obj as THREE.Mesh;
130
- if (!m.isMesh) return;
131
- if ((m as THREE.InstancedMesh).isInstancedMesh) return;
132
- if ((m as THREE.SkinnedMesh).isSkinnedMesh) return;
133
- if ((m as unknown as THREE.BatchedMesh).isBatchedMesh) return;
134
- if (!m.visible) return;
135
- if (!m.geometry || !m.material || Array.isArray(m.material)) return;
136
- if ((m.material as THREE.Material).transparent) return;
137
- if (this.root === m.parent) return; // our own instances
138
- if (entityOptOut(m)) return;
139
- const key = SIG(m);
152
+ if (!isBatchCandidate(obj, this.root)) return;
153
+ const key = SIG(obj);
140
154
  const arr = buckets.get(key) ?? [];
141
- arr.push(m);
155
+ arr.push(obj);
142
156
  buckets.set(key, arr);
143
157
  });
144
158
 
@@ -0,0 +1,64 @@
1
+ import type { SparkRenderer } from '@sparkjsdev/spark';
2
+ import type * as THREE from 'three';
3
+ import { hasUserData } from '../scene/user-data';
4
+
5
+ const IDLE_POLL_MS = 16;
6
+ const MAX_IDLE_WAIT_MS = 3_000;
7
+ export const SPARK_DISCOVERY_INTERVAL_MS = 500;
8
+
9
+ /** Gate fallback discovery so ordinary non-splat scenes are never traversed every frame. */
10
+ export function shouldDiscoverGaussianSplat(lastCheckAt: number, now: number): boolean {
11
+ return now - lastCheckAt >= SPARK_DISCOVERY_INTERVAL_MS;
12
+ }
13
+
14
+ /** True when a native scene currently contains at least one Gaussian payload. */
15
+ export function sceneHasGaussianSplat(scene: THREE.Object3D): boolean {
16
+ let found = false;
17
+ scene.traverse((object) => {
18
+ if (!found && hasUserData(object, 'gaussianSplat')) found = true;
19
+ });
20
+ return found;
21
+ }
22
+
23
+ /**
24
+ * Stop and dispose Spark after its asynchronous depth-sort worker is idle.
25
+ *
26
+ * SparkRenderer.dispose() rejects any worker calls still in flight. Spark's
27
+ * automatic update path intentionally does not await those calls, so disposing
28
+ * synchronously during an editor stop otherwise produces an unhandled
29
+ * `Worker terminate` rejection. Disable future work immediately, then release
30
+ * the renderer once the current sort has naturally settled.
31
+ */
32
+ export function disposeSparkRendererWhenIdle(renderer: SparkRenderer): void {
33
+ renderer.autoUpdate = false;
34
+ renderer.sortDirty = false;
35
+
36
+ if (renderer.updateTimeoutId !== -1) {
37
+ clearTimeout(renderer.updateTimeoutId);
38
+ renderer.updateTimeoutId = -1;
39
+ }
40
+ if (renderer.sortTimeoutId !== -1) {
41
+ clearTimeout(renderer.sortTimeoutId);
42
+ renderer.sortTimeoutId = -1;
43
+ }
44
+
45
+ let remainingIdleWaitMs = MAX_IDLE_WAIT_MS;
46
+ const disposeWhenIdle = (): void => {
47
+ if (renderer.sorting) {
48
+ if (remainingIdleWaitMs > 0) {
49
+ remainingIdleWaitMs -= IDLE_POLL_MS;
50
+ setTimeout(disposeWhenIdle, IDLE_POLL_MS);
51
+ } else {
52
+ // A stuck upstream worker is safer to abandon than to turn a routine
53
+ // editor stop into an unhandled rejection. Page teardown will reclaim
54
+ // the worker; normal sorts complete in a few milliseconds.
55
+ // biome-ignore lint/suspicious/noConsole: This rare upstream worker leak must degrade loudly.
56
+ console.warn('SparkRenderer did not become idle; skipped worker termination.');
57
+ }
58
+ return;
59
+ }
60
+ renderer.dispose();
61
+ };
62
+
63
+ disposeWhenIdle();
64
+ }
@@ -88,6 +88,12 @@ export function registerThreeWorld(
88
88
  // world directly at attach time, unaffected by this call.
89
89
  if (isFirstPartyMounted(mounted)) {
90
90
  mounted.ctx.components.adoptWorld(world);
91
+ } else if (mounted.components) {
92
+ // Same one-time backfill for a non-first-party mount exposing the
93
+ // optional `MountedWorldBase.components` capability (e.g.
94
+ // `@engine/world3d-react` — its `<Behavior>` attaches happened during
95
+ // `mount()`, before this `WorldInstance` existed).
96
+ mounted.components.adoptWorld(world);
91
97
  }
92
98
  game.registerWorld(world);
93
99
  return world;
@@ -407,7 +413,10 @@ export interface GameSession {
407
413
  pause(): void;
408
414
  resume(): void;
409
415
  step(): void;
410
- resize(width: number, height: number): void;
416
+ /** Resize every world's render buffer. `pixelRatio` (W2c device preview)
417
+ * optionally re-pins the threejs renderers' DPR in the same pass; omitted,
418
+ * the mount-time ratio is left untouched (legacy behavior). */
419
+ resize(width: number, height: number, pixelRatio?: number): void;
411
420
  readonly scene: THREE.Scene;
412
421
  readonly camera: THREE.PerspectiveCamera;
413
422
  /** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
@@ -616,14 +625,15 @@ async function mountOneThreeWorld(
616
625
  * did (never self-healing, by design — see above). */
617
626
  async function mountOnePixiWorld(
618
627
  spec: PixiWorldMountSpec,
619
- ctx: Omit<OneWorldContext, 'headless' | 'loopHandle' | 'assets'>,
628
+ ctx: Omit<OneWorldContext, 'loopHandle' | 'assets'>,
620
629
  ): Promise<OneWorldResult> {
621
- const { game, canvas, w, h, dpr, isBottom } = ctx;
630
+ const { game, canvas, w, h, dpr, headless, isBottom } = ctx;
622
631
  const pixiHost: World2DHost = {
623
632
  canvas,
624
633
  width: w,
625
634
  height: h,
626
635
  game,
636
+ headless,
627
637
  dpr,
628
638
  transparent: !isBottom,
629
639
  preserveDrawingBuffer: true,
@@ -846,6 +856,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
846
856
  stacked.forEach((entry, i) => {
847
857
  const isReact = kindById.get(entry.id) === 'react';
848
858
  const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
859
+ surface.dataset['vgaiWorldSurface'] = 'true';
849
860
  if (!isReact) {
850
861
  const canvas = surface as unknown as HTMLCanvasElement;
851
862
  canvas.width = w;
@@ -938,8 +949,20 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
938
949
  entry.mounted.dispose();
939
950
  entry.renderer?.dispose();
940
951
  entry.renderer?.forceContextLoss();
941
- container.removeChild(entry.element);
952
+ // Remove from the element's CURRENT parent, not the mount-time
953
+ // `container`: the editor's Game panel re-parents the live surfaces
954
+ // when its mount element swaps (fill <-> device preset, W2c), and
955
+ // `container.removeChild` would throw NotFoundError after such a move.
956
+ // `container` remains the fallback for hosts whose element stand-ins
957
+ // never wire `parentNode` (headless unit fixtures); an element already
958
+ // detached by such a host is a no-op via the catch.
959
+ try {
960
+ (entry.element.parentNode ?? container).removeChild(entry.element);
961
+ } catch {
962
+ /* already detached — nothing to remove */
963
+ }
942
964
  }
965
+ game.dispose();
943
966
  retractDevGlobals();
944
967
  }
945
968
 
@@ -960,10 +983,15 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
960
983
  step() {
961
984
  game.play.step();
962
985
  },
963
- resize(rw: number, rh: number) {
986
+ resize(rw: number, rh: number, pixelRatio?: number) {
964
987
  const safeWidth = Math.max(1, rw);
965
988
  const safeHeight = Math.max(1, rh);
966
989
  for (const entry of mountedEntries) {
990
+ // W2c device preview: re-pin DPR before setSize so the drawing
991
+ // buffer (and the first-party composer, whose `mounted.resize` reads
992
+ // `getDrawingBufferSize`) picks it up in this same pass. Pixi/react
993
+ // worlds have no `entry.renderer` and keep their own resolution.
994
+ if (pixelRatio !== undefined) entry.renderer?.setPixelRatio(pixelRatio);
967
995
  entry.renderer?.setSize(safeWidth, safeHeight, false);
968
996
  entry.mounted.resize?.(safeWidth, safeHeight);
969
997
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The standalone in-page debug bridge (`docs/SYNTHETIC-PLAYER-SPEC.md` §3.4,
3
- * D17/D18; Task 2.1 of `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md`). Query-gated
3
+ * D17/D18; Task 2.1 of `docs/E2E-TESTING-BUILD-PLAN.md`). Query-gated
4
4
  * `window.__vgai`, the same production-protection-lives-in-the-installer
5
5
  * pattern `render-control.ts`'s `installRenderControlHarness` established for
6
6
  * `?vgai-render=1` (production protection lives HERE, not just at whatever
@@ -9,7 +9,7 @@
9
9
  * ever gets a handle when the gate below actually passes).
10
10
  *
11
11
  * D17: this is door (a) of the seam's three doors — the SAME `DebugAdapter`
12
- * names/JSON the editor relay (door b) and `@vgai/probe` (door c, which
12
+ * names/JSON the editor relay (door b) and `@vgai/e2e` (door c, which
13
13
  * drives door (a) itself over Playwright) all read. D18: the bridge installs
14
14
  * only when the page opts in (`?vgai-debug=1`) AND is either a dev build
15
15
  * (`import.meta.env.DEV`) or the project's manifest explicitly opts a
@@ -152,7 +152,7 @@ export interface VgaiDebugHandle {
152
152
  * this is the honest human-input-path proof) → `input.clearVirtualActions()`
153
153
  * into ONE async bridge call, as a TOP-LEVEL method (not under `input`)
154
154
  * because it needs the sim clock, not just the input target. Was 15+
155
- * transport round trips over the editor relay (`@vgai/probe`'s old
155
+ * transport round trips over the editor relay (`@vgai/e2e`'s old
156
156
  * `GameInput.hold`: set → a 150ms-interval `waitSimTime` snapshot poll loop
157
157
  * → clear); now one `page.evaluate`/relay call that runs the wait
158
158
  * in-process on the page.
@@ -224,13 +224,13 @@ export interface MaybeInstallDebugBridgeOptions {
224
224
  const PAGE_ERROR_CAP = 100;
225
225
 
226
226
  /** `holdFor`'s in-process poll interval — this loop never leaves the page
227
- * (no transport round trip per poll, unlike `@vgai/probe`'s old
227
+ * (no transport round trip per poll, unlike `@vgai/e2e`'s old
228
228
  * `waitSimTime`), so it can afford to be tighter than that loop's 150ms. */
229
229
  const HOLD_FOR_POLL_MS = 50;
230
230
 
231
231
  /** Consecutive `HOLD_FOR_POLL_MS` polls with the tick unchanged before
232
232
  * `holdFor` gives up waiting and treats the game as stopped — mirrors
233
- * `@vgai/probe`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
233
+ * `@vgai/e2e`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
234
234
  * a genuinely frozen sim clock must never poll forever), scaled to this
235
235
  * faster in-process interval so the wall-clock grace period (~5s) lands in
236
236
  * the same neighborhood. */