@vgai/engine 0.5.13 → 0.5.14

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 (192) hide show
  1. package/dist/adapter/adapter-module.d.ts +447 -0
  2. package/dist/adapter/adapter-module.d.ts.map +1 -0
  3. package/dist/adapter/adapter-module.js +308 -0
  4. package/dist/adapter/authoring.d.ts +357 -10
  5. package/dist/adapter/authoring.d.ts.map +1 -1
  6. package/dist/adapter/authoring.js +87 -1
  7. package/dist/adapter/constraint.d.ts +9 -3
  8. package/dist/adapter/constraint.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +28 -0
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -0
  11. package/dist/adapter/finders/finder-result.js +10 -0
  12. package/dist/adapter/finders/index.d.ts +38 -0
  13. package/dist/adapter/finders/index.d.ts.map +1 -0
  14. package/dist/adapter/finders/index.js +41 -0
  15. package/dist/adapter/finders/prefabs-from-stories.d.ts +64 -0
  16. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -0
  17. package/dist/adapter/finders/prefabs-from-stories.js +92 -0
  18. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +67 -0
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -0
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +226 -0
  21. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  22. package/dist/adapter/first-party-systems.js +29 -0
  23. package/dist/adapter/index.d.ts +7 -3
  24. package/dist/adapter/index.d.ts.map +1 -1
  25. package/dist/adapter/index.js +5 -2
  26. package/dist/adapter/ingest/contract-system-adapters.d.ts +24 -1
  27. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  28. package/dist/adapter/ingest/contract-system-adapters.js +100 -34
  29. package/dist/adapter/ingest/game-contract.d.ts +188 -2
  30. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  31. package/dist/adapter/ingest/game-contract.js +107 -0
  32. package/dist/adapter/ingest/mount-readiness.d.ts +84 -0
  33. package/dist/adapter/ingest/mount-readiness.d.ts.map +1 -0
  34. package/dist/adapter/ingest/mount-readiness.js +81 -0
  35. package/dist/adapter/ingest/scene-capture.d.ts +91 -2
  36. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  37. package/dist/adapter/ingest/scene-capture.js +110 -18
  38. package/dist/adapter/ingest/structural-ids.d.ts +11 -5
  39. package/dist/adapter/ingest/structural-ids.d.ts.map +1 -1
  40. package/dist/adapter/ingest/structural-ids.js +18 -9
  41. package/dist/adapter/ingest/upstream-pin.d.ts +4 -2
  42. package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -1
  43. package/dist/adapter/ingest/upstream-pin.js +4 -2
  44. package/dist/adapter/ingest/visible-capture-window.d.ts +116 -0
  45. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -0
  46. package/dist/adapter/ingest/visible-capture-window.js +141 -0
  47. package/dist/adapter/loop-gate-report.d.ts +3 -4
  48. package/dist/adapter/loop-gate-report.d.ts.map +1 -1
  49. package/dist/adapter/loop-gate-report.js +3 -4
  50. package/dist/adapter/root-adapter.d.ts +22 -2
  51. package/dist/adapter/root-adapter.d.ts.map +1 -1
  52. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  53. package/dist/adapter/setup-three-root-adapter.js +15 -0
  54. package/dist/adapter/system-adapter.d.ts +161 -2
  55. package/dist/adapter/system-adapter.d.ts.map +1 -1
  56. package/dist/adapter/system-adapter.js +18 -1
  57. package/dist/animation/camera-ownership.d.ts +4 -2
  58. package/dist/animation/camera-ownership.d.ts.map +1 -1
  59. package/dist/animation/camera-ownership.js +58 -1
  60. package/dist/animation/runtime-inspection.d.ts +70 -0
  61. package/dist/animation/runtime-inspection.d.ts.map +1 -0
  62. package/dist/animation/runtime-inspection.js +25 -0
  63. package/dist/animation/xstate-animation-binding.d.ts +9 -1
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +48 -1
  66. package/dist/asset-formats/material.d.ts +1 -1
  67. package/dist/asset-formats/mesh.d.ts +1 -1
  68. package/dist/asset-formats/particles.d.ts +13 -0
  69. package/dist/asset-formats/particles.d.ts.map +1 -1
  70. package/dist/asset-formats/particles.js +28 -0
  71. package/dist/behavior/xstate-inspection.d.ts +24 -0
  72. package/dist/behavior/xstate-inspection.d.ts.map +1 -0
  73. package/dist/behavior/xstate-inspection.js +25 -0
  74. package/dist/core/game-loop.d.ts +7 -5
  75. package/dist/core/game-loop.d.ts.map +1 -1
  76. package/dist/core/game-loop.js +39 -18
  77. package/dist/core/types.d.ts +11 -12
  78. package/dist/core/types.d.ts.map +1 -1
  79. package/dist/data/data-asset.js +1 -1
  80. package/dist/dev/instruments.d.ts.map +1 -1
  81. package/dist/dev/instruments.js +5 -1
  82. package/dist/dev/render-debug-adapter.d.ts +19 -2
  83. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  84. package/dist/dev/render-debug-adapter.js +10 -0
  85. package/dist/ecs/user-data.d.ts +10 -2
  86. package/dist/ecs/user-data.d.ts.map +1 -1
  87. package/dist/ecs/user-data.js +6 -2
  88. package/dist/input/binding-identity.d.ts +10 -0
  89. package/dist/input/binding-identity.d.ts.map +1 -0
  90. package/dist/input/binding-identity.js +36 -0
  91. package/dist/input/input-manager.d.ts +18 -185
  92. package/dist/input/input-manager.d.ts.map +1 -1
  93. package/dist/input/input-manager.js +78 -68
  94. package/dist/manifest/load.d.ts +14 -0
  95. package/dist/manifest/load.d.ts.map +1 -1
  96. package/dist/manifest/load.js +6 -1
  97. package/dist/manifest/schema.d.ts +18 -0
  98. package/dist/manifest/schema.d.ts.map +1 -1
  99. package/dist/manifest/schema.js +64 -7
  100. package/dist/pixi/authoring.d.ts +27 -0
  101. package/dist/pixi/authoring.d.ts.map +1 -1
  102. package/dist/pixi/authoring.js +10 -3
  103. package/dist/pixi/index.d.ts +1 -1
  104. package/dist/pixi/index.d.ts.map +1 -1
  105. package/dist/pixi/index.js +1 -1
  106. package/dist/pixi/ingest.d.ts +8 -0
  107. package/dist/pixi/ingest.d.ts.map +1 -1
  108. package/dist/pixi/ingest.js +10 -1
  109. package/dist/pixi/render-pass-bracket.d.ts +56 -0
  110. package/dist/pixi/render-pass-bracket.d.ts.map +1 -0
  111. package/dist/pixi/render-pass-bracket.js +98 -0
  112. package/dist/pixi/scene-capture.d.ts +32 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +21 -9
  115. package/dist/pixi/system-adapters.d.ts +29 -0
  116. package/dist/pixi/system-adapters.d.ts.map +1 -1
  117. package/dist/pixi/system-adapters.js +27 -0
  118. package/dist/render/particles-factory.d.ts +16 -2
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +88 -5
  121. package/dist/render/soft-particle-depth.d.ts +149 -0
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -0
  123. package/dist/render/soft-particle-depth.js +305 -0
  124. package/dist/render/voxel-surface.d.ts +117 -0
  125. package/dist/render/voxel-surface.d.ts.map +1 -0
  126. package/dist/render/voxel-surface.js +263 -0
  127. package/dist/runtime/create-runtime.js +7 -0
  128. package/dist/runtime/debug-bridge.d.ts +15 -15
  129. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  130. package/dist/runtime/debug-bridge.js +27 -26
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +1 -2
  133. package/dist/setup/setup-particles.d.ts.map +1 -1
  134. package/dist/setup/setup-particles.js +9 -0
  135. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  136. package/dist/world3d-react/r3f-adapter.js +42 -3
  137. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  138. package/dist/world3d-react/rapier-physics-bridge.js +201 -11
  139. package/dist-config/config.js.map +1 -1
  140. package/package.json +4 -2
  141. package/schemas/engine-capabilities.json +12 -12
  142. package/schemas/vgai-project.schema.json +41 -2
  143. package/src/adapter/adapter-module.ts +607 -0
  144. package/src/adapter/authoring.ts +440 -10
  145. package/src/adapter/constraint.ts +10 -3
  146. package/src/adapter/finders/finder-result.ts +29 -0
  147. package/src/adapter/finders/index.ts +62 -0
  148. package/src/adapter/finders/prefabs-from-stories.ts +142 -0
  149. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +304 -0
  150. package/src/adapter/first-party-systems.ts +30 -0
  151. package/src/adapter/index.ts +32 -1
  152. package/src/adapter/ingest/contract-system-adapters.ts +116 -32
  153. package/src/adapter/ingest/game-contract.ts +265 -2
  154. package/src/adapter/ingest/mount-readiness.ts +140 -0
  155. package/src/adapter/ingest/scene-capture.ts +199 -22
  156. package/src/adapter/ingest/structural-ids.ts +21 -9
  157. package/src/adapter/ingest/upstream-pin.ts +4 -2
  158. package/src/adapter/ingest/visible-capture-window.ts +213 -0
  159. package/src/adapter/loop-gate-report.ts +3 -4
  160. package/src/adapter/root-adapter.ts +22 -2
  161. package/src/adapter/setup-three-root-adapter.ts +16 -0
  162. package/src/adapter/system-adapter.ts +188 -2
  163. package/src/animation/camera-ownership.ts +68 -3
  164. package/src/animation/runtime-inspection.ts +104 -0
  165. package/src/animation/xstate-animation-binding.ts +53 -1
  166. package/src/asset-formats/particles.ts +31 -0
  167. package/src/behavior/xstate-inspection.ts +51 -0
  168. package/src/core/game-loop.ts +39 -17
  169. package/src/core/types.ts +11 -12
  170. package/src/data/data-asset.ts +1 -1
  171. package/src/dev/instruments.ts +5 -1
  172. package/src/dev/render-debug-adapter.ts +22 -2
  173. package/src/ecs/user-data.ts +10 -2
  174. package/src/input/binding-identity.ts +37 -0
  175. package/src/input/input-manager.ts +93 -68
  176. package/src/manifest/load.ts +23 -2
  177. package/src/manifest/schema.ts +76 -7
  178. package/src/pixi/authoring.ts +37 -5
  179. package/src/pixi/index.ts +2 -0
  180. package/src/pixi/ingest.ts +15 -2
  181. package/src/pixi/render-pass-bracket.ts +117 -0
  182. package/src/pixi/scene-capture.ts +63 -13
  183. package/src/pixi/system-adapters.ts +41 -0
  184. package/src/render/particles-factory.ts +89 -6
  185. package/src/render/soft-particle-depth.ts +336 -0
  186. package/src/render/voxel-surface.ts +358 -0
  187. package/src/runtime/create-runtime.ts +7 -0
  188. package/src/runtime/debug-bridge.ts +32 -31
  189. package/src/runtime/game.ts +1 -2
  190. package/src/setup/setup-particles.ts +9 -0
  191. package/src/world3d-react/r3f-adapter.tsx +48 -2
  192. package/src/world3d-react/rapier-physics-bridge.tsx +249 -11
