@vgai/engine 0.5.2 → 0.5.3

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 (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -1,15 +1,13 @@
1
1
  /**
2
- * The standalone in-page debug bridge (`docs/SYNTHETIC-PLAYER-SPEC.md` §3.4,
3
- * D17/D18; Task 2.1 of `docs/E2E-TESTING-BUILD-PLAN.md`). Query-gated
4
- * `window.__vgai`, the same production-protection-lives-in-the-installer
5
- * pattern `render-control.ts`'s `installRenderControlHarness` established for
6
- * `?vgai-render=1` (production protection lives HERE, not just at whatever
7
- * call site invokes this a caller that calls
8
- * {@link maybeInstallDebugBridge} unconditionally on every boot still only
9
- * ever gets a handle when the gate below actually passes).
2
+ * The standalone in-page debug bridge. Query-gated `window.__vgai`, the same
3
+ * production-protection-lives-in-the-installer pattern `render-control.ts`'s
4
+ * `installRenderControlHarness` established for `?vgai-render=1` (production
5
+ * protection lives HERE, not just at whatever call site invokes this — a
6
+ * caller that calls {@link maybeInstallDebugBridge} unconditionally on every
7
+ * boot still only ever gets a handle when the gate below actually passes).
10
8
  *
11
9
  * D17: this is door (a) of the seam's three doors — the SAME `DebugAdapter`
12
- * names/JSON the editor relay (door b) and `@vgai/e2e` (door c, which
10
+ * names/JSON the editor relay (door b) and `@vgai/live` (door c, which
13
11
  * drives door (a) itself over Playwright) all read. D18: the bridge installs
14
12
  * only when the page opts in (`?vgai-debug=1`) AND is either a dev build
15
13
  * (`import.meta.env.DEV`) or the project's manifest explicitly opts a
@@ -40,7 +38,7 @@ export interface DebugBridgeManifest {
40
38
  * import. Every method takes an optional trailing `worldId` (D15/T-D15.5,
41
39
  * the review-objection-2 fix): omitted, it resolves to the SAME default
42
40
  * world the editor relay's `inject-input` case resolves to (one shared
43
- * resolution function, `debug-registry.ts`'s `resolveInputWorldId`) — never
41
+ * resolution function, `debug-registry.ts`'s `resolveInputRootId`) — never
44
42
  * "whichever world's `InputManager` happened to register last". An
45
43
  * explicit `worldId` reaches that world's `InputManager` specifically. */
46
44
  export interface VgaiDebugInputHandle {
@@ -52,15 +50,14 @@ export interface VgaiDebugInputHandle {
52
50
  tapVirtualAction(action: string, worldId?: string): { delivered: boolean; reason?: string };
53
51
  clearVirtualActions(worldId?: string): void;
54
52
  /**
55
- * D15/T-D15.5 (`docs/D15-DETERMINISM-DESIGN.md` §2.c): schedule a virtual
56
- * actuation for a specific tick — applied at the START of that tick's
57
- * input phase, composing with `runTicks` (a schedule for tick 500 fires
58
- * exactly once the sim has been driven through tick 500, regardless of
59
- * burst size). A digital `true` produces a genuine `isJustPressed` edge
60
- * exactly at the target tick. Throws `INPUT_ACTION_NOT_FOUND`/a valueType
61
- * mismatch (same as `setVirtualAction`) or `TICK_ALREADY_PASSED`
62
- * (`data.currentTick`, the NEXT tick to be serviced) for a tick that
63
- * already elapsed.
53
+ * D15/T-D15.5: schedule a virtual actuation for a specific tick — applied
54
+ * at the START of that tick's input phase, composing with `runTicks` (a
55
+ * schedule for tick 500 fires exactly once the sim has been driven
56
+ * through tick 500, regardless of burst size). A digital `true` produces
57
+ * a genuine `isJustPressed` edge exactly at the target tick. Throws
58
+ * `INPUT_ACTION_NOT_FOUND`/a valueType mismatch (same as
59
+ * `setVirtualAction`) or `TICK_ALREADY_PASSED` (`data.currentTick`, the
60
+ * NEXT tick to be serviced) for a tick that already elapsed.
64
61
  *
65
62
  * A scheduled tick whose input phase never runs at all (that world
66
63
  * paused/frozen, or skipped by a multi-tick gap, when the target tick
@@ -130,20 +127,20 @@ export interface VgaiDebugHandle {
130
127
  invoke(name: string, args: unknown[]): Promise<unknown>;
131
128
  events: DebugAdapter['events'];
132
129
  input: VgaiDebugInputHandle;
133
- /** See `DebugAdapter.events`'s doc comment for `sinceTick`/`sinceSeq`'s
134
- * contract (run-4 friction #5) — `snapshot`'s `events` member is filtered
135
- * the exact same way, just batched with `time`/`state`/`pageErrors` into
136
- * one synchronous read. */
137
- snapshot(sinceTick?: number, sinceSeq?: number): VgaiDebugSnapshot;
130
+ /** See `DebugAdapter.events`'s doc comment for `sinceSeq`'s contract
131
+ * (run-4 friction #5) — `snapshot`'s `events` member is filtered the exact
132
+ * same way, just batched with `time`/`state`/`pageErrors` into one
133
+ * synchronous read. */
134
+ snapshot(sinceSeq?: number): VgaiDebugSnapshot;
138
135
  /**
139
- * D15/T-D15.4 door (a) (`docs/D15-DETERMINISM-DESIGN.md` §2.b): synchronously
140
- * drive `n` fixed gameplay ticks via the live `Game`'s `GameInternal.runTicks`
141
- * (`runtime/game.ts`) reached through `DebugRegistry.getRunTicksTarget()`,
142
- * the SAME target the editor relay's `run-ticks` case (→ `play.runTicks`,
143
- * door b) calls, so behavior is byte-identical across every door (D17).
144
- * Throws `DEBUG_RUN_TICKS_UNAVAILABLE` if no `Game` has wired a target yet
145
- * (no world mounted); throws whatever `runTicks` itself throws otherwise
146
- * (e.g. `RUN_TICKS_PAUSED`) — never a silent no-op.
136
+ * D15/T-D15.4 door (a): synchronously drive `n` fixed gameplay ticks via the
137
+ * live `Game`'s `GameInternal.runTicks` (`runtime/game.ts`) — reached through
138
+ * `DebugRegistry.getRunTicksTarget()`, the SAME target the editor relay's
139
+ * `run-ticks` case (→ `play.runTicks`, door b) calls, so behavior is
140
+ * byte-identical across every door (D17). Throws `DEBUG_RUN_TICKS_UNAVAILABLE`
141
+ * if no `Game` has wired a target yet (no world mounted); throws whatever
142
+ * `runTicks` itself throws otherwise (e.g. `RUN_TICKS_PAUSED`) never a
143
+ * silent no-op.
147
144
  */
148
145
  runTicks(n: number, opts?: RunTicksOptions): void;
149
146
  /**
@@ -152,7 +149,7 @@ export interface VgaiDebugHandle {
152
149
  * this is the honest human-input-path proof) → `input.clearVirtualActions()`
153
150
  * into ONE async bridge call, as a TOP-LEVEL method (not under `input`)
154
151
  * because it needs the sim clock, not just the input target. Was 15+
155
- * transport round trips over the editor relay (`@vgai/e2e`'s old
152
+ * transport round trips over the editor relay (`@vgai/live`'s old
156
153
  * `GameInput.hold`: set → a 150ms-interval `waitSimTime` snapshot poll loop
157
154
  * → clear); now one `page.evaluate`/relay call that runs the wait
158
155
  * in-process on the page.
@@ -179,7 +176,7 @@ export interface VgaiDebugHandle {
179
176
  * Defect 5 fix — undo everything THIS install did: remove the
180
177
  * `error`/`unhandledrejection` window listeners it added, and delete
181
178
  * `window.__vgai` so the registry is no longer reachable from the page.
182
- * Idempotent (a second call is a harmless no-op). `mountManifestWorlds`
179
+ * Idempotent (a second call is a harmless no-op). `mountManifestRoots`
183
180
  * wires this into its session's `stop()`; a caller mounting more
184
181
  * directly (a hand-rolled host) should call it on its own teardown path.
185
182
  */
@@ -224,72 +221,128 @@ export interface MaybeInstallDebugBridgeOptions {
224
221
  const PAGE_ERROR_CAP = 100;
225
222
 
226
223
  /** `holdFor`'s in-process poll interval — this loop never leaves the page
227
- * (no transport round trip per poll, unlike `@vgai/e2e`'s old
224
+ * (no transport round trip per poll, unlike `@vgai/live`'s old
228
225
  * `waitSimTime`), so it can afford to be tighter than that loop's 150ms. */
229
226
  const HOLD_FOR_POLL_MS = 50;
230
227
 
231
228
  /** Consecutive `HOLD_FOR_POLL_MS` polls with the tick unchanged before
232
229
  * `holdFor` gives up waiting and treats the game as stopped — mirrors
233
- * `@vgai/e2e`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
230
+ * `@vgai/live`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
234
231
  * a genuinely frozen sim clock must never poll forever), scaled to this
235
232
  * faster in-process interval so the wall-clock grace period (~5s) lands in
236
233
  * the same neighborhood. */
237
234
  const HOLD_FOR_STALL_POLL_LIMIT = 100;
238
235
 
239
- /** Resolves once `simSeconds` of sim time has elapsed since the call (normal
240
- * host-loop ticks while visible; deterministic `runTicks` through the same
241
- * game phases when the host loop reports `hidden-paused`), or once the tick
242
- * has stopped changing for
243
- * `HOLD_FOR_STALL_POLL_LIMIT` consecutive polls (`stalled: true` — the loop
244
- * driving ticks stopped, e.g. play was stopped/paused). Never rejects.
245
- * Exported (not just used by `holdFor` below) so the editor relay's
246
- * `command-listener.ts` `holdFor` case can share the EXACT same
247
- * poll/stall/hidden-drive logic against its own `DebugAdapter` (reached via
248
- * `getActiveSystems().debug` rather than a `DebugRegistry`) D17:
249
- * byte-identical behavior across doors, not two hand-copies that can
250
- * silently drift apart. */
251
- export function waitForHoldBudget(
236
+ /** Ticks driven per synchronous hidden-tab batch. Sized like
237
+ * `fast-forward.ts`'s own batching rationale: big enough that per-batch
238
+ * bookkeeping is negligible, small enough that one batch of a complex game's
239
+ * phases stays a short synchronous burst. */
240
+ const HIDDEN_DRIVE_BATCH_TICKS = 60;
241
+
242
+ /** Hard ceiling on hidden-drive batches for ONE hold `HIDDEN_DRIVE_BATCH_TICKS
243
+ * * this` ≈ 33 sim-minutes. A bound, not a timeout: it exists so a
244
+ * `runTicks` target that advances the tick counter without advancing the sim
245
+ * clock can never spin forever, and it is far past any honest hold. */
246
+ const HIDDEN_DRIVE_MAX_BATCHES = 2000;
247
+
248
+ /** The message a hold gets when the loop is hidden-paused and NOTHING can
249
+ * drive it — the one case where the platform genuinely prevents the verb
250
+ * from working. Names the cause and the fix rather than expiring into a
251
+ * generic stall (which reported the wrong cause: "play stopped during
252
+ * hold"). One exported constant so both doors and their tests read the same
253
+ * string. */
254
+ export const HOLD_HIDDEN_NO_DRIVER_REASON =
255
+ 'the editor tab is hidden and this session has no way to drive ticks — the engine ' +
256
+ 'hidden-pauses its loop while document.hidden, so zero ticks are happening. Bring the ' +
257
+ 'editor tab to the foreground (or start play, which wires the run-ticks target) and retry.';
258
+
259
+ /** What `adapter.state('time')` returns for the two fields this loop reads,
260
+ * plus the liveness the hidden path branches on. */
261
+ interface HoldClockReading {
262
+ simSeconds: number;
263
+ tick: number;
264
+ loopLiveness?: GameLoopLiveness | null;
265
+ }
266
+
267
+ /**
268
+ * Resolves once `simSeconds` of sim time has elapsed since the call, or once
269
+ * the wait provably cannot make progress. Never rejects. Exported (not just
270
+ * used by `holdFor` below) so the editor relay's `command-listener.ts`
271
+ * `holdFor` case can share the EXACT same logic against its own
272
+ * `DebugAdapter` (reached via `getActiveSystems().debug` rather than a
273
+ * `DebugRegistry`) — D17: byte-identical behavior across doors, not two
274
+ * hand-copies that can silently drift apart.
275
+ *
276
+ * Two regimes, and the split is the whole point:
277
+ *
278
+ * - **Visible**: the host loop is ticking on its own, so this polls it every
279
+ * `HOLD_FOR_POLL_MS` and gives up after `HOLD_FOR_STALL_POLL_LIMIT`
280
+ * consecutive unchanged-tick polls (`stalled: true` — play stopped/paused).
281
+ *
282
+ * - **Hidden-paused**: the host loop has stopped outright (T2.1's idle
283
+ * throttle), so sim time only moves if this drives it. It therefore drives
284
+ * the WHOLE remaining budget in synchronous batches, yielding a MICROTASK
285
+ * between them — never a timer. That distinction is load-bearing, not
286
+ * stylistic: a hidden tab clamps `setTimeout` to ~1s (and to ~1/minute under
287
+ * Chrome's intensive throttling after 5 minutes hidden), so the previous
288
+ * "drive 3 ticks, then `setTimeout(poll, 50)`" shape ran the sim at ~3
289
+ * ticks per SECOND. A 1.2s hold needed ~24 clamped turns — ~24s of wall
290
+ * clock against the editor relay's 5s per-command budget, which is exactly
291
+ * how a hold that passes on a foregrounded tab died with a generic
292
+ * "editor connected but did not respond" on a backgrounded one. Microtasks
293
+ * are not throttled, so the hidden path now costs sim-work time and nothing
294
+ * else.
295
+ *
296
+ * The regime is re-read every iteration, so a tab foregrounded (or hidden)
297
+ * mid-hold crosses over without restarting the budget.
298
+ *
299
+ * `hiddenWithoutDriver: true` is the one honest refusal: hidden-paused with
300
+ * no `driveHiddenTicks` hook means nothing in this process can advance the
301
+ * clock, so it reports that IMMEDIATELY (see
302
+ * {@link HOLD_HIDDEN_NO_DRIVER_REASON}) instead of burning the stall guard
303
+ * and then blaming a stopped game.
304
+ */
305
+ export async function waitForHoldBudget(
252
306
  adapter: DebugAdapter,
253
307
  simSeconds: number,
254
308
  driveHiddenTicks?: (n: number) => void,
255
- ): Promise<{ stalled: boolean }> {
256
- return new Promise((resolve) => {
257
- const start = adapter.state('time') as {
258
- simSeconds: number;
259
- tick: number;
260
- loopLiveness?: GameLoopLiveness | null;
261
- };
262
- let lastTick = start.tick;
263
- let stalledPolls = 0;
264
- const poll = () => {
265
- const current = adapter.state('time') as {
266
- simSeconds: number;
267
- tick: number;
268
- loopLiveness?: GameLoopLiveness | null;
269
- };
270
- if (current.simSeconds - start.simSeconds >= simSeconds) {
271
- resolve({ stalled: false });
272
- return;
273
- }
274
- if (current.loopLiveness === 'hidden-paused' && driveHiddenTicks) {
275
- // One poll represents HOLD_FOR_POLL_MS of requested simulation
276
- // progress. Keep this deterministic instead of translating scheduler
277
- // jitter through a wall-clock read into a different tick count.
278
- const ticks = Math.max(1, Math.round(HOLD_FOR_POLL_MS / (1000 / 60)));
279
- driveHiddenTicks(ticks);
280
- setTimeout(poll, HOLD_FOR_POLL_MS);
281
- return;
282
- }
283
- stalledPolls = current.tick === lastTick ? stalledPolls + 1 : 0;
284
- lastTick = current.tick;
285
- if (stalledPolls >= HOLD_FOR_STALL_POLL_LIMIT) {
286
- resolve({ stalled: true });
287
- return;
309
+ ): Promise<{ stalled: boolean; hiddenWithoutDriver?: boolean }> {
310
+ const readTime = () => adapter.state('time') as HoldClockReading;
311
+ const start = readTime();
312
+ let lastTick = start.tick;
313
+ let stalledPolls = 0;
314
+ let hiddenBatches = 0;
315
+
316
+ for (;;) {
317
+ const current = readTime();
318
+ if (current.simSeconds - start.simSeconds >= simSeconds) return { stalled: false };
319
+
320
+ if (current.loopLiveness === 'hidden-paused') {
321
+ if (!driveHiddenTicks) return { stalled: true, hiddenWithoutDriver: true };
322
+ if (hiddenBatches >= HIDDEN_DRIVE_MAX_BATCHES) return { stalled: true };
323
+ hiddenBatches += 1;
324
+ driveHiddenTicks(HIDDEN_DRIVE_BATCH_TICKS);
325
+ const after = readTime();
326
+ // A batch that moved neither the tick counter nor the sim clock means
327
+ // the drive is a no-op (paused game, torn-down runtime) — the same
328
+ // "nothing is advancing" verdict the visible path's stall guard
329
+ // reaches, just observable in one batch instead of a hundred polls.
330
+ if (after.tick === current.tick && after.simSeconds === current.simSeconds) {
331
+ return { stalled: true };
288
332
  }
289
- setTimeout(poll, HOLD_FOR_POLL_MS);
290
- };
291
- setTimeout(poll, HOLD_FOR_POLL_MS);
292
- });
333
+ lastTick = after.tick;
334
+ stalledPolls = 0;
335
+ // Yield so the page can service other work between batches, WITHOUT
336
+ // handing control to a clock a hidden tab is allowed to starve.
337
+ await Promise.resolve();
338
+ continue;
339
+ }
340
+
341
+ stalledPolls = current.tick === lastTick ? stalledPolls + 1 : 0;
342
+ lastTick = current.tick;
343
+ if (stalledPolls >= HOLD_FOR_STALL_POLL_LIMIT) return { stalled: true };
344
+ await new Promise<void>((resolve) => setTimeout(resolve, HOLD_FOR_POLL_MS));
345
+ }
293
346
  }
294
347
 
295
348
  function hasRealWindow(): boolean {
@@ -337,7 +390,7 @@ function buildDebugHandle(opts: {
337
390
  if (!target) {
338
391
  throw new DebugError(
339
392
  'DEBUG_INPUT_UNAVAILABLE',
340
- `debug bridge: ${method}() has no virtual-input target wired — no default threejs ` +
393
+ `debug bridge: ${method}() has no virtual-input target wired — no default three ` +
341
394
  "world has mounted yet, or this project's mount path never wired one",
342
395
  );
343
396
  }
@@ -391,7 +444,7 @@ function buildDebugHandle(opts: {
391
444
  injectPointerPosition: (sourceId, value, worldId) =>
392
445
  requireInputTarget('injectPointerPosition', worldId).injectPointerPosition(sourceId, value),
393
446
  },
394
- snapshot(sinceTick, sinceSeq) {
447
+ snapshot(sinceSeq) {
395
448
  // One synchronous pass — see VgaiDebugSnapshot's doc comment.
396
449
  return {
397
450
  time: adapter.state('time') as {
@@ -400,7 +453,7 @@ function buildDebugHandle(opts: {
400
453
  loopLiveness: GameLoopLiveness | null;
401
454
  },
402
455
  state: adapter.stateAll(),
403
- events: adapter.events(sinceTick, sinceSeq),
456
+ events: adapter.events(sinceSeq),
404
457
  pageErrors: pageErrors.slice(),
405
458
  };
406
459
  },
@@ -428,15 +481,17 @@ function buildDebugHandle(opts: {
428
481
  : { delivered: false };
429
482
  }
430
483
  const runTicksTarget = registry.getRunTicksTarget();
431
- const { stalled } = await waitForHoldBudget(
484
+ const { stalled, hiddenWithoutDriver } = await waitForHoldBudget(
432
485
  adapter,
433
486
  simSeconds,
434
487
  runTicksTarget ? (n) => runTicksTarget.runTicks(n, { render: 'last' }) : undefined,
435
488
  );
436
489
  target.clearVirtualActions();
437
- return stalled
438
- ? { delivered: false, reason: 'play stopped during hold' }
439
- : { delivered: true };
490
+ if (!stalled) return { delivered: true };
491
+ return {
492
+ delivered: false,
493
+ reason: hiddenWithoutDriver ? HOLD_HIDDEN_NO_DRIVER_REASON : 'play stopped during hold',
494
+ };
440
495
  },
441
496
  uninstall() {
442
497
  opts.window.removeEventListener?.('error', onWindowError);