@volter/editor-game 0.5.65 → 0.5.67

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 (281) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +3 -3
  11. package/contributions/generations.status.tsx +1 -1
  12. package/contributions/godot.palette.json +99 -0
  13. package/contributions/godot.style.ts +82 -0
  14. package/contributions/godot.view.ts +75 -0
  15. package/contributions/ingest.service.ts +4 -4
  16. package/contributions/instances.command.ts +1 -1
  17. package/contributions/navmesh.menu.ts +1 -1
  18. package/contributions/network-observer.service.ts +14 -0
  19. package/contributions/play.command.ts +11 -5
  20. package/contributions/react/component-board.service.ts +1 -1
  21. package/contributions/react/design-time-mount.service.ts +1 -1
  22. package/contributions/react/react-inspector.service.ts +2 -2
  23. package/contributions/scene-document.service.ts +5 -5
  24. package/contributions/state-watch.menu.ts +1 -1
  25. package/contributions/state-watch.utility.tsx +1 -1
  26. package/contributions/team-playtest.service.ts +4 -4
  27. package/contributions/three/component-board.service.ts +1 -1
  28. package/contributions/three/component-verbs.command.ts +8 -8
  29. package/contributions/three/three-authoring.service.ts +8 -5
  30. package/contributions/three-story-capture.service.ts +12 -0
  31. package/contributions/unity.palette.json +102 -0
  32. package/contributions/unity.style.ts +72 -0
  33. package/contributions/unity.view.ts +82 -0
  34. package/contributions/unreal.palette.json +105 -0
  35. package/contributions/unreal.style.ts +66 -0
  36. package/contributions/unreal.view.ts +93 -0
  37. package/dist-node/serving.mjs +16 -0
  38. package/package.json +33 -16
  39. package/serving/index.ts +27 -0
  40. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  41. package/src/asset-budget/asset-budget-model.ts +4 -4
  42. package/src/asset-budget/optimize-apply.ts +7 -7
  43. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  44. package/src/bridge/dispatch.ts +16 -16
  45. package/src/bridge/live-frames.ts +1 -1
  46. package/src/bridge/screenshot.ts +6 -6
  47. package/src/build/BuildProfilesPanel.tsx +3 -3
  48. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  49. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  50. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  51. package/src/canvas/canvas-design-mount.ts +524 -0
  52. package/src/canvas/design-time-canvas-mount.ts +79 -0
  53. package/src/coverage/live-authoring-surface.ts +6 -6
  54. package/src/coverage/live-project-verbs.ts +2 -2
  55. package/src/coverage/native-system-coverage.ts +6 -6
  56. package/src/coverage/root-coverage.ts +1 -1
  57. package/src/coverage/session-coverage.ts +5 -5
  58. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  59. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +13 -53
  60. package/src/edit-mode/edit-mode-audio.ts +4 -4
  61. package/src/edit-mode/edit-mode-networking.ts +2 -2
  62. package/src/game-document/GameCaptureFrameButton.tsx +2 -2
  63. package/src/game-document/GameDocument.tsx +2 -2
  64. package/src/game-document/GamePanel.tsx +8 -8
  65. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  66. package/src/game-document/crowd-debug.ts +1 -1
  67. package/src/game-document/device-preview.ts +10 -11
  68. package/src/game-document/physics-debug.ts +1 -1
  69. package/src/generation/GenerationActivity.tsx +6 -8
  70. package/src/generation/generation-documents.tsx +8 -8
  71. package/src/generation/generation-jobs.ts +2 -2
  72. package/src/generation/generation-presentation.ts +1 -1
  73. package/src/host/adapter-reach.ts +1 -1
  74. package/src/host/adapter-runtime-bindings.ts +97 -6
  75. package/src/host/api/configurations.ts +2 -2
  76. package/src/host/authoring/babylon-authoring-adapter.ts +8 -8
  77. package/src/host/authoring/contract-hierarchy-authoring.ts +1 -1
  78. package/src/host/authoring/contract-scenes-stories.ts +1 -1
  79. package/src/host/authoring/creation-site-related.ts +2 -2
  80. package/src/host/authoring/ephemeral-persistence.ts +1 -1
  81. package/src/host/authoring/gesture-persist.ts +2 -2
  82. package/src/host/authoring/ingest-data-writer.ts +1 -1
  83. package/src/host/authoring/ingest-source-persistence.ts +8 -8
  84. package/src/host/authoring/mount-isolated-pixi-screen.ts +1 -1
  85. package/src/host/authoring/mounted-authoring.ts +4 -4
  86. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  87. package/src/host/authoring/pixi-authoring-adapter.ts +42 -17
  88. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  89. package/src/host/authoring/pixi-live-write-target.ts +7 -7
  90. package/src/host/authoring/pixi-source-identity.ts +3 -3
  91. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  92. package/src/host/authoring/pixi-still-presentation.ts +2 -2
  93. package/src/host/authoring/pixi-structure-history.ts +3 -3
  94. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  95. package/src/host/authoring/source-persistence-backend.ts +6 -6
  96. package/src/host/authoring/struct-write-pipe.ts +3 -3
  97. package/src/host/binding-resolver.ts +8 -9
  98. package/src/host/browser-transpile.ts +2 -2
  99. package/src/host/canvas-entry-runtime.ts +59 -48
  100. package/src/host/canvas-preview-frames.ts +482 -0
  101. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  102. package/src/host/components/HeaderTelemetry.tsx +9 -16
  103. package/src/host/components/PixiIsolationSceneContent.tsx +17 -16
  104. package/src/host/components/ThreeIsolationSceneContent.tsx +5 -5
  105. package/src/host/components/frame-debugger-model.ts +2 -2
  106. package/src/host/components/header-telemetry-model.ts +2 -2
  107. package/src/host/components/scene-document.tsx +29 -61
  108. package/src/host/components/utility-view-state.ts +1 -1
  109. package/src/host/components/world-root-stage-binding.tsx +17 -26
  110. package/src/host/components/world-root-stage.ts +130 -63
  111. package/src/host/coverage/capability-coverage.ts +13 -1
  112. package/src/host/coverage/game-contract-seam-evidence.ts +1 -1
  113. package/src/host/coverage/project-verb-coverage.ts +2 -17
  114. package/src/host/coverage/system-adapter-coverage.ts +4 -5
  115. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +14 -8
  116. package/src/host/document-preview-three.ts +2 -2
  117. package/src/host/entry-adjudication.ts +6 -6
  118. package/src/host/game-css-scope-transform.ts +4 -0
  119. package/src/host/game-module-access.ts +1 -1
  120. package/src/host/game-realm-page.ts +16 -8
  121. package/src/host/game-realm-reclaim.ts +1 -1
  122. package/src/host/gameplay-export.ts +25 -14
  123. package/src/host/gameplay-recording.ts +5 -5
  124. package/src/host/gated-globals.ts +6 -6
  125. package/src/host/history/json-history-resource.ts +3 -3
  126. package/src/host/instance-extract-actions.ts +1 -1
  127. package/src/host/instance-fork-actions.ts +1 -1
  128. package/src/host/projection/dom.ts +2 -2
  129. package/src/host/projection/pixi.ts +25 -3
  130. package/src/host/r3f-entry-runtime.ts +65 -34
  131. package/src/host/react-mount-runtime.ts +8 -49
  132. package/src/host/realm-services.ts +2 -2
  133. package/src/host/roots/canvas-root.tsx +361 -0
  134. package/src/host/roots/module-root.ts +1 -1
  135. package/src/host/roots/r3f-root.tsx +473 -0
  136. package/src/host/roots/react-root.ts +9 -43
  137. package/src/host/scene-view-drawability.ts +2 -2
  138. package/src/host/served-bundle-runtime-modules.ts +1 -19
  139. package/src/host/server-log-bridge.ts +3 -3
  140. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  141. package/src/host/stories/pixi-story-model.ts +30 -0
  142. package/src/host/stories/story-media-captures.ts +46 -0
  143. package/src/host/stories/story-media-presence.ts +3 -3
  144. package/src/host/stories/story-pixi-preview.ts +408 -0
  145. package/src/host/stories/story-three-preview.ts +806 -0
  146. package/src/host/stories/three-story-captures.ts +35 -0
  147. package/src/host/story-three-preview-runtime.ts +56 -0
  148. package/src/host/three-ingest-runtime.ts +1 -1
  149. package/src/host/use-active-performance-source.ts +3 -3
  150. package/src/host/viewport-pose-memory.ts +1 -1
  151. package/src/host/viewport-root-presentation.ts +6 -5
  152. package/src/ingest/active-ingest.ts +2 -2
  153. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  154. package/src/ingest/authoring/ingest-root-adapter.ts +13 -13
  155. package/src/ingest/capture-wait-report.ts +1 -1
  156. package/src/ingest/deferred-ingest-play.ts +11 -10
  157. package/src/ingest/discovery-public-ingest.ts +2 -2
  158. package/src/ingest/ingest-boot-viewport.ts +6 -8
  159. package/src/ingest/ingest-canvas-scene-document.tsx +17 -16
  160. package/src/ingest/ingest-canvas-scene.ts +5 -8
  161. package/src/ingest/ingest-evidence-hook.ts +1 -1
  162. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  163. package/src/ingest/ingest-play-control.ts +4 -4
  164. package/src/ingest/ingest-render-debug.ts +11 -11
  165. package/src/ingest/ingest-siblings.ts +16 -28
  166. package/src/ingest/module-mode.ts +18 -19
  167. package/src/ingest/mount-canvas-ingest-root.ts +27 -27
  168. package/src/ingest/mount-coverage.ts +4 -4
  169. package/src/ingest/mount-dom-ingest-root.ts +16 -16
  170. package/src/ingest/mount-ingest-root.ts +14 -14
  171. package/src/ingest/mount-three-ingest-root.ts +12 -12
  172. package/src/ingest/resolve-canvas.ts +1 -1
  173. package/src/ingest/served-html-boot.ts +1 -1
  174. package/src/ingest/surface-canvas.ts +1 -1
  175. package/src/ingest/unmount-ingest-root.ts +7 -7
  176. package/src/navmesh/navmesh-handler.ts +24 -16
  177. package/src/network/NetworkInspectorPanel.tsx +449 -14
  178. package/src/network/network-inspector-model.ts +14 -2
  179. package/src/play/play-log-events.ts +1 -1
  180. package/src/play/play-mode.ts +99 -142
  181. package/src/play/play-recording.ts +2 -2
  182. package/src/play/react-play-live-authoring.ts +3 -3
  183. package/src/play/run-selection.ts +93 -0
  184. package/src/play-bar/PlayBar.tsx +20 -39
  185. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  186. package/src/profiler/PerformancePanel.tsx +4 -4
  187. package/src/react/design-time-react-mount.ts +37 -85
  188. package/src/react/dom-authoring-adapter.ts +11 -11
  189. package/src/react/react-inspector-section.tsx +13 -13
  190. package/src/react/react-world-authoring-adapter.ts +31 -143
  191. package/src/react/story-documents/story-documents.tsx +18 -22
  192. package/src/react/ui-board-document.tsx +10 -11
  193. package/src/react/ui-component-board.ts +5 -5
  194. package/src/runtime/adapter/audio-meter.ts +21 -0
  195. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  196. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  197. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  198. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  199. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  200. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  201. package/src/runtime/adapter/native-debug-module.ts +498 -0
  202. package/src/runtime/audio/bus-mixer.ts +161 -0
  203. package/src/runtime/audio/pose-guard.ts +80 -0
  204. package/src/runtime/core/frame-pacing.ts +126 -0
  205. package/src/runtime/core/game-loop.ts +225 -0
  206. package/src/runtime/core/game-scoped-slot.ts +28 -0
  207. package/src/runtime/core/seeded-random.ts +162 -0
  208. package/src/runtime/core/sim-clock.ts +391 -0
  209. package/src/runtime/core/system-runner.ts +269 -0
  210. package/src/runtime/core/types.ts +104 -0
  211. package/src/runtime/create-runtime.ts +1128 -0
  212. package/src/runtime/debug-bridge.ts +570 -0
  213. package/src/runtime/debug-registry.ts +899 -0
  214. package/src/runtime/dev/chrome-trace.ts +153 -0
  215. package/src/runtime/dev/instruments.ts +403 -0
  216. package/src/runtime/dev/logger.ts +119 -0
  217. package/src/runtime/dev/performance-profiler.ts +367 -0
  218. package/src/runtime/dev/register-render-vitals.ts +276 -0
  219. package/src/runtime/dev/render-census.ts +354 -0
  220. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  221. package/src/runtime/dev/render-memory.ts +226 -0
  222. package/src/runtime/dev/render-vitals.ts +338 -0
  223. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  224. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  225. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  226. package/src/runtime/dev-build.ts +47 -0
  227. package/src/runtime/game.ts +1636 -0
  228. package/src/runtime/gameplay-rng-trap.ts +135 -0
  229. package/src/runtime/host-context.ts +64 -0
  230. package/src/runtime/input-router.ts +169 -0
  231. package/src/runtime/mount-manifest.ts +480 -0
  232. package/src/runtime/pixi/authoring.ts +706 -0
  233. package/src/runtime/pixi/ingest.ts +116 -0
  234. package/src/runtime/pixi/physics-registry.ts +49 -0
  235. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  236. package/src/runtime/pixi/scene-capture.ts +179 -0
  237. package/src/runtime/pixi/system-adapters.ts +69 -0
  238. package/src/runtime/playtest.ts +22 -0
  239. package/src/runtime/presentation.ts +141 -0
  240. package/src/runtime/render-control.ts +642 -0
  241. package/src/runtime/render-seed.ts +77 -0
  242. package/src/runtime/run-ticks-settled.ts +73 -0
  243. package/src/runtime/setup/setup-audio.ts +72 -0
  244. package/src/services/audio-pose-guard.ts +2 -2
  245. package/src/services/game-audio.ts +152 -0
  246. package/src/services/game-network.ts +657 -0
  247. package/src/services/game-physics.ts +334 -0
  248. package/src/state-watch/StateWatchPanel.tsx +1 -1
  249. package/src/three/authoring/camera-runtime-inspector-section.tsx +4 -3
  250. package/src/three/authoring/constraint-inspector-section.tsx +8 -7
  251. package/src/three/authoring/model-asset-inspector-section.tsx +14 -17
  252. package/src/three/authoring/oid-source-persistence.ts +13 -13
  253. package/src/three/authoring/r3f-design-session.ts +79 -61
  254. package/src/three/authoring/r3f-source-authoring-adapter.ts +112 -108
  255. package/src/three/authoring/reflection-probe-inspector-section.tsx +5 -4
  256. package/src/three/authoring/spatial-joint-handles.ts +1 -1
  257. package/src/three/authoring/spatial-lod-handles.ts +1 -1
  258. package/src/three/authoring/spatial-particle-handles.ts +1 -1
  259. package/src/three/authoring/three-authoring-adapter.ts +33 -33
  260. package/src/three/component-verbs/extract-menu.ts +8 -7
  261. package/src/three/component-verbs/fork-menu.ts +8 -7
  262. package/src/three/component-verbs/internals-menu.ts +3 -3
  263. package/src/three/drei-asset-caches.ts +13 -0
  264. package/src/three/story-documents/three-story-documents.tsx +24 -34
  265. package/src/three/three-board/ThreeBoardDocument.tsx +25 -25
  266. package/src/three/three-board/board-scene.ts +7 -7
  267. package/src/three/three-board/three-component-board.ts +5 -5
  268. package/contributions/react/pasteboard.action.ts +0 -41
  269. package/contributions/xstate-behavior.action.ts +0 -42
  270. package/contributions/xstate-behavior.document.tsx +0 -73
  271. package/contributions/xstate-behavior.inspector.tsx +0 -28
  272. package/contributions/xstate-behavior.menu.ts +0 -23
  273. package/src/react/pasteboard-materialize.ts +0 -154
  274. package/src/services/game-audio-unlock.ts +0 -48
  275. package/src/xstate/XStateBehaviorSection.tsx +0 -130
  276. package/src/xstate/XStateMachineInspector.tsx +0 -566
  277. package/src/xstate/character-animation-machine.fixture.ts +0 -74
  278. package/src/xstate/live-behaviors.ts +0 -106
  279. package/src/xstate/use-live-actor-state.ts +0 -44
  280. package/src/xstate/xstate-graph.ts +0 -235
  281. package/src/xstate/xstate-layout.ts +0 -76