@@ -50,6 +50,11 @@
50
50
  */
51
51
 
52
52
  import type * as THREE from 'three';
53
+ import {
54
+ startVisibleCaptureWindow,
55
+ type VisibilityClock,
56
+ type VisibleCaptureWindow,
57
+ } from './visible-capture-window';
53
58
 
54
59
  /** A live runtime captured from an external game on its first rendered frame. */
55
60
  export interface CapturedRuntime {
@@ -82,6 +87,78 @@ export interface SceneCaptureOptions {
82
87
  * so (`packages/editor/src/host-renderers.ts`).
83
88
  */
84
89
  isHostRenderer?: (renderer: unknown) => boolean;
90
+ /**
91
+ * The game's OWN declared world, read from its contract
92
+ * (`window.vgaiGame` — the host passes a reader, never a cached value, because
93
+ * the contract is declared by the game's modules and may not exist yet when
94
+ * the trap installs).
95
+ *
96
+ * When it answers non-null, the DECLARATION decides: only a render of that
97
+ * scene is adopted, and first-render-wins never runs. When it answers null —
98
+ * the case for every game that declares nothing — behaviour is unchanged and
99
+ * the adoption is reported as `measured`.
100
+ */
101
+ declaredScene?: () => unknown;
102
+ /**
103
+ * How the world was adopted, and every DISTINCT world seen afterwards.
104
+ *
105
+ * First-non-host-render-wins is a good measured default and a permanent,
106
+ * SILENT commitment: a splash scene, a shadow pre-pass, or a
107
+ * render-to-texture warm-up that happens to draw first is adopted as the game
108
+ * forever, and the real world that renders one frame later reaches no reader
109
+ * at all. This is that reader. It never changes which world is adopted — it
110
+ * makes the ambiguity a recorded fact (`packages/editor/src/world-adoption.ts`
111
+ * publishes it to `vgai status`).
112
+ *
113
+ * Post-processing games legitimately render several (scene, camera) pairs per
114
+ * frame, so alternates are INFORMATION, never an error. Host renders are
115
+ * excluded by the same `isHostRenderer` declaration the adoption itself uses.
116
+ */
117
+ onWorldAdoption?: (event: WorldAdoptionEvent) => void;
118
+ }
119
+
120
+ /**
121
+ * One world-adoption fact. `adopted` fires exactly once, when the trap commits
122
+ * to a (scene, camera, renderer) triple; `alternate` fires for each DISTINCT
123
+ * triple seen afterwards, up to {@link MAX_RECORDED_ALTERNATES}.
124
+ */
125
+ export type WorldAdoptionEvent =
126
+ | {
127
+ readonly phase: 'adopted';
128
+ /** `declared` = the contract named this scene; `measured` = first render won. */
129
+ readonly source: 'declared' | 'measured';
130
+ readonly sceneId: string;
131
+ readonly cameraId: string;
132
+ }
133
+ | {
134
+ readonly phase: 'alternate';
135
+ readonly sceneId: string;
136
+ readonly cameraId: string;
137
+ /** `false` ⇒ a SECOND renderer is drawing, which is the stronger signal. */
138
+ readonly sameRenderer: boolean;
139
+ /** Draws observed when this alternate first appeared — how far past the
140
+ * adoption it is, without a wall clock. */
141
+ readonly drawCount: number;
142
+ };
143
+
144
+ /**
145
+ * How many distinct alternates are recorded before the trap stops looking.
146
+ *
147
+ * The bound is the point: this runs inside the game's own render call, and a
148
+ * post-processing chain can present a new (scene, camera) pair every frame. A
149
+ * handful names the ambiguity; an unbounded set would turn a diagnostic into a
150
+ * leak on the hottest path in the process.
151
+ */
152
+ export const MAX_RECORDED_ALTERNATES = 8;
153
+
154
+ /** A three object's identity, as a string a status facet can carry. `type` is
155
+ * what a reader recognizes ("Scene", "PerspectiveCamera"); `uuid` is what
156
+ * makes two of the same type tellable apart. */
157
+ function objectId(value: unknown): string {
158
+ const obj = value as { type?: unknown; uuid?: unknown } | null | undefined;
159
+ const type = typeof obj?.type === 'string' ? obj.type : 'unknown';
160
+ const uuid = typeof obj?.uuid === 'string' ? obj.uuid : '(no uuid)';
161
+ return `${type}:${uuid}`;
85
162
  }
86
163
 
87
164
  /**
@@ -104,6 +181,28 @@ export interface RenderPassHooks {
104
181
  after(): void;
105
182
  }
106
183
 
184
+ /**
185
+ * Options for {@link SceneCaptureHandle.waitForCapture}. Passing a bare number
186
+ * is the same as `{ timeoutMs }` — the shape every existing caller uses.
187
+ */
188
+ export interface CaptureWaitOptions {
189
+ /** The capture window, in VISIBLE milliseconds (default 10s). */
190
+ timeoutMs?: number | undefined;
191
+ /** Injected in tests; defaults to the document's own visibility. */
192
+ visibility?: VisibilityClock | undefined;
193
+ /**
194
+ * Called with a LIVE view of the wait when it begins, and with `null` the
195
+ * moment it ends (captured, expired, or the window was cancelled).
196
+ *
197
+ * A wait parked on a hidden tab is otherwise indistinguishable from a hung
198
+ * mount: nothing renders, nothing fails, and every door reports silence.
199
+ * This is the seam the editor publishes to `vgai status` so the answer is
200
+ * "waiting for the first visible frame — the tab is hidden", not a
201
+ * countdown that is not running.
202
+ */
203
+ onWait?: ((wait: VisibleCaptureWindow | null) => void) | undefined;
204
+ }
205
+
107
206
  /** Handle returned by {@link installSceneCapture}. */
108
207
  export interface SceneCaptureHandle {
109
208
  /** The captured runtime, or null until the game renders its first frame. */
@@ -116,8 +215,17 @@ export interface SceneCaptureHandle {
116
215
  * renders come through the same trap and are not the game's frame.
117
216
  */
118
217
  setRenderPassHooks(hooks: RenderPassHooks | null): void;
119
- /** Resolve once a scene+camera is captured (rejects on timeout). */
120
- waitForCapture(timeoutMs?: number): Promise<CapturedRuntime>;
218
+ /**
219
+ * Resolve once a scene+camera is captured.
220
+ *
221
+ * The timeout is a budget of **visible** time, not wall-clock time: a hidden
222
+ * document cannot render (the browser parks rAF), so counting hidden time
223
+ * against the game is counting time it was not allowed to use. The wait
224
+ * therefore PARKS while `document.hidden` and resumes on `visibilitychange`
225
+ * — see `visible-capture-window.ts` for the whole argument. Rejects only
226
+ * when the window is spent with the document VISIBLE.
227
+ */
228
+ waitForCapture(options?: number | CaptureWaitOptions): Promise<CapturedRuntime>;
121
229
  /** Total `render()` calls observed through the trap (a liveness signal). */
122
230
  getDrawCount(): number;
123
231
  /** The game's last (non-null) `setAnimationLoop` callback for a renderer, so the
@@ -184,6 +292,14 @@ export function installSceneCapture(
184
292
 
185
293
  let captured: CapturedRuntime | null = null;
186
294
  let drawCount = 0;
295
+ /** Memoized answer of `opts.declaredScene` — asked each render until it
296
+ * answers, because a game declares its contract from its own modules and may
297
+ * not have run yet when the trap installs. Once it answers, it is fixed:
298
+ * a declaration that changes mid-boot is not a thing the host chases. */
299
+ let declaredScene: unknown = null;
300
+ /** Distinct alternates already reported, keyed by the same triple identity the
301
+ * event carries — see {@link MAX_RECORDED_ALTERNATES} for why it is bounded. */
302
+ const seenAlternates = new Set<string>();
187
303
  /** Set post-capture by the host; see {@link SceneCaptureHandle.setRenderPassHooks}. */
188
304
  let renderPassHooks: RenderPassHooks | null = null;
189
305
  const waiters: Array<(rt: CapturedRuntime) => void> = [];
@@ -240,6 +356,55 @@ export function installSceneCapture(
240
356
  }
241
357
  }
242
358
 
359
+ /**
360
+ * Commit to a world, DECLARATION FIRST.
361
+ *
362
+ * First-non-host-render-wins is the measured default and a permanent one, so
363
+ * a game that states which scene is its world is not made to race its own
364
+ * splash screen. `declaredScene` is asked until it answers (the game's own
365
+ * modules declare the contract, and may not have run when the trap installed);
366
+ * once it does, only that scene is adopted.
367
+ */
368
+ function adoptWorld(self: unknown, scene: unknown, camera: unknown): void {
369
+ if (declaredScene === null) declaredScene = opts?.declaredScene?.() ?? null;
370
+ if (declaredScene !== null && declaredScene !== scene) return;
371
+ captured = {
372
+ scene: scene as THREE.Scene,
373
+ camera: camera as THREE.Camera,
374
+ renderer: self as THREE.WebGLRenderer,
375
+ };
376
+ opts?.onWorldAdoption?.({
377
+ phase: 'adopted',
378
+ source: declaredScene === null ? 'measured' : 'declared',
379
+ sceneId: objectId(scene),
380
+ cameraId: objectId(camera),
381
+ });
382
+ for (const resolve of waiters.splice(0)) resolve(captured);
383
+ }
384
+
385
+ /**
386
+ * A DISTINCT world drew after the adopted one. Recorded, never acted on:
387
+ * which world is adopted does not change (that would break every handle the
388
+ * host already built from it) — but the reader stops being the only party who
389
+ * could have noticed. Deduped and bounded because this is the game's own
390
+ * render call.
391
+ */
392
+ function recordAlternateWorld(self: unknown, scene: unknown, camera: unknown): void {
393
+ if (!captured || seenAlternates.size >= MAX_RECORDED_ALTERNATES) return;
394
+ if (scene === captured.scene && camera === captured.camera) return;
395
+ const sameRenderer = self === captured.renderer;
396
+ const key = `${objectId(scene)}|${objectId(camera)}|${sameRenderer}`;
397
+ if (seenAlternates.has(key)) return;
398
+ seenAlternates.add(key);
399
+ opts?.onWorldAdoption?.({
400
+ phase: 'alternate',
401
+ sceneId: objectId(scene),
402
+ cameraId: objectId(camera),
403
+ sameRenderer,
404
+ drawCount,
405
+ });
406
+ }
407
+
243
408
  Object.defineProperty(proto, 'render', {
244
409
  configurable: true,
245
410
  set(this: Record<symbol, unknown>, fn: unknown) {
@@ -269,14 +434,9 @@ export function installSceneCapture(
269
434
  // Whether that happened at all came down to whether the editor
270
435
  // rendered a frame inside the game's capture window, so the same
271
436
  // session could pass or fail on timing alone.
272
- const isHostRender = opts?.isHostRenderer?.(self) === true;
273
- if (!captured && !isHostRender && scene && (scene as { isScene?: boolean }).isScene) {
274
- captured = {
275
- scene: scene as THREE.Scene,
276
- camera: camera as THREE.Camera,
277
- renderer: self as unknown as THREE.WebGLRenderer,
278
- };
279
- for (const resolve of waiters.splice(0)) resolve(captured);
437
+ if (opts?.isHostRenderer?.(self) !== true && (scene as { isScene?: boolean })?.isScene) {
438
+ if (captured) recordAlternateWorld(self, scene, camera);
439
+ else adoptWorld(self, scene, camera);
280
440
  }
281
441
  return forwardRender(self, args);
282
442
  };
@@ -340,24 +500,41 @@ export function installSceneCapture(
340
500
  composer.setPixelRatio(pixelRatio);
341
501
  }
342
502
  },
343
- waitForCapture(timeoutMs = 10_000) {
503
+ waitForCapture(options) {
504
+ const opts: CaptureWaitOptions =
505
+ typeof options === 'number' ? { timeoutMs: options } : (options ?? {});
506
+ const timeoutMs = opts.timeoutMs ?? 10_000;
344
507
  if (captured) return Promise.resolve(captured);
345
508
  return new Promise<CapturedRuntime>((resolve, reject) => {
346
- const timer = setTimeout(() => {
347
- const i = waiters.indexOf(wrapped);
348
- if (i >= 0) waiters.splice(i, 1);
349
- reject(
350
- new Error(
351
- `Scene capture timed out after ${timeoutMs}ms — the game never rendered, ` +
352
- `or it bundles its own (un-shared) copy of three.`,
353
- ),
354
- );
355
- }, timeoutMs);
509
+ // A budget of VISIBLE time. The window disarms itself while the
510
+ // document is hidden and resumes when it comes back, so a tab that
511
+ // boots in the background waits instead of dying — and the trap it is
512
+ // waiting on stays installed the whole time, which is what makes the
513
+ // first frame after foregrounding a capture rather than a retry.
514
+ const captureWindow = startVisibleCaptureWindow({
515
+ budgetMs: timeoutMs,
516
+ ...(opts.visibility !== undefined ? { clock: opts.visibility } : {}),
517
+ onExpire: () => {
518
+ const i = waiters.indexOf(wrapped);
519
+ if (i >= 0) waiters.splice(i, 1);
520
+ opts.onWait?.(null);
521
+ reject(
522
+ new Error(
523
+ `Scene capture timed out after ${timeoutMs}ms of VISIBLE time ` +
524
+ `(${Math.round(captureWindow.elapsedHiddenMs())}ms hidden, which is not counted — ` +
525
+ 'a hidden tab cannot render) — the game never rendered, ' +
526
+ 'or it bundles its own (un-shared) copy of three.',
527
+ ),
528
+ );
529
+ },
530
+ });
356
531
  const wrapped = (rt: CapturedRuntime) => {
357
- clearTimeout(timer);
532
+ captureWindow.cancel();
533
+ opts.onWait?.(null);
358
534
  resolve(rt);
359
535
  };
360
536
  waiters.push(wrapped);
537
+ opts.onWait?.(captureWindow);
361
538
  });
362
539
  },
363
540
  uninstall() {
@@ -3,14 +3,14 @@
3
3
  * reflection helper that rides along with it) the editor's live-three authoring
4
4
  * adapter uses to address the objects of a world whose source carries no
5
5
  * serve-time identity stamps
6
- * (`packages/editor/src/authoring/live-three-identity.ts`, `structuralIdentity`).
6
+ * (`packages/editor/src/projection/three.ts`, `structuralIdentity`).
7
7
  *
8
8
  * Identity: each object gets a **structural-path id** — deterministic from the
9
9
  * scene's shape (position in the tree + three.js type + name), so the SAME id
10
10
  * re-binds to the SAME object after the game rebuilds its scene within a
11
- * session. It is also written to `userData.entityId` so the editor's object map
12
- * / gizmo / selection (which key on `entityId`) bind to foreign objects with no
13
- * further shim.
11
+ * session. The pure walk returns that identity beside the native objects; the
12
+ * editor keeps the reverse lookup in its authoring adapter rather than writing
13
+ * editor currency into a foreign graph.
14
14
  *
15
15
  * This module used to be the shared core of a per-game JSON sidecar
16
16
  * persistence system, which was deleted outright (2026-08-02) — ingest edits
@@ -36,7 +36,7 @@ const EDITOR_ONLY_LAYER_MASK = 1 << 31;
36
36
 
37
37
  /**
38
38
  * Editor furniture, not game content. Excluded from the walk entirely — see
39
- * {@link assignStructuralIds} for why that exclusion is what makes the id
39
+ * {@link collectStructuralIds} for why that exclusion is what makes the id
40
40
  * scheme's central promise true.
41
41
  */
42
42
  function isEditorOnly(o: THREE.Object3D): boolean {
@@ -67,7 +67,7 @@ export interface StructuralIdWalk {
67
67
  }
68
68
 
69
69
  /**
70
- * (Re)assign structural-path ids to every object under `scene` (and, if
70
+ * (Re)collect structural-path ids for every object under `scene` (and, if
71
71
  * given, the separately-captured render `camera`, under the fixed
72
72
  * {@link CAMERA_ID}). Idempotent and deterministic from scene structure
73
73
  * (position + type + name) — so a re-walk after the game rebuilds part of its
@@ -89,7 +89,7 @@ export interface StructuralIdWalk {
89
89
  * hierarchy/inspector projections built on this walk, where they were
90
90
  * selectable and colorable as if they were game content.
91
91
  */
92
- export function assignStructuralIds(
92
+ export function collectStructuralIds(
93
93
  scene: THREE.Object3D,
94
94
  camera?: THREE.Object3D | undefined,
95
95
  ): StructuralIdWalk {
@@ -102,7 +102,6 @@ export function assignStructuralIds(
102
102
  path: string,
103
103
  ): void => {
104
104
  const id = `ingest:${path}:${o.type}:${o.name || ''}`;
105
- setUserData(o, 'entityId', id);
106
105
  byId.set(id, o);
107
106
  count++;
108
107
  if (o.isLight) lights++;
@@ -119,9 +118,22 @@ export function assignStructuralIds(
119
118
  };
120
119
  visitChildren(scene, '');
121
120
  if (camera) {
122
- setUserData(camera, 'entityId', CAMERA_ID);
123
121
  byId.set(CAMERA_ID, camera);
124
122
  count++;
125
123
  }
126
124
  return { byId, count, meshes, lights };
127
125
  }
126
+
127
+ /**
128
+ * Compatibility entry point for first-party callers that explicitly want
129
+ * structural ids stamped into their own graph. Foreign ingest authoring uses
130
+ * {@link collectStructuralIds} and never calls this mutating form.
131
+ */
132
+ export function assignStructuralIds(
133
+ scene: THREE.Object3D,
134
+ camera?: THREE.Object3D | undefined,
135
+ ): StructuralIdWalk {
136
+ const walk = collectStructuralIds(scene, camera);
137
+ for (const [id, object] of walk.byId) setUserData(object, 'entityId', id);
138
+ return walk;
139
+ }
@@ -27,8 +27,10 @@
27
27
  * - Commit: **`6339493130b4b1d4d28f0f52d17b2fba738c7d47`**
28
28
  *
29
29
  * i.e. a Markdown bullet whose label is `Commit:` and whose value is a bold,
30
- * backtick-code full (or abbreviated) git SHA. Every vendored game in this
31
- * repo (`tanks`, `racing-game`, `simcity`) follows this exact shape.
30
+ * backtick-code full (or abbreviated) git SHA. Every BUNDLE-vendored game in
31
+ * this repo (`tanks`, `simcity`) follows this exact shape. Source-tree
32
+ * vendored games carry their pin in `vendor/games/<id>.UPSTREAM.lock`'s
33
+ * machine-readable `commit` instead and never reach this parser.
32
34
  */
33
35
 
34
36
  /** Matches `- Commit: **\`<sha>\`**` (any amount of internal whitespace, case-insensitive hex). */
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The capture window is a budget of VISIBLE time, not of wall-clock time.
3
+ *
4
+ * WHY THIS MODULE EXISTS. An ingest mount waits for the game to render its
5
+ * first frame and fails by name when that never happens
6
+ * (`scene-capture.ts`'s `waitForCapture`). That wait used to be a plain
7
+ * `setTimeout`, i.e. a wall-clock deadline started AT BOOT — and a game cannot
8
+ * render while its tab is hidden, because the browser parks `requestAnimation
9
+ * Frame` for a backgrounded document. The two facts together make one
10
+ * deterministic failure: a tab that opens in the BACKGROUND (the normal human
11
+ * path — `vgai edit` auto-opens a tab that routinely lands behind the current
12
+ * window) burns its whole capture window unable to draw, the deadline fires,
13
+ * the mount dies terminally, and foregrounding the tab later changes nothing.
14
+ * Agents, whose tabs happen to be visible, never saw it.
15
+ *
16
+ * So the clock only runs while the document is VISIBLE. Hidden time is not
17
+ * spent, and while a tab is hidden the wait PARKS rather than expiring: the
18
+ * capture trap stays installed, the game's modules stay live, and the first
19
+ * frame the tab draws after the human brings it forward is trapped exactly as
20
+ * it would have been at boot. That is the retry — no second mount, no
21
+ * re-running `load()` (which would re-construct module-level state: the
22
+ * ARCHITECTURE-CORE "LOADING CONSTRUCTS, once" contract), and no forcing of a
23
+ * frame: the game's own loop resumes on its own when the browser un-parks it.
24
+ *
25
+ * There is deliberately no wall-clock cap on the parked state. An unspent
26
+ * budget is not a failure — a human returning to the tab is what spends it —
27
+ * and a cap would be exactly the boot-time clock this module exists to
28
+ * remove. The parked wait is reported instead of being silent: see
29
+ * `CaptureWaitObserver` in `scene-capture.ts` and `vgai status`'s
30
+ * `ingestCaptureWait`.
31
+ *
32
+ * The state machine is a pure function of (banked segments, now, hidden) so
33
+ * the whole accrual rule is testable with no browser and no timers
34
+ * (`packages/engine/test/visible-capture-window.test.ts`); the runtime half
35
+ * below is only the timer arm/disarm around it.
36
+ */
37
+
38
+ /**
39
+ * Everything the budget reads about the outside world. Injected so the pure
40
+ * core stays pure and the runtime half is drivable by a fake in tests — and,
41
+ * as a side effect, so this module never hard-depends on `document` existing
42
+ * (unit runners, SSR).
43
+ */
44
+ export interface VisibilityClock {
45
+ /** Milliseconds, monotonic-ish; `performance.now()`/`Date.now()` both fit. */
46
+ now(): number;
47
+ /** True while the document is hidden (no rAF, so no frame can be captured). */
48
+ hidden(): boolean;
49
+ /** Subscribe to visibility transitions; returns the unsubscribe. */
50
+ subscribe(onChange: () => void): () => void;
51
+ }
52
+
53
+ /** The real one: `document.visibilityState` + `visibilitychange`. Falls back
54
+ * to permanently-visible where there is no `document` at all, which is the
55
+ * honest answer for a headless caller — it has no tab to background. */
56
+ export function documentVisibilityClock(): VisibilityClock {
57
+ const doc = typeof document === 'undefined' ? null : document;
58
+ return {
59
+ now: () => (typeof performance === 'undefined' ? Date.now() : performance.now()),
60
+ hidden: () => doc?.hidden === true,
61
+ subscribe(onChange) {
62
+ if (!doc) return () => {};
63
+ doc.addEventListener('visibilitychange', onChange);
64
+ return () => doc.removeEventListener('visibilitychange', onChange);
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * The banked halves of the wait plus the segment currently running. Immutable:
71
+ * every transition returns a new value, so a caller can hold one and compare.
72
+ */
73
+ export interface VisibleBudgetState {
74
+ /** The window, in VISIBLE milliseconds. */
75
+ readonly budgetMs: number;
76
+ /** Visible time banked from completed segments. */
77
+ readonly visibleMs: number;
78
+ /** Hidden time banked from completed segments (reported, never spent). */
79
+ readonly hiddenMs: number;
80
+ /** When the current segment started. */
81
+ readonly since: number;
82
+ /** Whether the current segment is a hidden one. */
83
+ readonly hidden: boolean;
84
+ }
85
+
86
+ /** Open the window at `now`, in whichever visibility the document is in. */
87
+ export function beginVisibleBudget(
88
+ budgetMs: number,
89
+ now: number,
90
+ hidden: boolean,
91
+ ): VisibleBudgetState {
92
+ return { budgetMs, visibleMs: 0, hiddenMs: 0, since: now, hidden };
93
+ }
94
+
95
+ /**
96
+ * Apply the document's current visibility at `now`: bank the segment that just
97
+ * ended into its own bucket and start the next one. A call that does not change
98
+ * visibility is a no-op *by value* (same accrual, same segment start), so a
99
+ * duplicate `visibilitychange` can never bank a zero-length segment twice or
100
+ * restart the clock.
101
+ */
102
+ export function applyVisibility(
103
+ state: VisibleBudgetState,
104
+ now: number,
105
+ hidden: boolean,
106
+ ): VisibleBudgetState {
107
+ if (hidden === state.hidden) return state;
108
+ const elapsed = Math.max(0, now - state.since);
109
+ return {
110
+ budgetMs: state.budgetMs,
111
+ visibleMs: state.hidden ? state.visibleMs : state.visibleMs + elapsed,
112
+ hiddenMs: state.hidden ? state.hiddenMs + elapsed : state.hiddenMs,
113
+ since: now,
114
+ hidden,
115
+ };
116
+ }
117
+
118
+ /** Visible time spent so far, including the segment in flight. */
119
+ export function visibleElapsedMs(state: VisibleBudgetState, now: number): number {
120
+ return state.visibleMs + (state.hidden ? 0 : Math.max(0, now - state.since));
121
+ }
122
+
123
+ /** Hidden time so far, including the segment in flight. Never spent — it is
124
+ * reported so a failure message can say what the window did NOT count. */
125
+ export function hiddenElapsedMs(state: VisibleBudgetState, now: number): number {
126
+ return state.hiddenMs + (state.hidden ? Math.max(0, now - state.since) : 0);
127
+ }
128
+
129
+ /** Visible time left in the window; `0` once it is spent. */
130
+ export function visibleRemainingMs(state: VisibleBudgetState, now: number): number {
131
+ return Math.max(0, state.budgetMs - visibleElapsedMs(state, now));
132
+ }
133
+
134
+ /** A live view of one running window, for the status wire and the failure message. */
135
+ export interface VisibleCaptureWindow {
136
+ /** The window's size, in visible milliseconds. */
137
+ readonly budgetMs: number;
138
+ /** Visible milliseconds spent so far. */
139
+ elapsedVisibleMs(): number;
140
+ /** Milliseconds this window has spent parked while hidden (not counted). */
141
+ elapsedHiddenMs(): number;
142
+ /** Whether the wait is parked right now because the document is hidden. */
143
+ isHidden(): boolean;
144
+ /** Stop the timer and drop the visibility listener. Idempotent. */
145
+ cancel(): void;
146
+ }
147
+
148
+ /**
149
+ * Start a window that calls `onExpire` after `budgetMs` of VISIBLE time.
150
+ * While the document is hidden the timer is disarmed entirely (so a throttled
151
+ * background timer cannot fire it late either) and re-armed with the remaining
152
+ * budget when the document comes back.
153
+ */
154
+ export function startVisibleCaptureWindow(opts: {
155
+ budgetMs: number;
156
+ onExpire: () => void;
157
+ /** Defaults to the document's own visibility. */
158
+ clock?: VisibilityClock;
159
+ /** Defaults to `setTimeout`/`clearTimeout`. */
160
+ setTimer?: (fn: () => void, ms: number) => unknown;
161
+ clearTimer?: (handle: unknown) => void;
162
+ }): VisibleCaptureWindow {
163
+ const clock = opts.clock ?? documentVisibilityClock();
164
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
165
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
166
+
167
+ let state = beginVisibleBudget(opts.budgetMs, clock.now(), clock.hidden());
168
+ let timer: unknown = null;
169
+ let done = false;
170
+
171
+ function disarm(): void {
172
+ if (timer !== null) {
173
+ clearTimer(timer);
174
+ timer = null;
175
+ }
176
+ }
177
+
178
+ function arm(): void {
179
+ disarm();
180
+ if (done || state.hidden) return;
181
+ timer = setTimer(
182
+ () => {
183
+ timer = null;
184
+ if (done) return;
185
+ done = true;
186
+ unsubscribe();
187
+ opts.onExpire();
188
+ },
189
+ visibleRemainingMs(state, clock.now()),
190
+ );
191
+ }
192
+
193
+ const unsubscribe = clock.subscribe(() => {
194
+ if (done) return;
195
+ state = applyVisibility(state, clock.now(), clock.hidden());
196
+ arm();
197
+ });
198
+
199
+ arm();
200
+
201
+ return {
202
+ budgetMs: opts.budgetMs,
203
+ elapsedVisibleMs: () => visibleElapsedMs(state, clock.now()),
204
+ elapsedHiddenMs: () => hiddenElapsedMs(state, clock.now()),
205
+ isHidden: () => state.hidden,
206
+ cancel() {
207
+ if (done) return;
208
+ done = true;
209
+ disarm();
210
+ unsubscribe();
211
+ },
212
+ };
213
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Capability-gate shortfall reporting (D10, T7.6) — the honesty half of the
3
- * play-control contract. §(d) draws the `loop` axis (`gated | self-driven`) as
4
- * independent of capability tier: a `self-driven` world (an ingested game
3
+ * play-control contract. §(d) draws the `loop` axis (`gated | self-driven`) on
4
+ * its own: a `self-driven` world (an ingested game
5
5
  * driving its own rAF/ticker) pauses for real ONLY if its adapter implements an
6
6
  * explicit loop-gate capability (`MountedRootBase.setPaused`/`step`) —
7
7
  * otherwise it "honestly reports so" rather than a silent no-op (the
@@ -10,8 +10,7 @@
10
10
  * has the identical shape: absent or unable to silence a world's audio on pause
11
11
  * must report, not pretend.
12
12
  *
13
- * Pure compute + message-formatting, same split as `achieved-tier.ts`
14
- * (`packages/editor/src/achieved-tier.ts`) — callers own the actual
13
+ * Pure compute + message-formatting callers own the actual
15
14
  * `console.warn`/`editorConsole.warn` call; this module never logs.
16
15
  */
17
16
 
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type { Container } from 'pixi.js';
27
27
  import type * as THREE from 'three';
28
+ import type { WorldRendererConfig } from '../world3d-react/renderer-config';
28
29
  import type { AdapterSurface } from './adapter-surface';
29
30
  import type { AuthoringAdapter } from './authoring';
30
31
  import type { HostContextFor } from './host-context';
@@ -78,8 +79,8 @@ export interface MountedRootBase {
78
79
  * not a promise: implement `setPaused`/`step` only where the game exposes
79
80
  * a sanctioned pause/step mechanism. Gating a raw-rAF loop from outside
80
81
  * was demonstrated and REJECTED (D5 — it halts the loop rather than
81
- * pausing it); such games are the "composited, unsynchronized" tier:
82
- * `setPaused` absent, host degrades loudly (T7.6 owns the tier surface).
82
+ * pausing it). Where `setPaused` is absent the host reports the
83
+ * shortfall loudly, naming the mechanism that would close it (T7.6).
83
84
  */
84
85
  readonly drivesOwnLoop: boolean;
85
86
 
@@ -112,6 +113,25 @@ export interface MountedThreeRoot extends MountedRootBase {
112
113
  /** The live scene + camera the editor inspects/renders for authoring. */
113
114
  readonly scene: THREE.Scene;
114
115
  readonly camera: THREE.Camera;
116
+ /**
117
+ * The colour pipeline this world was authored for, REPORTED rather than
118
+ * applied — the adapter has already applied it to the renderer its own host
119
+ * handed it (`world3d-react/renderer-config.ts`).
120
+ *
121
+ * It is here because the host that MOUNTS a world is not always the host
122
+ * that DRAWS it. The editor's design session mounts against a
123
+ * non-rasterizing renderer on purpose and then draws the mounted scene with
124
+ * the editor viewport's own `WebGLRenderer`, so the world's declaration
125
+ * lands on a surface with no pixels while the surface with pixels never
126
+ * hears it. Reporting the declaration is what lets that host apply the same
127
+ * config, through the same `applyWorldRendererConfig`, to the renderer that
128
+ * actually produces the frame.
129
+ *
130
+ * Absent means the world declared nothing and the drawing host should leave
131
+ * its own configuration alone — the same "absent = don't touch" rule the
132
+ * config's own fields follow.
133
+ */
134
+ readonly rendererConfig?: WorldRendererConfig | undefined;
115
135
  }
116
136
 
117
137
  /** A live, mounted canvas world (T7.3) — the pixi analog of