@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The play-state control surface over whatever `{ ingest }` root is live —
3
+ * the one seam `components/PlayBar.tsx` and the CLI relay
4
+ * (`ingest/ingest-play-commands.ts`) drive ▶/⏸/Step through.
5
+ *
6
+ * Reads the single session slot (`active-ingest.ts`) and nothing else; the
7
+ * mount paths call {@link setIngestPlaying} and the teardown path calls
8
+ * {@link resetIngestPlaySurface}.
9
+ */
10
+
11
+ import { gameContractEpoch } from '../host/coverage/game-contract-seam-evidence';
12
+ import { recordLiveSeamEvidence } from '@volter/editor-core/coverage/live-seam-evidence';
13
+ import { editorConsole } from '@volter/editor-core/editor-console';
14
+ import { activateLiveDocument } from '@volter/editor-core/live-document';
15
+ import type { VgaiGameContract } from '@volter/editor-project/adapter/ingest/game-contract';
16
+ import { activeIngest, type IngestLifecycleControl } from './active-ingest';
17
+ import { readIngestGameContract } from './game-contract-realm';
18
+
19
+ /**
20
+ * The play-state control surface for the CURRENT ingest session (F17/F21):
21
+ * mounts are COLD by doctrine — the adapter sets `window.__vgaiMountCold`
22
+ * before the game's entry executes, and a session-driven game may defer its
23
+ * side-effects (backend connection, narrative, audio) until the first
24
+ * `play()`. Everything is contract-first (game-contract.ts): `lifecycle.start`
25
+ * is called AT MOST ONCE per mount — later ▶ presses resume rather than
26
+ * re-start, so games never need an idempotent start;
27
+ * Mounting normalizes `lifecycle.pause`/`resume` or the substrate's real
28
+ * `setPaused` fallback into ONE {@link IngestLifecycleControl}. This module's
29
+ * runtime consumers never choose between raw mechanisms, and a malformed
30
+ * half-pair never borrows its missing half from the fallback. Games that
31
+ * declare no `start` still get the pre-contract wire event.
32
+ */
33
+ export interface IngestPlayControl {
34
+ readonly playing: boolean;
35
+ play(): void;
36
+ pause(): void;
37
+ resume(): void;
38
+ /**
39
+ * True only when the loop gate actually holds this game's
40
+ * frames (`ingest/same-realm-loop-gate.ts`), which is the only condition
41
+ * under which a single-frame step is a real thing rather than a button that
42
+ * lies.
43
+ */
44
+ readonly canStep: boolean;
45
+ /** Advance exactly one frame batch and hold again. No-op when `canStep` is false. */
46
+ step(): void;
47
+ }
48
+
49
+ let _ingestPlaying = false;
50
+
51
+ /** Whether the live session's loop was last told to run (the status facet's
52
+ * `playing`). */
53
+ export function ingestPlaying(): boolean {
54
+ return _ingestPlaying;
55
+ }
56
+
57
+ /**
58
+ * THE one write to the ingest play state.
59
+ *
60
+ * An ingested game's input is gated by the same mechanisms a first-party play
61
+ * session's is (the engine's `InputManager.setEnabled` and `gated-globals.ts`'s
62
+ * lexical window/document shadow), driven by the input gate the mount installs.
63
+ */
64
+ export function setIngestPlaying(playing: boolean): void {
65
+ _ingestPlaying = playing;
66
+ }
67
+
68
+ /**
69
+ * F26: the play surface's state is session-global (one active session,
70
+ * whatever its kind), so EVERY per-kind exit path resets it — the playing
71
+ * flag plus the cold-mount/played window globals the entry paths set.
72
+ */
73
+ export function resetIngestPlaySurface(): void {
74
+ setIngestPlaying(false);
75
+ const w = window as unknown as { __vgaiMountCold?: boolean; __vgaiIngestPlayed?: boolean };
76
+ delete w.__vgaiMountCold;
77
+ delete w.__vgaiIngestPlayed;
78
+ }
79
+
80
+ /**
81
+ * F26 helper: the active session's kind + its own honest pause capability
82
+ * (D-V1: dispatches on the single slot, never a per-kind global).
83
+ * `null` when nothing is ingested.
84
+ */
85
+ export interface IngestLifecycleFallback {
86
+ readonly setPaused?: ((paused: boolean) => void) | undefined;
87
+ readonly pauseGap?: string | undefined;
88
+ readonly step?: (() => void) | undefined;
89
+ readonly canStep?: (() => boolean) | undefined;
90
+ }
91
+
92
+ function runContractLifecycleEffect(
93
+ contract: VgaiGameContract,
94
+ member: 'start' | 'pause' | 'resume',
95
+ run: () => void,
96
+ ): void {
97
+ const receipt = (outcome: 'pass' | 'fail', detail: string): void =>
98
+ recordLiveSeamEvidence({
99
+ seam: `contract.lifecycle.${member}`,
100
+ subject: 'game',
101
+ epoch: gameContractEpoch(contract),
102
+ stage: 'effect',
103
+ outcome,
104
+ source: 'consumer',
105
+ detail,
106
+ });
107
+ try {
108
+ run();
109
+ receipt('pass', `the mounted ingest lifecycle consumed lifecycle.${member}`);
110
+ } catch (error) {
111
+ receipt(
112
+ 'fail',
113
+ `lifecycle.${member} failed: ${error instanceof Error ? error.message : String(error)}`,
114
+ );
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Normalize raw lifecycle declarations and a substrate fallback ONCE, at
121
+ * mount. The declared pause/resume pair wins as one indivisible capability;
122
+ * a half-pair is malformed and may never silently mix with the fallback.
123
+ * Step belongs only to the fallback that actually took the hold.
124
+ */
125
+ export function bindIngestLifecycle(
126
+ fallback: IngestLifecycleFallback = {},
127
+ ): IngestLifecycleControl {
128
+ const contract = readIngestGameContract();
129
+ const declared = contract?.lifecycle;
130
+ const start = () => {
131
+ if (contract && declared?.start) {
132
+ runContractLifecycleEffect(contract, 'start', () => declared.start?.());
133
+ return;
134
+ }
135
+ window.dispatchEvent(new CustomEvent('vgai:ingest-play'));
136
+ };
137
+
138
+ const hasPause = typeof declared?.pause === 'function';
139
+ const hasResume = typeof declared?.resume === 'function';
140
+ if (contract && hasPause !== hasResume) {
141
+ return {
142
+ start,
143
+ pauseGap: hasPause
144
+ ? 'the game contract declares lifecycle.pause without lifecycle.resume'
145
+ : 'the game contract declares lifecycle.resume without lifecycle.pause',
146
+ };
147
+ }
148
+ if (contract && hasPause && hasResume) {
149
+ return {
150
+ start,
151
+ setPaused: (paused) =>
152
+ runContractLifecycleEffect(contract, paused ? 'pause' : 'resume', () => {
153
+ if (paused) declared?.pause?.();
154
+ else declared?.resume?.();
155
+ }),
156
+ };
157
+ }
158
+ if (fallback.setPaused) {
159
+ return {
160
+ start,
161
+ setPaused: fallback.setPaused,
162
+ ...(fallback.step ? { step: fallback.step } : {}),
163
+ ...(fallback.canStep ? { canStep: fallback.canStep } : {}),
164
+ };
165
+ }
166
+ return {
167
+ start,
168
+ pauseGap:
169
+ fallback.pauseGap ??
170
+ 'the game declares no lifecycle.pause/resume pair and its mounted adapter exposes no setPaused capability',
171
+ };
172
+ }
173
+
174
+ export function activeIngestPauseGap(): string | null {
175
+ const active = activeIngest();
176
+ if (!active) return null;
177
+ return active.session.lifecycle.pauseGap ?? null;
178
+ }
179
+
180
+ /**
181
+ * D-B2 (Slice B): fan a pause/resume state out to
182
+ * every mounted composite sibling's OPTIONAL `SiblingMount.setPaused`
183
+ * (`ingest-siblings.ts`), AFTER the primary surface's own pause/resume
184
+ * already ran — mirrors `exitActiveIngest`'s reverse-order dispose
185
+ * fan-out (the same "one bad sibling never breaks the rest"
186
+ * isolation: per-sibling try/catch, loud log on failure, never swallowed
187
+ * silently). A sibling with no `setPaused` at all (the bare `default-react`
188
+ * sibling, D-B1 — it has no loop to gate) is silently skipped via the
189
+ * optional call — never an error, never a fabricated pause. A no-op (empty
190
+ * `siblings`, or no live session at all) for every non-composite entry
191
+ * path, unchanged.
192
+ */
193
+ function fanOutSiblingPause(paused: boolean): void {
194
+ const active = activeIngest();
195
+ if (!active) return;
196
+ for (const sibling of active.siblings) {
197
+ try {
198
+ sibling.setPaused?.(paused);
199
+ } catch (err) {
200
+ editorConsole.error(`Composite sibling setPaused(${paused}) failed: ${err}`, 'ingest');
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The DECLARED contract for whatever is ingested right now.
207
+ *
208
+ * The game runs in the editor's own realm and declares on the host window —
209
+ * either from its own entry, or from the host-added module the manifest names
210
+ * in `ingest.contractShim`, which the mount imports first.
211
+ */
212
+ export function activeIngestContract(): VgaiGameContract | null {
213
+ return readIngestGameContract();
214
+ }
215
+
216
+ export function getIngestPlayControl(): IngestPlayControl | null {
217
+ const lifecycle = activeIngest()?.session.lifecycle;
218
+ if (!lifecycle?.setPaused) return null;
219
+ /** HOLD content time through the mount-normalized lifecycle capability. */
220
+ const hold = () => {
221
+ lifecycle.setPaused?.(true);
222
+ fanOutSiblingPause(true);
223
+ setIngestPlaying(false);
224
+ };
225
+ /** RELEASE through the exact same normalized capability that took the hold. */
226
+ const release = () => {
227
+ lifecycle.setPaused?.(false);
228
+ fanOutSiblingPause(false);
229
+ setIngestPlaying(true);
230
+ };
231
+ return {
232
+ get playing() {
233
+ return _ingestPlaying;
234
+ },
235
+ play() {
236
+ // ▶ BRINGS THE GAME DOCUMENT TO THE FRONT. A boot-mounted ingest lands
237
+ // in Edit on purpose (`ingest-boot-viewport.ts`), so without this the
238
+ // one gesture that starts the game left the person on the authoring
239
+ // Scene — for a root whose content exists only under its own loop, the
240
+ // "No renderable content" card, with the game running unseen behind it
241
+ // (walk 3, beat 19: `playState: "playing"`, `activeViewportTab: "edit"`,
242
+ // one canvas in the page). `live-document.ts` owns that activation, and
243
+ // going through it is also what writes the play tab the input gate
244
+ // reads — so the game is drivable and not merely visible. A play-time
245
+ // mount already did this for itself (`deferred-ingest-play.ts`).
246
+ activateLiveDocument();
247
+ // Late-constructed game clients must not re-defer: the flag records
248
+ // that play already happened this session — and makes ▶-after-⏸ a
249
+ // RESUME, so `lifecycle.start` fires at most once per mount.
250
+ const w = window as unknown as { __vgaiIngestPlayed?: boolean };
251
+ if (w.__vgaiIngestPlayed) return release();
252
+ w.__vgaiIngestPlayed = true;
253
+ lifecycle.start();
254
+ // A mount ends HELD (see `ingestContentTimeForMode`), so the first ▶ is
255
+ // ALSO the release of that hold — same path every later ▶ takes.
256
+ release();
257
+ },
258
+ pause: hold,
259
+ resume: release,
260
+ canStep: !!lifecycle.step && (lifecycle.canStep?.() ?? true),
261
+ step() {
262
+ if (lifecycle.canStep?.() === false) return;
263
+ lifecycle.step?.();
264
+ setIngestPlaying(false);
265
+ },
266
+ };
267
+ }
268
+
269
+ /**
270
+ * THE hold/release decision for an ingested game's content time — pure, so the
271
+ * rule is checkable without a browser, a mount or a clock.
272
+ *
273
+ * The rule is the ontology's, not this lane's: LOADING CONSTRUCTS, PLAY RUNS
274
+ * (ARCHITECTURE-CORE). Content time follows the editor's MODE and nothing else
275
+ * — not whether a mount just finished, not how far a game got into its own
276
+ * boot. A mount is simply the first moment this decision is evaluated, which is
277
+ * why "mount in Edit" needs no case of its own.
278
+ */
279
+ export function ingestContentTimeForMode(mode: 'edit' | 'play'): 'held' | 'released' {
280
+ return mode === 'edit' ? 'held' : 'released';
281
+ }
282
+
283
+ /**
284
+ * Apply {@link ingestContentTimeForMode} to the live session, through the play
285
+ * control's OWN pause path — so a game that declares `lifecycle.pause` is held
286
+ * by its own hand rather than by an outside-in loop gate that may not reach it.
287
+ *
288
+ * Only the HOLD half acts here, because only the hold half has a caller: ▶
289
+ * (`play`/`resume` above) is the one thing that releases, and it is a user
290
+ * action, never something a mode read may fabricate. Returns whether it held,
291
+ * so a caller can log honestly rather than assume.
292
+ */
293
+ export function holdIngestContentTimeForMode(mode: 'edit' | 'play'): boolean {
294
+ if (ingestContentTimeForMode(mode) !== 'held') return false;
295
+ const control = getIngestPlayControl();
296
+ if (!control) return false;
297
+ control.pause();
298
+ return true;
299
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * `renderDebug` for an INGESTED world — the engine-owned auto-registration
3
+ * the first-party mount already does (`setup-three-root-adapter.ts` builds the
4
+ * same wiring under the same real-WebGL2-context guard), reached for a game
5
+ * whose render loop the host does not own.
6
+ *
7
+ * ## Why this is host-owned and not per-game
8
+ *
9
+ * `RenderDebugAdapter` needs three things: a real WebGL2 context, the live scene
10
+ * to attribute draws against, and a bracket around the render pass. A captured
11
+ * ingest mount has all three and the host already holds them — the capture trap
12
+ * (`@volter/threejs-runtime/adapter/ingest/scene-capture`) stands between the game and its own
13
+ * `WebGLRenderer.render`, which is the ONLY place a host can see a
14
+ * self-driven game's pass begin and end. So no game declares this slot: it is
15
+ * derived from the mount, exactly like the first-party path, and a game that
16
+ * declared one would be shimming what the host can measure.
17
+ *
18
+ * ## Why the game's own `three` instance is not a problem here
19
+ *
20
+ * `dev/render-debug-adapter.ts` never uses `instanceof`: it duck-types
21
+ * renderables (`isMesh`/`isPoints`/…) and patches `onBeforeRender` on the
22
+ * objects themselves. `dev/render-memory.ts` is the same shape
23
+ * (`isTexture`/`isBufferGeometry` flags, numeric format enums), so neither
24
+ * depends on the captured objects being the host's own classes.
25
+ *
26
+ * Returns `null` — the honest absence, which leaves the Frame debugger showing
27
+ * its own "no adapter" notice — whenever the mount has no real WebGL2 context to
28
+ * capture from (a headless harness, a stub renderer).
29
+ *
30
+ * ## The CANVAS lane, and what it can and cannot say
31
+ *
32
+ * The same three things are needed on the Pixi surface, and the mount holds two
33
+ * and a half of them. `webgl-frame-capture.ts` is GL-LEVEL — it shadows the
34
+ * five WebGL2 draw entry points on whatever real context it is handed and knows
35
+ * nothing about three — so it is reused verbatim over the captured Pixi
36
+ * renderer's own `gl`. The bracket is the renderer's own published seam, its
37
+ * `prerender`/`postrender` runners (`@volter/game-runtime/pixi/render-pass-bracket`).
38
+ *
39
+ * What is NOT there is per-draw attribution, and it is absent for a structural
40
+ * reason rather than a missing patch point: three calls `onBeforeRender` on the
41
+ * object it is about to draw, whereas Pixi's batcher merges many display
42
+ * objects into a single draw, so no display object IS the author of a given
43
+ * draw. `createCanvasIngestRenderDebug` therefore omits `scene` and every draw
44
+ * lands in `totals.unattributed` — the Frame debugger reads that count and says
45
+ * so, which is the honest answer and not a degraded one.
46
+ *
47
+ * Render memory is absent on this lane for the same kind of reason.
48
+ * `RenderMemorySnapshot` is a WHOLE-snapshot shape with non-nullable geometry
49
+ * counts and byte totals; Pixi's renderer publishes one real managed collection
50
+ * (`renderer.texture.managedTextures`) and no managed geometry or program
51
+ * collection at all, so filling the geometry half with zeros is exactly the
52
+ * fabrication the anti-shim rule forbids. The capability is therefore ABSENT
53
+ * (`memorySnapshot` undefined), which `frame-debugger-model.ts` already reports
54
+ * per-capability and the Profiler's memory view already renders as its own
55
+ * notice.
56
+ */
57
+
58
+ import { adapterObservations } from '@volter/editor-core/adapter-observation';
59
+ import { createContractDebugAdapter } from '@volter/game-runtime/adapter/ingest/contract-debug-adapter';
60
+ import {
61
+ type ContractSurface,
62
+ projectContractSystemAdapters,
63
+ } from '@volter/game-runtime/adapter/ingest/contract-system-adapters';
64
+ import { mergeDebugAdapters } from '@volter/game-runtime/adapter/ingest/merge-debug-adapters';
65
+ import { createObservationDebugAdapter } from '@volter/game-runtime/adapter/ingest/observation-debug-adapter';
66
+ import {
67
+ createRenderDebugAdapter,
68
+ frameCaptureContextFor,
69
+ type RenderDebugWiring,
70
+ } from '@volter/game-runtime/dev/render-debug-adapter';
71
+ import { collectRenderMemory } from '@volter/game-runtime/dev/render-memory';
72
+ import { createWebGLFrameCapture } from '@volter/game-runtime/dev/webgl-frame-capture';
73
+ import { pixiRenderingContext } from '@volter/game-runtime/pixi/render-pass-bracket';
74
+ import type { ObservationDeclaration } from '@volter/editor-project/adapter/adapter-module';
75
+ import type { VgaiGameSystems } from '@volter/editor-project/adapter/ingest/game-contract';
76
+ import type { SystemAdapters } from '@volter/editor-project/adapter/system-adapter';
77
+ import type { RenderPassHooks } from '@volter/threejs-runtime/adapter/ingest/scene-capture';
78
+ import type * as THREE from 'three';
79
+ import { ingestGameRealmWindow } from './game-contract-realm';
80
+ import {
81
+ deliverArmedIngestFrame,
82
+ type IngestFrameSource,
83
+ setIngestFrameSource,
84
+ } from './ingest-frame-snapshot';
85
+
86
+ /** The pieces of a captured runtime this needs. Structural so a headless test
87
+ * can hand in a mock GL context and a plain scene, exactly as the first-party
88
+ * render-debug suite does. */
89
+ export interface IngestRenderDebugRuntime {
90
+ readonly scene: THREE.Object3D;
91
+ readonly renderer: {
92
+ getContext?(): unknown;
93
+ readonly info?: THREE.WebGLInfo | undefined;
94
+ /** Read by the same-frame snapshot only (`ingest-frame-snapshot.ts`); a
95
+ * runtime without them simply has no snapshot capability. */
96
+ readonly domElement?: unknown;
97
+ getRenderTarget?(): unknown;
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build the render-debug wiring for a captured ingest runtime, or `null` when
103
+ * the mount cannot host frame capture. The caller installs
104
+ * `beforeRender`/`afterRender` as the capture handle's render-pass hooks and
105
+ * calls `dispose()` on unmount.
106
+ */
107
+ export function createIngestRenderDebug(rt: IngestRenderDebugRuntime): RenderDebugWiring | null {
108
+ // Reads a renderer the host did not construct: an unfamiliar/stub shape must
109
+ // degrade to "no adapter", never break the mount.
110
+ let glContext: unknown;
111
+ try {
112
+ glContext = typeof rt.renderer.getContext === 'function' ? rt.renderer.getContext() : undefined;
113
+ } catch {
114
+ return null;
115
+ }
116
+ // `headless: false` — this path only runs for a mount that actually rendered a
117
+ // frame; `frameCaptureContextFor` is the ONE predicate deciding whether the
118
+ // context is real, shared with the first-party mount so both sides agree.
119
+ const context = frameCaptureContextFor(false, glContext);
120
+ if (!context) return null;
121
+ const info = rt.renderer.info;
122
+ return createRenderDebugAdapter({
123
+ capture: createWebGLFrameCapture(context),
124
+ scene: rt.scene,
125
+ // `memorySnapshot` is the W3a-style OPTIONAL capability: attached only when
126
+ // the renderer exposes its own `info`, absent otherwise (never a zeroed
127
+ // snapshot standing in for a count nobody read).
128
+ ...(info ? { memory: () => collectRenderMemory(rt.scene, info) } : {}),
129
+ });
130
+ }
131
+
132
+ /** The pieces of a captured CANVAS runtime this needs — just the game's own
133
+ * Pixi renderer, because the two things the three shape carries beyond it
134
+ * (a traversable scene, a `renderer.info`) have no honest Pixi analogue (see
135
+ * the module header). Structural, so a headless test hands in a plain object
136
+ * with a mock `gl` and a pair of runners. */
137
+ export interface CanvasIngestRenderDebugRuntime {
138
+ readonly renderer: unknown;
139
+ }
140
+
141
+ /**
142
+ * Build the render-debug wiring for a captured PIXI ingest runtime, or `null`
143
+ * when the mount cannot host frame capture — no real WebGL2 context (a WebGPU
144
+ * or headless renderer), or a renderer that publishes no render-pass runners to
145
+ * bracket. Both are the same honest absence the three lane's `null` is.
146
+ *
147
+ * The caller installs `beforeRender`/`afterRender` through
148
+ * {@link installPixiRenderPassBracket} and calls `dispose()` on unmount.
149
+ */
150
+ export function createCanvasIngestRenderDebug(
151
+ rt: CanvasIngestRenderDebugRuntime,
152
+ ): RenderDebugWiring | null {
153
+ // Reads a renderer the host did not construct: an unfamiliar/stub shape must
154
+ // degrade to "no adapter", never break the mount.
155
+ let glContext: unknown;
156
+ try {
157
+ glContext = pixiRenderingContext(rt.renderer);
158
+ } catch {
159
+ return null;
160
+ }
161
+ // The SAME predicate the three lane uses, so both surfaces agree on what
162
+ // counts as a real context. `headless: false` — this path only runs for a
163
+ // mount whose game already rendered a capturable frame.
164
+ const context = frameCaptureContextFor(false, glContext);
165
+ if (!context) return null;
166
+ return createRenderDebugAdapter({
167
+ capture: createWebGLFrameCapture(context),
168
+ // No `scene`: this surface has no per-draw annotation seam, so every draw
169
+ // is honestly unattributed. No `memory`: see the module header.
170
+ });
171
+ }
172
+
173
+ /** What a three ingest mount publishes to `setActiveSystems`, plus the
174
+ * render-debug handle its `dispose()` has to release. */
175
+ export interface IngestSystemsWiring {
176
+ /** Spread onto the `MountedThreeRoot`. Empty when nothing was reachable —
177
+ * the caller omits `systems` entirely then, keeping the pre-contract floor. */
178
+ readonly systems: SystemAdapters;
179
+ /** Non-null only when a real frame capture was built; `dispose()` on unmount. */
180
+ readonly renderDebug: RenderDebugWiring | null;
181
+ /**
182
+ * One sentence per name BOTH debug feeders declared — the game's own contract
183
+ * and its adapter's observation table (`merge-debug-adapters.ts`). Empty is
184
+ * the norm; non-empty is a defect in whichever of the two the reader owns, and
185
+ * the mount says so out loud rather than leaving the coded refusal to be
186
+ * discovered by whoever calls the name first.
187
+ */
188
+ readonly debugCollisions: readonly string[];
189
+ }
190
+
191
+ /**
192
+ * THE one place an ingest mount's `SystemAdapters` bag is assembled — on EITHER
193
+ * surface — so a shim never has to know anything about the mount, and no second
194
+ * assembly can drift into publishing a different surface from the same
195
+ * declaration.
196
+ *
197
+ * Four sources, each with a different provenance:
198
+ * - `debug` — projected from the contract's own `commands`/`state`;
199
+ * - `debug`, AGAIN — projected from the loaded ADAPTER's observation
200
+ * declarations (`@volter/game-runtime/adapter/ingest/observation-debug-adapter`).
201
+ * These two are peers onto ONE slot, so they are MERGED
202
+ * (`merge-debug-adapters.ts`) rather than layered: a name declared by both
203
+ * is refused by a coded error naming both sources, never shadowed;
204
+ * - the game-declarable slots — projected from its `systemAdapters` carrier
205
+ * (an IMPLEMENTED-EMPTY slot is deliberately NOT bound; it is an answer the
206
+ * coverage report reads, not an adapter);
207
+ * - `renderDebug` — engine-owned, derived from the captured runtime, and wired
208
+ * to that surface's render-pass bracket.
209
+ *
210
+ * The observation half is read HERE rather than threaded in by each mount, and
211
+ * that is the point of "the one place": the adapter's table is a PROJECT fact
212
+ * the loader already resolved (`project-adapter.ts` loads it per project, not
213
+ * per mount), so a lane cannot forget to pass it. Both parameters exist only as
214
+ * a test seam.
215
+ *
216
+ * `runtime` and `canvasRuntime` are the SAME source in two surface shapes, and
217
+ * are mutually exclusive: a mount has captured a three renderer or a Pixi one,
218
+ * never both. They stay separate types rather than one widened type because the
219
+ * three shape's `scene`/`info` have no honest Pixi analogue — a union that
220
+ * merely made them optional would let a three mount silently lose its
221
+ * attribution. What they share is this function and the `setRenderPassHooks`
222
+ * slot; the MECHANISM behind that slot is each surface's own (the render
223
+ * accessor-trap for three, the renderer's runners for Pixi).
224
+ */
225
+ export function wireIngestSystems(opts: {
226
+ readonly contractSystems: VgaiGameSystems | undefined;
227
+ /**
228
+ * THIS MOUNT'S SURFACE, stated rather than inferred from which runtime is
229
+ * present — the recognized-Phaser/Babylon canvas branch captures neither
230
+ * renderer and would infer nothing. The projection needs it to check the
231
+ * physics slot's keying (`contract-system-adapters.ts` header).
232
+ */
233
+ readonly surface: ContractSurface;
234
+ /** Absent when there is no captured THREE renderer to bracket. */
235
+ readonly runtime?: IngestRenderDebugRuntime | undefined;
236
+ /** Absent when there is no captured PIXI renderer to bracket. */
237
+ readonly canvasRuntime?: CanvasIngestRenderDebugRuntime | undefined;
238
+ /**
239
+ * The mount's OWN resolved physics carrier, which REPLACES the projected
240
+ * `physics` slot when supplied.
241
+ *
242
+ * The canvas mount is the case and the reason: the carrier its gizmo path
243
+ * actually uses is the declared one COMPOSED with the host's registry-backed
244
+ * one (`mount-canvas-ingest-root.ts`), and the bag must publish that same
245
+ * object — a coverage row reading `bound` off a different instance than the
246
+ * editor calls would be true about nothing. Absent ⇒ the projection's own
247
+ * slot stands, which is every other mount.
248
+ */
249
+ readonly physics?: SystemAdapters['physics'] | undefined;
250
+ readonly setRenderPassHooks?: ((hooks: RenderPassHooks | null) => void) | undefined;
251
+ /**
252
+ * TEST SEAM. Omitted — every shipped mount — this is the loaded adapter's own
253
+ * table, which is what makes the projection impossible for a lane to forget.
254
+ */
255
+ readonly observation?: readonly ObservationDeclaration[] | undefined;
256
+ /**
257
+ * The mounted game handle an observation `answer` is evaluated against.
258
+ *
259
+ * Omitted — every shipped mount — it is THIS REALM: an ingest mount runs the
260
+ * game's own modules in its gated realm, so the game's public handles are on
261
+ * that realm's window proxy (not the editor tab's real `window`) and that is
262
+ * the only honest thing to hand a foreign game's closure
263
+ * (`observation-debug-adapter.ts` states the whole argument). Passed
264
+ * explicitly only by tests, which hand a fake realm.
265
+ */
266
+ readonly game?: unknown;
267
+ }): IngestSystemsWiring {
268
+ const contractDebug = createContractDebugAdapter(opts.contractSystems);
269
+ const observationDebug = createObservationDebugAdapter(
270
+ opts.observation ?? adapterObservations(),
271
+ 'game' in opts ? opts.game : typeof window === 'undefined' ? null : ingestGameRealmWindow(),
272
+ );
273
+ const { adapter: debug, collisions } = mergeDebugAdapters([
274
+ { label: "the game's own contract (window.vgaiGame.systems)", adapter: contractDebug },
275
+ { label: "its adapter's observation table (vgai.adapter.ts)", adapter: observationDebug },
276
+ ]);
277
+ const declared = projectContractSystemAdapters(opts.contractSystems, opts.surface);
278
+ const renderDebug = opts.runtime
279
+ ? createIngestRenderDebug(opts.runtime)
280
+ : opts.canvasRuntime
281
+ ? createCanvasIngestRenderDebug(opts.canvasRuntime)
282
+ : null;
283
+ // ONE bracket, TWO consumers. `setRenderPassHooks` is a single-writer slot
284
+ // (last writer wins), so this is the only place that may install it, and
285
+ // anything needing the game's render pass multiplexes here rather than
286
+ // opening a second seam. The second consumer is the same-frame canvas
287
+ // snapshot (`ingest-frame-snapshot.ts`) that makes `vgai screenshot` readable
288
+ // over a game whose canvas has no `preserveDrawingBuffer`; it costs nothing
289
+ // until armed, and `deliverArmedIngestFrame` no-ops while no source is set —
290
+ // which is the canvas lane's state, because a Pixi ingest is already
291
+ // photographable through its own registered `Application`
292
+ // (`stories/story-pixi-preview.ts`, registered at the canvas mount) and needs
293
+ // no second route to the same pixels.
294
+ if (opts.runtime) setIngestFrameSource(opts.runtime as IngestFrameSource);
295
+ if (opts.setRenderPassHooks && (renderDebug || opts.runtime)) {
296
+ opts.setRenderPassHooks({
297
+ // Neither hook may throw — they run inside the game's own render call.
298
+ before: () => {
299
+ renderDebug?.beforeRender();
300
+ },
301
+ after: () => {
302
+ try {
303
+ renderDebug?.afterRender();
304
+ } finally {
305
+ deliverArmedIngestFrame();
306
+ }
307
+ },
308
+ });
309
+ }
310
+ return {
311
+ systems: {
312
+ ...declared.bound,
313
+ ...(opts.physics ? { physics: opts.physics } : {}),
314
+ ...(debug ? { debug } : {}),
315
+ ...(renderDebug ? { renderDebug: renderDebug.adapter } : {}),
316
+ },
317
+ renderDebug,
318
+ debugCollisions: collisions,
319
+ };
320
+ }