@@ -0,0 +1,570 @@
1
+ /**
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).
8
+ *
9
+ * D17: this is door (a) of the seam's three doors — the SAME `DebugAdapter`
10
+ * names/JSON the editor relay (door b) and `@vgai/live` (door c, which
11
+ * drives door (a) itself over Playwright) all read. D18: the bridge installs
12
+ * only when the page opts in (`?vgai-debug=1`) AND is either a dev build
13
+ * (`import.meta.env.DEV`) or the project's manifest explicitly opts a
14
+ * production build in (`manifest.debug.allowInProduction`, the described
15
+ * field whose runtime reader THIS module is).
16
+ */
17
+
18
+ import type { DebugAdapter, TickStampedEvent } from '@volter/editor-project/adapter/system-adapter';
19
+ import type { GameLoopLiveness } from './core/types';
20
+ import { DebugError, type DebugRegistry, type RunTicksOptions } from './debug-registry';
21
+ import { runTicksWhenSettled } from './run-ticks-settled';
22
+
23
+ /** Query-param name that opts a standalone page into the debug bridge (D18) —
24
+ * mirrors `render-seed.ts`'s `RENDER_MODE_QUERY_PARAM` naming/shape, own
25
+ * const because this is a different call site with a different flag. */
26
+ export const DEBUG_MODE_QUERY_PARAM = 'vgai-debug';
27
+
28
+ /** The subset of `ResolvedGameManifest` the D18 gate reads — typed narrowly
29
+ * (not imported from `manifest/load.ts`) so this module doesn't need the
30
+ * full manifest shape, just the one described field it is the runtime
31
+ * reader for. */
32
+ export interface DebugBridgeManifest {
33
+ readonly debug?: { readonly allowInProduction: boolean } | undefined;
34
+ }
35
+
36
+ /** The bridge's actuation surface — a root's input door, reached through
37
+ * `DebugRegistry.getVirtualInputTarget(worldId?)`. Every method takes an optional trailing `worldId` (D15/T-D15.5,
38
+ * the review-objection-2 fix): omitted, it resolves to the SAME default
39
+ * world the editor relay's `inject-input` case resolves to (one shared
40
+ * resolution function, `debug-registry.ts`'s `resolveInputRootId`) — never
41
+ * "whichever root happened to register last". An explicit `worldId`
42
+ * reaches that root's door specifically. */
43
+ export interface VgaiDebugInputHandle {
44
+ setVirtualAction(
45
+ action: string,
46
+ value: boolean | number | { x: number; y: number },
47
+ worldId?: string,
48
+ ): { delivered: boolean; reason?: string };
49
+ tapVirtualAction(action: string, worldId?: string): { delivered: boolean; reason?: string };
50
+ clearVirtualActions(worldId?: string): void;
51
+ /**
52
+ * D15/T-D15.5: schedule a virtual actuation for a specific tick — applied
53
+ * at the START of that tick's input phase, composing with `runTicks` (a
54
+ * schedule for tick 500 fires exactly once the sim has been driven
55
+ * through tick 500, regardless of burst size). A digital `true` produces
56
+ * a genuine `isJustPressed` edge exactly at the target tick. Throws
57
+ * `INPUT_ACTION_NOT_FOUND`/a valueType mismatch (same as
58
+ * `setVirtualAction`) or `TICK_ALREADY_PASSED` (`data.currentTick`, the
59
+ * NEXT tick to be serviced) for a tick that already elapsed.
60
+ *
61
+ * A scheduled tick whose input phase never runs at all (that world
62
+ * paused/frozen, or skipped by a multi-tick gap, when the target tick
63
+ * would have been serviced) DROPS the actuation rather than applying it
64
+ * late — it is never delivered, and there is no `{delivered: false,
65
+ * reason}`-shaped result to read here (the call already returned, long
66
+ * before the drop happens). The only observable trace is an
67
+ * `'input.schedule.dropped'` debug event (`{tick, action}`), readable via
68
+ * `state()`/`stateAll()`'s `events` or `snapshot().events`.
69
+ */
70
+ scheduleActionAtTick(
71
+ tick: number,
72
+ action: string,
73
+ value: boolean | number | { x: number; y: number },
74
+ worldId?: string,
75
+ ): void;
76
+ /** Start (or restart) recording the post-gate action-delta trace, readable
77
+ * back via `state('input.trace')`/`stateAll()` (a built-in provider —
78
+ * itself resolved against the DEFAULT world only; see that provider's own
79
+ * doc comment in `debug-registry.ts`). */
80
+ startRecording(worldId?: string): void;
81
+ /** Stop recording — the trace accumulated so far stays readable. */
82
+ stopRecording(worldId?: string): void;
83
+ /** Whether a recording is currently active. */
84
+ isRecording(worldId?: string): boolean;
85
+ /** Accumulate a synthetic pointer delta for a named test source (the
86
+ * "injected test input" seam): multiple calls within the same frame SUM, and the accumulator clears
87
+ * each frame (`endFrame`). Bindings with `valueType: 'pointerDelta'`
88
+ * reading this source (`{ type: 'test_pointer_delta', sourceId }`) see the
89
+ * accumulated value via `getPointerDelta`. */
90
+ injectPointerDelta(sourceId: string, delta: { x: number; y: number }, worldId?: string): void;
91
+ /** Set a synthetic absolute pointer position for a named test source:
92
+ * LAST-WRITE-WINS across
93
+ * contributing sources on read, and persists until changed. Bindings with
94
+ * `valueType: 'pointerPosition'` reading this source (`{ type:
95
+ * 'test_pointer_position', sourceId }`) see it via `getPointerPosition`. */
96
+ injectPointerPosition(sourceId: string, value: { x: number; y: number }, worldId?: string): void;
97
+ }
98
+
99
+ /** `snapshot()`'s return shape — ONE synchronous pass over the registry, so
100
+ * every field reflects the exact same instant (AC-B1.2's batched-read
101
+ * primitive: a probe polling this never sees `time` from one tick and
102
+ * `state` from another). */
103
+ export interface VgaiDebugSnapshot {
104
+ /** `loopLiveness` (issue #175): the REAL `GameLoop.liveness` behind this
105
+ * session — `'loop-starved'` when no recent host rAF progress was observed,
106
+ * `null` only when no loop is wired at all (a bare debug-registry test
107
+ * stand-in with no real `Game`). Never a fabricated `'running'` and never
108
+ * a conclusion about tab visibility. */
109
+ time: { simSeconds: number; tick: number; loopLiveness: GameLoopLiveness | null };
110
+ state: Record<string, unknown>;
111
+ events: TickStampedEvent[];
112
+ pageErrors: string[];
113
+ }
114
+
115
+ /** The frozen `window.__vgai` shape (D18/spec §3.4) — version it if it ever
116
+ * needs a breaking change; `providers`/`state`/`stateAll`/`commands`/`events`
117
+ * are straight off the game's `DebugAdapter`, `invoke` re-checks D18 gating
118
+ * (belt-and-braces, AC-A1.7), and `input`/`snapshot` are bridge-only. */
119
+ export interface VgaiDebugHandle {
120
+ readonly version: 1;
121
+ providers: DebugAdapter['providers'];
122
+ state: DebugAdapter['state'];
123
+ stateAll: DebugAdapter['stateAll'];
124
+ commands: DebugAdapter['commands'];
125
+ invoke(name: string, args: unknown[]): Promise<unknown>;
126
+ events: DebugAdapter['events'];
127
+ input: VgaiDebugInputHandle;
128
+ /** See `DebugAdapter.events`'s doc comment for `sinceSeq`'s contract
129
+ * (run-4 friction #5) — `snapshot`'s `events` member is filtered the exact
130
+ * same way, just batched with `time`/`state`/`pageErrors` into one
131
+ * synchronous read. */
132
+ snapshot(sinceSeq?: number): VgaiDebugSnapshot;
133
+ /**
134
+ * D15/T-D15.4 door (a): synchronously drive `n` fixed gameplay ticks via the
135
+ * live `Game`'s `GameInternal.runTicks` (`runtime/game.ts`) — reached through
136
+ * `DebugRegistry.getRunTicksTarget()`, the SAME target the editor relay's
137
+ * `run-ticks` case (→ `play.runTicks`, door b) calls, so behavior is
138
+ * byte-identical across every door (D17). Throws `DEBUG_RUN_TICKS_UNAVAILABLE`
139
+ * if no `Game` has wired a target yet (no world mounted); throws whatever
140
+ * `runTicks` itself throws otherwise (e.g. `RUN_TICKS_PAUSED`) — never a
141
+ * silent no-op.
142
+ */
143
+ runTicks(n: number, opts?: RunTicksOptions): void;
144
+ /**
145
+ * The SETTLED-AWARE variant of {@link runTicks} (`runtime/run-ticks-settled.ts` — one
146
+ * implementation with the editor relay's `run-ticks` case, D17): drives the budget one tick
147
+ * at a time and, before each tick, waits for every registered world-settled probe
148
+ * (`DebugRegistry.registerWorldSettledProbe`, declared by the game's `debug.settled` entry
149
+ * export) — so a tick never races a scene remount's async commit, and WHICH tick first runs
150
+ * a freshly reloaded world is a function of the sim rather than wall timing. Session tick
151
+ * drivers (`@vgai/live`'s fastForward) prefer this door; with no probe registered it behaves
152
+ * exactly like {@link runTicks}. Bounded: throws `WORLD_UNSETTLED_TIMEOUT` if a probe never
153
+ * settles.
154
+ */
155
+ runTicksSettled(n: number, opts?: RunTicksOptions): Promise<void>;
156
+ /**
157
+ * Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
158
+ * REAL sim time (real ticks — deliberately NOT `runTicks`/fast-forward;
159
+ * this is the honest human-input-path proof) → `input.clearVirtualActions()`
160
+ * into ONE async bridge call, as a TOP-LEVEL method (not under `input`)
161
+ * because it needs the sim clock, not just the input target. Was 15+
162
+ * transport round trips over the editor relay (`@vgai/live`'s old
163
+ * `GameInput.hold`: set → a 150ms-interval `waitSimTime` snapshot poll loop
164
+ * → clear); now one `page.evaluate`/relay call that runs the wait
165
+ * in-process on the page.
166
+ *
167
+ * Resolves the world's input target exactly like the other `input.*`
168
+ * methods (`worldId` omitted → the same default-world resolution every
169
+ * door on this seam shares). A gated actuation (the initial
170
+ * `setVirtualAction` reports `delivered:false`) clears immediately and
171
+ * resolves that SAME `{delivered:false, reason}` shape WITHOUT waiting —
172
+ * matching `setVirtualAction`'s own gated-result contract. If the live
173
+ * game's sim clock stalls while waiting (play stopped/paused mid-hold),
174
+ * the action is still cleared (never left stuck) and this resolves
175
+ * `{delivered:false, reason:'play stopped during hold'}` instead of
176
+ * hanging forever. Throws `DEBUG_INPUT_UNAVAILABLE`/
177
+ * `DEBUG_INPUT_WORLD_NOT_FOUND` up front, same as `input.setVirtualAction`,
178
+ * when no target is wired/resolvable at all.
179
+ */
180
+ holdFor(
181
+ action: string,
182
+ simSeconds: number,
183
+ worldId?: string,
184
+ ): Promise<{ delivered: boolean; reason?: string }>;
185
+ /**
186
+ * Defect 5 fix — undo everything THIS install did: remove the
187
+ * `error`/`unhandledrejection` window listeners it added, and delete
188
+ * `window.__vgai` so the registry is no longer reachable from the page.
189
+ * Idempotent (a second call is a harmless no-op). `mountManifestRoots`
190
+ * wires this into its session's `stop()`; a caller mounting more
191
+ * directly (a hand-rolled host) should call it on its own teardown path.
192
+ */
193
+ uninstall(): void;
194
+ }
195
+
196
+ /** Structural `window` surface this module needs — just enough to publish
197
+ * the handle and listen for page errors, typed narrowly (no `any`, no DOM
198
+ * lib dependency beyond what every other runtime module already assumes). */
199
+ export interface DebugBridgeWindowTarget {
200
+ addEventListener?(
201
+ type: 'error' | 'unhandledrejection',
202
+ listener: (event: { message?: string; error?: unknown; reason?: unknown }) => void,
203
+ ): void;
204
+ /** Defect 5 fix — the installer's `uninstall()` calls this (when present)
205
+ * to remove the exact listener references it added via `addEventListener`
206
+ * above. Optional, like `addEventListener`, so a minimal test/host stub
207
+ * that never needs to uninstall can omit it. */
208
+ removeEventListener?(
209
+ type: 'error' | 'unhandledrejection',
210
+ listener: (event: { message?: string; error?: unknown; reason?: unknown }) => void,
211
+ ): void;
212
+ [key: string]: unknown;
213
+ }
214
+
215
+ export interface MaybeInstallDebugBridgeOptions {
216
+ /** The game-scoped registry (`getDebugRegistry(session.game)`) — its
217
+ * `.adapter` backs every read/invoke method on the published handle, and
218
+ * its `getVirtualInputTarget()` backs `input.*`. */
219
+ readonly registry: DebugRegistry;
220
+ readonly manifest: DebugBridgeManifest;
221
+ /** Where to read `?vgai-debug=1` from. Defaults to `window.location` when a
222
+ * real `window` exists; a headless caller with no `window` at all (and no
223
+ * override) gets no bridge — there's nowhere to read a URL from. */
224
+ readonly url?: { readonly search: string } | undefined;
225
+ /** Where to publish the handle and install the page-error listeners.
226
+ * Defaults to the real `window`; override in a unit test to avoid
227
+ * touching (or requiring) the global object. */
228
+ readonly window?: DebugBridgeWindowTarget | undefined;
229
+ }
230
+
231
+ const PAGE_ERROR_CAP = 100;
232
+
233
+ /** `holdFor`'s in-process poll interval — this loop never leaves the page
234
+ * (no transport round trip per poll, unlike `@vgai/live`'s old
235
+ * `waitSimTime`), so it can afford to be tighter than that loop's 150ms. */
236
+ const HOLD_FOR_POLL_MS = 50;
237
+
238
+ /** Consecutive `HOLD_FOR_POLL_MS` polls with the tick unchanged before
239
+ * `holdFor` gives up waiting and treats the game as stopped — mirrors
240
+ * `@vgai/live`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
241
+ * a genuinely frozen sim clock must never poll forever), scaled to this
242
+ * faster in-process interval so the wall-clock grace period (~5s) lands in
243
+ * the same neighborhood. */
244
+ const HOLD_FOR_STALL_POLL_LIMIT = 100;
245
+
246
+ /** Maximum ticks driven per synchronous starved-loop batch. Sized like
247
+ * `fast-forward.ts`'s own batching rationale: big enough that per-batch
248
+ * bookkeeping is negligible, small enough that one batch of a complex game's
249
+ * phases stays a short synchronous burst. The actual batch is capped to the
250
+ * hold's remaining fixed-tick budget below; otherwise a one-tick `tap()` in a
251
+ * hidden tab becomes a 60-tick hold and releases only after short gameplay
252
+ * interactions have already completed. */
253
+ const STARVED_DRIVE_BATCH_TICKS = 60;
254
+
255
+ /** Hard ceiling on starved-drive batches for ONE hold — `STARVED_DRIVE_BATCH_TICKS
256
+ * * this` ≈ 33 sim-minutes. A bound, not a timeout: it exists so a
257
+ * `runTicks` target that advances the tick counter without advancing the sim
258
+ * clock can never spin forever, and it is far past any honest hold. */
259
+ const STARVED_DRIVE_MAX_BATCHES = 2000;
260
+
261
+ /** The message a hold gets when the loop is loop-starved and NOTHING can
262
+ * drive it — the one case where the platform genuinely prevents the verb
263
+ * from working. Names the cause and the fix rather than expiring into a
264
+ * generic stall (which reported the wrong cause: "play stopped during
265
+ * hold"). One exported constant so both doors and their tests read the same
266
+ * string. */
267
+ export const HOLD_STARVED_NO_DRIVER_REASON =
268
+ "the host loop reported liveness 'loop-starved' and this session has no way to drive " +
269
+ 'ticks — no recent rAF progress was observed. Check `vgai status` for the separate ' +
270
+ 'visibility readings; foreground/reload the editor, or start play to wire the run-ticks ' +
271
+ 'target, then retry.';
272
+
273
+ /** What `adapter.state('time')` returns for the two fields this loop reads,
274
+ * plus the liveness the hidden path branches on. */
275
+ interface HoldClockReading {
276
+ simSeconds: number;
277
+ tick: number;
278
+ loopLiveness?: GameLoopLiveness | null;
279
+ /** Present for every real Game-backed debug registry. Optional only because a bare adapter
280
+ * stand-in can omit it; that case drives one conservative tick at a time. */
281
+ fixedDt?: number;
282
+ }
283
+
284
+ /**
285
+ * Resolves once `simSeconds` of sim time has elapsed since the call, or once
286
+ * the wait provably cannot make progress. Never rejects. Exported (not just
287
+ * used by `holdFor` below) so the editor relay's `command-listener.ts`
288
+ * `holdFor` case can share the EXACT same logic against its own
289
+ * `DebugAdapter` (reached via `getActiveSystems().debug` rather than a
290
+ * `DebugRegistry`) — D17: byte-identical behavior across doors, not two
291
+ * hand-copies that can silently drift apart.
292
+ *
293
+ * Two regimes, and the split is the whole point:
294
+ *
295
+ * - **Visible**: the host loop is ticking on its own, so this polls it every
296
+ * `HOLD_FOR_POLL_MS` and gives up after `HOLD_FOR_STALL_POLL_LIMIT`
297
+ * consecutive unchanged-tick polls (`stalled: true` — play stopped/paused).
298
+ *
299
+ * - **Loop-starved**: no recent host rAF progress is observable, so sim time
300
+ * only moves reliably if this drives it. It therefore drives
301
+ * the WHOLE remaining budget in synchronous batches, yielding a MICROTASK
302
+ * between them — never a timer. That distinction is load-bearing, not
303
+ * stylistic: a hidden tab clamps `setTimeout` to ~1s (and to ~1/minute under
304
+ * Chrome's intensive throttling after 5 minutes hidden), so the previous
305
+ * "drive 3 ticks, then `setTimeout(poll, 50)`" shape ran the sim at ~3
306
+ * ticks per SECOND. A 1.2s hold needed ~24 clamped turns — ~24s of wall
307
+ * clock against the editor relay's 5s per-command budget, which is exactly
308
+ * how a hold that passes on a foregrounded tab died with a generic
309
+ * "editor connected but did not respond" on a backgrounded one. Microtasks
310
+ * are not throttled, so the hidden path now costs sim-work time and nothing
311
+ * else.
312
+ *
313
+ * The regime is re-read every iteration, so an rAF chain that resumes or
314
+ * becomes starved mid-hold crosses over without restarting the budget.
315
+ *
316
+ * `starvedWithoutDriver: true` is the one honest refusal: loop-starved with
317
+ * no `driveStarvedTicks` hook means nothing in this process can advance the
318
+ * clock, so it reports that IMMEDIATELY (see
319
+ * {@link HOLD_STARVED_NO_DRIVER_REASON}) instead of burning the stall guard
320
+ * and then blaming a stopped game.
321
+ */
322
+ export async function waitForHoldBudget(
323
+ adapter: DebugAdapter,
324
+ simSeconds: number,
325
+ driveStarvedTicks?: (n: number) => void,
326
+ ): Promise<{ stalled: boolean; starvedWithoutDriver?: boolean }> {
327
+ const readTime = () => adapter.state('time') as HoldClockReading;
328
+ const start = readTime();
329
+ let lastTick = start.tick;
330
+ let stalledPolls = 0;
331
+ let starvedBatches = 0;
332
+
333
+ for (;;) {
334
+ const current = readTime();
335
+ if (current.simSeconds - start.simSeconds >= simSeconds) return { stalled: false };
336
+
337
+ if (current.loopLiveness === 'loop-starved') {
338
+ if (!driveStarvedTicks) return { stalled: true, starvedWithoutDriver: true };
339
+ if (starvedBatches >= STARVED_DRIVE_MAX_BATCHES) return { stalled: true };
340
+ starvedBatches += 1;
341
+ const remaining = simSeconds - (current.simSeconds - start.simSeconds);
342
+ const fixedDt = current.fixedDt;
343
+ // A real Game publishes its fixed timestep, so drive exactly the number of whole ticks
344
+ // still needed (up to the throughput cap). Subtract a tiny ratio tolerance before `ceil`:
345
+ // repeated floating-point additions can leave an integer tick budget a few ulps above its
346
+ // mathematical value, and that must not manufacture one extra held-input frame. A bare
347
+ // adapter that does not publish `fixedDt` takes the conservative path: one tick, re-read,
348
+ // repeat. Exact input duration matters more than batching a non-Game test stand-in.
349
+ const ticksForRemaining =
350
+ fixedDt !== undefined && Number.isFinite(fixedDt) && fixedDt > 0
351
+ ? Math.max(1, Math.ceil(remaining / fixedDt - 1e-9))
352
+ : 1;
353
+ driveStarvedTicks(Math.min(STARVED_DRIVE_BATCH_TICKS, ticksForRemaining));
354
+ const after = readTime();
355
+ // A batch that moved neither the tick counter nor the sim clock means
356
+ // the drive is a no-op (paused game, torn-down runtime) — the same
357
+ // "nothing is advancing" verdict the visible path's stall guard
358
+ // reaches, just observable in one batch instead of a hundred polls.
359
+ if (after.tick === current.tick && after.simSeconds === current.simSeconds) {
360
+ return { stalled: true };
361
+ }
362
+ lastTick = after.tick;
363
+ stalledPolls = 0;
364
+ // Yield so the page can service other work between batches, WITHOUT
365
+ // handing control to the same timer lane the browser may be starving.
366
+ await Promise.resolve();
367
+ continue;
368
+ }
369
+
370
+ stalledPolls = current.tick === lastTick ? stalledPolls + 1 : 0;
371
+ lastTick = current.tick;
372
+ if (stalledPolls >= HOLD_FOR_STALL_POLL_LIMIT) return { stalled: true };
373
+ await new Promise<void>((resolve) => setTimeout(resolve, HOLD_FOR_POLL_MS));
374
+ }
375
+ }
376
+
377
+ function hasRealWindow(): boolean {
378
+ return typeof window !== 'undefined';
379
+ }
380
+
381
+ function isDebugModeRequested(url: { readonly search: string }): boolean {
382
+ return new URLSearchParams(url.search).get(DEBUG_MODE_QUERY_PARAM) === '1';
383
+ }
384
+
385
+ /** D18's gate: a dev build, or a production build the manifest explicitly
386
+ * opted in. Read fresh on every call (not cached at install time) so
387
+ * `invoke()`'s belt-and-braces re-check (AC-A1.7) is a genuine second
388
+ * evaluation, not a rubber stamp of a value computed once at install. */
389
+ function isDebugAllowed(manifest: DebugBridgeManifest): boolean {
390
+ return Boolean(import.meta.env?.DEV) || manifest.debug?.allowInProduction === true;
391
+ }
392
+
393
+ function buildDebugHandle(opts: {
394
+ registry: DebugRegistry;
395
+ manifest: DebugBridgeManifest;
396
+ window: DebugBridgeWindowTarget;
397
+ }): VgaiDebugHandle {
398
+ const { registry, manifest } = opts;
399
+ const adapter = registry.adapter;
400
+
401
+ const pageErrors: string[] = [];
402
+ function pushPageError(message: string): void {
403
+ pageErrors.push(message);
404
+ if (pageErrors.length > PAGE_ERROR_CAP) pageErrors.shift();
405
+ }
406
+ // Named (not inline-anonymous) so `uninstall()` below can pass the exact
407
+ // same reference to `removeEventListener` (Defect 5 fix).
408
+ const onWindowError = (event: { message?: string; error?: unknown }) => {
409
+ pushPageError(event.message || String(event.error));
410
+ };
411
+ const onWindowRejection = (event: { reason?: unknown }) => {
412
+ pushPageError(`Unhandled promise rejection: ${String(event.reason)}`);
413
+ };
414
+ opts.window.addEventListener?.('error', onWindowError);
415
+ opts.window.addEventListener?.('unhandledrejection', onWindowRejection);
416
+
417
+ function requireInputTarget(method: string, worldId?: string) {
418
+ const target = registry.getVirtualInputTarget(worldId);
419
+ if (!target) {
420
+ throw new DebugError(
421
+ 'DEBUG_INPUT_UNAVAILABLE',
422
+ `debug bridge: ${method}() has no virtual-input target wired — no root ` +
423
+ "entry exports `debug.input` (the game's own input door), or none has mounted yet",
424
+ );
425
+ }
426
+ return target;
427
+ }
428
+
429
+ return {
430
+ version: 1,
431
+ // None of these `DebugAdapter` methods reference `this` (they close over
432
+ // the registry's own private maps) — passing the references directly is
433
+ // safe and avoids five redundant wrapper closures.
434
+ providers: adapter.providers,
435
+ state: adapter.state,
436
+ stateAll: adapter.stateAll,
437
+ commands: adapter.commands,
438
+ events: adapter.events,
439
+ async invoke(name, args) {
440
+ // Belt-and-braces D18 re-check (AC-A1.7): the outer install gate
441
+ // already required this to be true, but a caller could hold onto this
442
+ // handle across an environment/manifest change — refuse loudly rather
443
+ // than trust a decision made once at install time.
444
+ if (!isDebugAllowed(manifest)) {
445
+ throw new DebugError(
446
+ 'DEBUG_COMMANDS_UNSUPPORTED',
447
+ `debug: command "${name}" invocation refused — production build without ` +
448
+ 'debug.allowInProduction (D18)',
449
+ { name },
450
+ );
451
+ }
452
+ return adapter.invoke(name, args);
453
+ },
454
+ input: {
455
+ setVirtualAction: (action, value, worldId) =>
456
+ requireInputTarget('setVirtualAction', worldId).setVirtualAction(action, value),
457
+ tapVirtualAction: (action, worldId) =>
458
+ requireInputTarget('tapVirtualAction', worldId).tapVirtualAction(action),
459
+ clearVirtualActions: (worldId) =>
460
+ requireInputTarget('clearVirtualActions', worldId).clearVirtualActions(),
461
+ scheduleActionAtTick: (tick, action, value, worldId) =>
462
+ requireInputTarget('scheduleActionAtTick', worldId).scheduleActionAtTick(
463
+ tick,
464
+ action,
465
+ value,
466
+ ),
467
+ startRecording: (worldId) =>
468
+ requireInputTarget('startRecording', worldId).startInputRecording(),
469
+ stopRecording: (worldId) => requireInputTarget('stopRecording', worldId).stopInputRecording(),
470
+ isRecording: (worldId) => requireInputTarget('isRecording', worldId).isInputRecording(),
471
+ injectPointerDelta: (sourceId, delta, worldId) =>
472
+ requireInputTarget('injectPointerDelta', worldId).injectPointerDelta(sourceId, delta),
473
+ injectPointerPosition: (sourceId, value, worldId) =>
474
+ requireInputTarget('injectPointerPosition', worldId).injectPointerPosition(sourceId, value),
475
+ },
476
+ snapshot(sinceSeq) {
477
+ // One synchronous pass — see VgaiDebugSnapshot's doc comment.
478
+ return {
479
+ time: adapter.state('time') as {
480
+ simSeconds: number;
481
+ tick: number;
482
+ loopLiveness: GameLoopLiveness | null;
483
+ },
484
+ state: adapter.stateAll(),
485
+ events: adapter.events(sinceSeq),
486
+ pageErrors: pageErrors.slice(),
487
+ };
488
+ },
489
+ runTicks(n, runTicksOpts) {
490
+ const target = registry.getRunTicksTarget();
491
+ if (!target) {
492
+ throw new DebugError(
493
+ 'DEBUG_RUN_TICKS_UNAVAILABLE',
494
+ 'debug bridge: runTicks() has no run-ticks target wired — no Game has mounted yet',
495
+ );
496
+ }
497
+ target.runTicks(n, runTicksOpts);
498
+ },
499
+ runTicksSettled(n, runTicksOpts) {
500
+ return runTicksWhenSettled(registry, n, runTicksOpts);
501
+ },
502
+ async holdFor(action, simSeconds, worldId) {
503
+ const target = requireInputTarget('holdFor', worldId);
504
+ const setResult = target.setVirtualAction(action, true);
505
+ if (!setResult.delivered) {
506
+ target.clearVirtualActions();
507
+ // `exactOptionalPropertyTypes`: don't write an explicit `reason:
508
+ // undefined` when the gate itself didn't supply one — omit the key
509
+ // entirely rather than assign `undefined` into an optional `string`
510
+ // property.
511
+ return setResult.reason !== undefined
512
+ ? { delivered: false, reason: setResult.reason }
513
+ : { delivered: false };
514
+ }
515
+ const runTicksTarget = registry.getRunTicksTarget();
516
+ const { stalled, starvedWithoutDriver } = await waitForHoldBudget(
517
+ adapter,
518
+ simSeconds,
519
+ runTicksTarget ? (n) => runTicksTarget.runTicks(n, { render: 'last' }) : undefined,
520
+ );
521
+ target.clearVirtualActions();
522
+ if (!stalled) return { delivered: true };
523
+ return {
524
+ delivered: false,
525
+ reason: starvedWithoutDriver ? HOLD_STARVED_NO_DRIVER_REASON : 'play stopped during hold',
526
+ };
527
+ },
528
+ uninstall() {
529
+ opts.window.removeEventListener?.('error', onWindowError);
530
+ opts.window.removeEventListener?.('unhandledrejection', onWindowRejection);
531
+ delete opts.window['__vgai'];
532
+ },
533
+ };
534
+ }
535
+
536
+ /**
537
+ * Install `window.__vgai` iff the page opted in (`?vgai-debug=1`) AND D18's
538
+ * gate passes (dev build, or manifest `debug.allowInProduction`). When the
539
+ * param is present but the gate fails, warns once (this single call IS the
540
+ * "once" — there is exactly one install attempt per boot) and installs
541
+ * nothing. Returns the installed handle (mostly useful for tests), or
542
+ * `undefined` when nothing was installed.
543
+ */
544
+ export function maybeInstallDebugBridge(
545
+ opts: MaybeInstallDebugBridgeOptions,
546
+ ): VgaiDebugHandle | undefined {
547
+ const url = opts.url ?? (hasRealWindow() ? window.location : undefined);
548
+ if (!url) return undefined;
549
+ if (!isDebugModeRequested(url)) return undefined;
550
+
551
+ const windowTarget =
552
+ opts.window ?? (hasRealWindow() ? (window as unknown as DebugBridgeWindowTarget) : undefined);
553
+ if (!windowTarget) return undefined;
554
+
555
+ if (!isDebugAllowed(opts.manifest)) {
556
+ // biome-ignore lint/suspicious/noConsole: structured, greppable D18 signal — mirrors debug-registry.ts's own console.warn idiom.
557
+ console.warn(
558
+ '[debug] ?vgai-debug=1 ignored: production build without debug.allowInProduction (D18)',
559
+ );
560
+ return undefined;
561
+ }
562
+
563
+ const handle = buildDebugHandle({
564
+ registry: opts.registry,
565
+ manifest: opts.manifest,
566
+ window: windowTarget,
567
+ });
568
+ windowTarget['__vgai'] = handle;
569
+ return handle;
570
+ }