@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,897 @@
1
+ /**
2
+ * IngestRootAdapter — the {@link RootAdapter} for an UNMODIFIED external three.js
3
+ * game. Its `mount()` is the host-and-ingest path: install the render accessor
4
+ * trap on the shared `three`, run the unmodified game (its own renderer/scene/
5
+ * camera/rAF), capture its live scene on the first frame, and return a
6
+ * {@link MountedThreeRoot} whose `authoring` is the three authoring adapter
7
+ * over that live `Object3D` tree, parameterized by what the captured graph
8
+ * MEASURES as: a world carrying serve-time source stamps gets OID identity plus
9
+ * JSX write-back ({@link oidSourceThree}), one without them keeps
10
+ * structural-path identity and creation-site persistence
11
+ * ({@link structuralThree}). See {@link countStampedObjects} for why that
12
+ * is a measurement rather than a manifest field. `drivesOwnLoop` is true — the
13
+ * host does not tick it.
14
+ *
15
+ * This is the runtime half of the Phase-B inversion: an unmodified game becomes a
16
+ * peer implementer of the SAME `RootAdapter`/`MountedThreeRoot` contract first-party
17
+ * content uses — no separate editor data model.
18
+ */
19
+
20
+ import { authoringOidOf } from '@volter/editor-core/authoring/component-instance-root';
21
+ import { setIngestDataWriter } from '../../host/authoring/ingest-data-writer';
22
+ import { editorConsole } from '@volter/editor-core/editor-console';
23
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
24
+ import { GAME_SURFACE_CONTAINMENT_CSS } from '../../host/game-realm-page';
25
+ import { clearGameSurface, gameLoopGate, setGameSurface } from '../../host/gated-globals';
26
+ import { authoringJournal } from '../../host/history/json-history-resource';
27
+ import { clearPresentationSurface, recordPresentationSurface } from '@volter/editor-core/presentation-surface';
28
+ import { getCurrentProject } from '@volter/editor-core/project-manager';
29
+ import { clearRootReadiness, recordRootReadiness } from '@volter/editor-core/readiness';
30
+ import {
31
+ type SameRealmLoopGate,
32
+ type SameRealmLoopVerdict,
33
+ verifySameRealmLoopControl,
34
+ } from '../../host/same-realm-loop-gate';
35
+ import { ensureScopedGameStyles } from '@volter/editor-core/scoped-game-css';
36
+ import { resolveThreeIngestRuntimeForEditor } from '../../host/three-ingest-runtime';
37
+ import type { OidEntry } from '@volter/editor-core/ui-source/oid-transform';
38
+ import { createHttpSourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
39
+ import { serverRecordsSourceWrites } from '@volter/editor-core/ui-source/tier-source-write-backend';
40
+ import { clearWorldAdoption, worldAdoptionRecorder } from '@volter/editor-core/world-adoption';
41
+ import { markGameCssScope } from '@volter/editor-sdk/session/game-css-scope';
42
+ import type { MountedThreeRoot } from '@volter/editor-project/adapter';
43
+ import {
44
+ readGamePresentation,
45
+ readGameReady,
46
+ readGameWorld,
47
+ } from '@volter/editor-project/adapter/ingest/game-contract';
48
+ import { describeMountFailure } from '@volter/editor-project/adapter/ingest/mount-readiness';
49
+ import { formatLoopGateMessage } from '@volter/editor-project/adapter/loop-gate-report';
50
+ import {
51
+ oidSourceThree,
52
+ structuralThree,
53
+ type ThreeAuthoringAdapter,
54
+ } from '../../three/authoring/three-authoring-adapter';
55
+ import { isHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
56
+ import {
57
+ type CapturedThreeRenderer,
58
+ installSceneCapture,
59
+ type SceneCaptureHandle,
60
+ } from '@volter/threejs-runtime/adapter/ingest/scene-capture';
61
+ // TYPES ONLY. Every VALUE this module needs from `three` — the namespace the
62
+ // capture trap is installed on, its `DefaultLoadingManager`, and the two addon
63
+ // renderer classes — comes from `resolveThreeIngestRuntimeForEditor()` instead,
64
+ // because under the packaged runtime the shell's copy is not the one the
65
+ // ingested game's own modules resolve. See `../three-ingest-runtime.ts`.
66
+ import type * as THREE from 'three';
67
+ import { setCaptureWait } from '../capture-wait-report';
68
+ import { DOM_STUB_MARK } from '../dom-stub-mark';
69
+ import { ingestGameRealmWindow, readIngestGameContract } from '../game-contract-realm';
70
+ import { installGamePointerLockGate, releaseGamePointerLock } from '../game-pointer-lock';
71
+ import { claimHostSurfaceBox, hostSurfaceBackingSize } from '../host-surface-box';
72
+ import { clearIngestFrameSource } from '../ingest-frame-snapshot';
73
+ import { wireIngestSystems } from '../ingest-render-debug';
74
+ import type { IngestGame } from '../types';
75
+
76
+ export interface MountIngestOptions {
77
+ /**
78
+ * How long to wait for the game's first captured frame before the mount FAILS
79
+ * by name. A game that needs a long boot raises it.
80
+ */
81
+ captureTimeoutMs?: number | undefined;
82
+ }
83
+
84
+ /** A mounted ingest game plus the host-side handles the editor needs to tear down. */
85
+ export interface IngestMount {
86
+ game: MountedThreeRoot;
87
+ /** The live-scene authoring adapter. */
88
+ authoring: ThreeAuthoringAdapter;
89
+ hostEl: HTMLElement;
90
+ /** Live capture stats (draw count, child count) for proof/telemetry. */
91
+ capture: SceneCaptureHandle;
92
+ /** The captured scene (== game.scene). */
93
+ scene: THREE.Scene;
94
+ /**
95
+ * The measured loop verdict, read LIVE (a function, not a snapshot — the
96
+ * probe re-runs at every pause, so a frozen value would start lying the
97
+ * moment the game's shape changed); `null` before the first pause, because a
98
+ * verdict is a measurement and there has not been one yet.
99
+ */
100
+ realmLoopVerdict?: (() => SameRealmLoopVerdict | null) | undefined;
101
+ }
102
+
103
+ /**
104
+ * D10/T7.6 loop-gate honesty check, extracted for direct unit testing
105
+ * builds the `setPaused` a mounted
106
+ * ingest game exposes to `Game.play.pause()`/`resume()` — separate from
107
+ * the authoring adapter's own edit-time `loop.pause()`/`loop.resume()`
108
+ * (gizmo freeze/unfreeze), which always calls through regardless of whether
109
+ * the loop is genuinely gateable.
110
+ *
111
+ * S-5 (the SimCity ingest ledger) rewrote both halves of this.
112
+ *
113
+ * COVERAGE. Pausing used to be `loop.pause()` alone — `setAnimationLoop(null)`,
114
+ * which reaches exactly one loop driver. SimCity renders through
115
+ * `setAnimationLoop` and SIMULATES through `setInterval(this.simulate, 1000)`,
116
+ * so pause froze the picture while the city kept building (measured: the game
117
+ * still advanced after the gate reported success). A pause now also HOLDS the
118
+ * same-realm scheduling gate (`../ingest/same-realm-loop-gate.ts`), which the
119
+ * dev-server prelude has shadowed into every game module.
120
+ *
121
+ * HONESTY. The old check asked `capture.getAnimationLoop(renderer) !== null` —
122
+ * whether the game had ever DECLARED a loop through the renderer — and warned
123
+ * only when it had not. That is a declaration, not a measurement, and it is why
124
+ * a gate that controlled none of SimCity's simulation still "reported success".
125
+ * Every pause now runs the probe (`verifySameRealmLoopControl`): control is
126
+ * claimed only when something was actually withheld, nothing gated ran, and the
127
+ * game renderer's own frame counter did not advance. Anything else warns loudly
128
+ * with the measured reason.
129
+ */
130
+ export function createIngestLoopGate(
131
+ capture: Pick<SceneCaptureHandle, 'getAnimationLoop' | 'getDrawCount'>,
132
+ renderer: CapturedThreeRenderer,
133
+ worldId: string,
134
+ loop: { pause(): void; resume(): void },
135
+ opts: {
136
+ /** The same-realm scheduling gate (`gated-globals.ts`) — `null` when none
137
+ * is installed, which the probe reports as `self-driven`. Injected so this
138
+ * is unit-testable with a fake gate and no browser. */
139
+ realmGate?: SameRealmLoopGate | null;
140
+ /** Called with the MEASURED verdict after each pause. */
141
+ onVerdict?: (verdict: SameRealmLoopVerdict) => void;
142
+ verifyWindowMs?: number;
143
+ sleep?: (ms: number) => Promise<void>;
144
+ } = {},
145
+ ): { setPaused(paused: boolean): void; lastVerdict(): SameRealmLoopVerdict | null } {
146
+ let lastVerdict: SameRealmLoopVerdict | null = null;
147
+ return {
148
+ setPaused(paused: boolean) {
149
+ if (!paused) {
150
+ loop.resume();
151
+ // Release the scheduling gate LAST so a parked simulation tick never
152
+ // runs against a renderer that has not been handed its loop back.
153
+ opts.realmGate?.release();
154
+ return;
155
+ }
156
+ // S-5: pausing is BOTH halves. `setAnimationLoop(null)` stops the game's
157
+ // rendering; the same-realm gate parks the scheduling its simulation runs
158
+ // on. Freezing only the first is what let a "gated" SimCity keep building
159
+ // its city — its sim tick is a bare `setInterval`, which
160
+ // `setAnimationLoop(null)` does not touch and the old declaration-only
161
+ // check (`getAnimationLoop(renderer) !== null`) never even looked at.
162
+ loop.pause();
163
+ opts.realmGate?.hold();
164
+ // MEASURE, never assume — the honesty gate. A verdict is reported for
165
+ // every pause, including the `self-driven` ones the old check could not
166
+ // produce (it only ever warned about a game that never called
167
+ // `setAnimationLoop`, and said nothing at all otherwise).
168
+ void verifySameRealmLoopControl({
169
+ gate: opts.realmGate ?? null,
170
+ progress: () => renderer.info?.render.frame ?? capture.getDrawCount(),
171
+ ...(opts.verifyWindowMs !== undefined ? { windowMs: opts.verifyWindowMs } : {}),
172
+ ...(opts.sleep !== undefined ? { sleep: opts.sleep } : {}),
173
+ }).then((verdict) => {
174
+ lastVerdict = verdict;
175
+ opts.onVerdict?.(verdict);
176
+ if (verdict.loop === 'gated') return;
177
+ // A NEW native console.warn site — suppressed to keep this task's
178
+ // diff at zero NEW lint warnings (same reasoning as
179
+ // `runtime/game.ts`'s `reportGateShortfallOnce`).
180
+ // biome-ignore lint/suspicious/noConsole: see comment above
181
+ console.warn(
182
+ formatLoopGateMessage({
183
+ worldId,
184
+ reason: capture.getAnimationLoop(renderer)
185
+ ? verdict.reason
186
+ : `${verdict.reason} (this game also never called renderer.setAnimationLoop)`,
187
+ }),
188
+ );
189
+ });
190
+ },
191
+ lastVerdict: () => lastVerdict,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * How many objects in the captured world carry a serve-time source stamp —
197
+ * THE measurement that decides which authoring lane this mount gets.
198
+ *
199
+ * Exported for the unit test that proves the decision is made on the graph
200
+ * rather than on a game id: a stamped scene picks the OID lane, an unstamped
201
+ * one keeps the structural lane.
202
+ */
203
+ export function countStampedObjects(scene: THREE.Object3D): number {
204
+ let stamped = 0;
205
+ scene.traverse((object) => {
206
+ if (authoringOidOf(object) !== undefined) stamped++;
207
+ });
208
+ return stamped;
209
+ }
210
+
211
+ /**
212
+ * The same count, given a bounded window to become true.
213
+ *
214
+ * A ONE-SHOT count taken the instant `waitForSceneToSettle` returns is the
215
+ * wrong instrument, and it decorrelates exactly where it matters. That helper
216
+ * watches `scene.children.length`, so a React world sitting in a Suspense
217
+ * fallback while its GLBs stream reads as "settled" with its authored tree not
218
+ * yet committed — every object unstamped, and the mount silently takes the
219
+ * structural lane for the rest of its life. Measured on racing-game, whose
220
+ * WARM boot took the OID lane (`r3f:<world>:<oid>` rows, 101 of 107 objects
221
+ * source-addressable) and whose COLD boot on the same bytes took the
222
+ * structural one (`ingest:0:Mesh:` rows, nothing writable) — the same game,
223
+ * the same source, a different instant.
224
+ *
225
+ * So the count gets a window. It returns the moment a stamp appears, which is
226
+ * immediately for a world that already committed; only a world with none pays
227
+ * the full budget, and that is the case where the answer genuinely is not
228
+ * knowable yet.
229
+ */
230
+ export async function countStampedObjectsWhenCommitted(
231
+ scene: THREE.Object3D,
232
+ maxMs = 2500,
233
+ sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
234
+ ): Promise<number> {
235
+ const start = performance.now();
236
+ for (;;) {
237
+ const stamped = countStampedObjects(scene);
238
+ if (stamped > 0) return stamped;
239
+ if (performance.now() - start >= maxMs) return 0;
240
+ await sleep(100);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Resolve one project-relative path against a project root, POSIX-style.
246
+ *
247
+ * Spelled out rather than borrowed from `node:path` (this runs in the browser)
248
+ * or from `new URL(rel, 'file://…')` (which percent-encodes, so the result stops
249
+ * comparing equal to the raw absolute paths the OID index carries). `..` in a
250
+ * declared path is the ORDINARY case for a repo-vendored fixture — racing-game's
251
+ * root declares its world as `../../../../../../vendor/games/racing-game/src/App.tsx`
252
+ * — so resolving it is the whole job.
253
+ */
254
+ export function resolveProjectPath(projectRoot: string, relative: string): string {
255
+ const rel = relative.replace(/\\/g, '/');
256
+ const base = rel.startsWith('/') ? '' : projectRoot.replace(/\\/g, '/');
257
+ const out: string[] = [];
258
+ for (const segment of `${base}/${rel}`.split('/')) {
259
+ if (segment === '' || segment === '.') continue;
260
+ if (segment === '..') out.pop();
261
+ else out.push(segment);
262
+ }
263
+ return `/${out.join('/')}`;
264
+ }
265
+
266
+ /**
267
+ * The directory trees THIS ingest root's own source lives under — the scope the
268
+ * serve-time index door (below) is allowed to answer from.
269
+ *
270
+ * It is derived from MOUNTING FACTS the manifest already states, never guessed:
271
+ * the project folder itself (a user's own ingested game keeps its source there)
272
+ * plus the parent directory of every source module the root declares
273
+ * (`entry`, `world.entry` — carried on the descriptor as
274
+ * {@link IngestGame.sourceModules}). The second is what covers a repo-vendored
275
+ * game, whose project folder holds only a manifest and a host shim while its
276
+ * actual TSX lives under `vendor/games/<id>/src/` — measured on racing-game,
277
+ * where all 41 stamped files sit outside the project folder.
278
+ */
279
+ export function ingestSourceRoots(
280
+ projectRoot: string | null | undefined,
281
+ sourceModules: readonly string[] | undefined,
282
+ ): string[] {
283
+ if (!projectRoot) return [];
284
+ const root = projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
285
+ const roots = new Set<string>([root]);
286
+ for (const module of sourceModules ?? []) {
287
+ const resolved = resolveProjectPath(root, module);
288
+ const dir = resolved.slice(0, resolved.lastIndexOf('/'));
289
+ if (dir) roots.add(dir);
290
+ }
291
+ return [...roots];
292
+ }
293
+
294
+ /**
295
+ * THE SERVE-TIME DOOR: did the OID transform stamp any of THIS root's own
296
+ * source, according to the index the dev server built while serving it?
297
+ *
298
+ * `GET /__ui-source/index` (`vite-plugin-ui-oid.ts`) records exactly what the
299
+ * transform stamped, and it is populated at TRANSFORM time — before the module
300
+ * is evaluated, and therefore long before any object exists to carry a stamp.
301
+ * That is precisely the ordering the graph door cannot have: on a project's
302
+ * first-ever cold mount (fresh Vite transform, streaming GLBs) fiber has not
303
+ * committed yet when the graph is counted, the count is 0, and the mount used
304
+ * to freeze on the structural lane for the whole session while every warm mount
305
+ * on the same bytes healed to the OID lane.
306
+ *
307
+ * Scoped by {@link ingestSourceRoots} rather than asking "is the index
308
+ * non-empty": a project can serve stamped TSX that has nothing to do with this
309
+ * root (a sibling `dom` root's HUD is the standing case), and answering yes for
310
+ * that would put a plain three.js ingest — whose creation sites really are in
311
+ * its own source — onto the wrong lane.
312
+ */
313
+ export function indexStampsSourceUnder(
314
+ index: Readonly<Record<string, { readonly file?: unknown }>> | null | undefined,
315
+ sourceRoots: readonly string[],
316
+ ): boolean {
317
+ if (!index || sourceRoots.length === 0) return false;
318
+ for (const entry of Object.values(index)) {
319
+ const file = entry?.file;
320
+ if (typeof file !== 'string') continue;
321
+ const clean = file.replace(/\\/g, '/');
322
+ if (sourceRoots.some((root) => clean === root || clean.startsWith(`${root}/`))) return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ /**
328
+ * WHICH LANE, from the two doors — the only place that decision is spelled.
329
+ *
330
+ * OID lane iff EITHER door says yes; structural only on a double no.
331
+ *
332
+ * The graph door stays as the second door on purpose: a warm server restart can
333
+ * serve stamped bytes out of Vite's own cache without repopulating the plugin's
334
+ * in-memory index, and a hosted (non-dev) tier has no index endpoint at all —
335
+ * in both, the stamps on the live objects are the only evidence there is.
336
+ */
337
+ export function chooseIngestAuthoringLane(doors: {
338
+ readonly servedStampedSource: boolean;
339
+ readonly graphStamps: number;
340
+ }): 'oid-source' | 'structural' {
341
+ return doors.servedStampedSource || doors.graphStamps > 0 ? 'oid-source' : 'structural';
342
+ }
343
+
344
+ /**
345
+ * The served OID index, or `null` when THIS SESSION'S HOST serves no
346
+ * `/__ui-source/*` route to ask, or the request fails. Never throws: a door
347
+ * that cannot answer is a `null`,
348
+ * and the graph door still decides.
349
+ *
350
+ * The route, not `import.meta.env.DEV`: the packaged editor serves this index
351
+ * from a PRODUCTION shell bundle, and asking the build flag closed the served
352
+ * door on exactly the tier the index exists for
353
+ * (`../ui-source/tier-source-write-backend.ts`).
354
+ */
355
+ async function readServedOidIndex(): Promise<Record<string, OidEntry> | null> {
356
+ if (!(await serverRecordsSourceWrites())) return null;
357
+ try {
358
+ return (await createHttpSourceWriteBackend().index?.()) ?? null;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Uncaught page errors logged during a game's BOOT WINDOW (M29).
366
+ *
367
+ * Reads the same 'runtime'-source console entries `command-listener.ts`'s
368
+ * `collectPageErrors` does — the window error/unhandledrejection capture
369
+ * `installEditorConsoleCapture` installs — fenced to this mount's own window
370
+ * rather than to a play run, because an ingest mount IS the run. Capped: the
371
+ * point is to NAME the blocker inside one sentence, not to reprint the console
372
+ * (the full text is there, and this list is a pointer to it).
373
+ */
374
+ function bootWindowPageErrors(startedAt: number): string[] {
375
+ return editorConsole
376
+ .getEntries()
377
+ .filter(
378
+ (entry) =>
379
+ entry.level === 'error' && entry.source === 'runtime' && entry.timestamp >= startedAt,
380
+ )
381
+ .slice(-10)
382
+ .map((entry) => (entry.count > 1 ? `${entry.message} (×${entry.count})` : entry.message));
383
+ }
384
+
385
+ /**
386
+ * Wait for the game's world to be BUILT, declaration first.
387
+ *
388
+ * A game that declares `window.vgaiGame.ready` is awaited exactly once and the
389
+ * measured poll never runs — the game states when it is done, so the host has
390
+ * nothing to estimate. Everything else keeps {@link waitForSceneToSettle},
391
+ * which is the MEASURED fallback and is labelled as such wherever readiness is
392
+ * reported (`readiness.ts`).
393
+ *
394
+ * A declared signal that REJECTS does not fail the mount: the world is already
395
+ * captured by this point, so refusing here would throw away a live scene over a
396
+ * boot-completion promise. It degrades to the measured wait, LOUDLY, and the
397
+ * facet then reports this root as `measured` — which is the truth about which
398
+ * answer actually stood.
399
+ */
400
+ async function awaitWorldReady(
401
+ gameId: string,
402
+ scene: THREE.Scene,
403
+ ready: (() => Promise<unknown>) | null,
404
+ ): Promise<'declared' | 'measured'> {
405
+ if (ready) {
406
+ try {
407
+ await ready();
408
+ return 'declared';
409
+ } catch (err) {
410
+ editorConsole.error(
411
+ `Ingest game "${gameId}" declared \`window.vgaiGame.ready\` and it REJECTED (${String(err)}) ` +
412
+ '— the world was already captured, so the host fell back to the measured settle wait. ' +
413
+ 'This root now reports readiness as measured.',
414
+ 'ingest',
415
+ );
416
+ }
417
+ }
418
+ await waitForSceneToSettle(scene);
419
+ return 'measured';
420
+ }
421
+
422
+ /** Wait until the game's async world (e.g. a streamed GLTF) stops growing. */
423
+ async function waitForSceneToSettle(scene: THREE.Scene, maxMs = 5000): Promise<void> {
424
+ const start = performance.now();
425
+ let last = -1;
426
+ let stableTicks = 0;
427
+ return new Promise((resolve) => {
428
+ const tick = () => {
429
+ const n = scene.children.length;
430
+ stableTicks = n === last ? stableTicks + 1 : 0;
431
+ last = n;
432
+ if (stableTicks >= 3 || performance.now() - start > maxMs) return resolve();
433
+ setTimeout(tick, 120);
434
+ };
435
+ tick();
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Settle which element the host adopts into its surface, and put it there.
441
+ *
442
+ * Some games append their canvas to `document.body` — reparent it into the
443
+ * host. A DOM-hybrid game (React/R3F with HTML UI: HUDs, overlay panels, drei
444
+ * `<Html>` portals) can declare the element that OWNS its canvas via
445
+ * `window.vgaiGame.root` (the declared game→host contract, game-contract.ts;
446
+ * `__vgaiGameRoot` is the pre-contract alias). Adopting only the bare canvas
447
+ * would strand that UI at page level over the editor chrome, and following the
448
+ * canvas would require the game to know host layout internals — an arcane
449
+ * demand. Adopt the declared root wholesale so the game's own DOM structure
450
+ * survives intact; games that declare nothing keep the bare-canvas behaviour.
451
+ *
452
+ * THE PAGE IS A LEGITIMATE ANSWER, and it used to kill the mount. A game whose
453
+ * canvas, title wrapper, pause wrapper and game-over overlay are all direct
454
+ * children of the body has no wrapper div to name, so its shim declares
455
+ * `root: document.body` and is right to. The
456
+ * page is an ANCESTOR of our surface, so adopting it is not merely wrong, it
457
+ * throws — `hostEl.appendChild(document.body)` → "The new child element
458
+ * contains the parent" — and the whole mount died there. In-realm, that
459
+ * declaration means "everything I appended to the page", and everything the
460
+ * game appended to the page is already inside the surface, because the realm
461
+ * put it there (game-realm-page.ts). So the surface IS the adopted root, and
462
+ * there is nothing left to reparent or restyle: the host already owns its box.
463
+ *
464
+ * Otherwise the pane sizes the adopted element from here on, so strip whatever
465
+ * page-level positioning the game used while it owned the whole window (S-4:
466
+ * this runs for the BARE-CANVAS case too, which is the case it was missing — a
467
+ * game that declares no root hands us a canvas three already stamped with
468
+ * literal `${w}px`/`${h}px`, and the host's own resizes use
469
+ * `updateStyle:false`, so nothing else would ever write that stamp again).
470
+ */
471
+ function adoptGameDomRoot(hostEl: HTMLElement, surface: HTMLElement): HTMLElement {
472
+ const realmWindow = ingestGameRealmWindow();
473
+ const declaredRoot =
474
+ readIngestGameContract()?.root ??
475
+ (realmWindow as unknown as { __vgaiGameRoot?: unknown }).__vgaiGameRoot;
476
+ const declared =
477
+ declaredRoot instanceof HTMLElement && declaredRoot.contains(surface) ? declaredRoot : surface;
478
+ if (declared.contains(hostEl)) return hostEl;
479
+ // Already nested INSIDE the game's own DOM under the host (a staged page's
480
+ // renderer lives inside its own `#container`, among siblings whose paint
481
+ // order the page designed): the page owns its layout — hoisting the element
482
+ // to the host's end re-stacks it OVER later siblings (measured on
483
+ // css3d_periodictable: the renderer covered the page's own `#menu`, so its
484
+ // TABLE/SPHERE buttons never received a click). Only a DIRECT child gets
485
+ // the host's box claim, and only a DETACHED/outside element is rescued in.
486
+ if (declared.parentElement !== hostEl && hostEl.contains(declared)) return declared;
487
+ claimHostSurfaceBox(declared);
488
+ if (declared.parentElement !== hostEl) hostEl.appendChild(declared);
489
+ return declared;
490
+ }
491
+
492
+ /**
493
+ * Mount an unmodified game and capture its live scene as a `MountedThreeRoot`.
494
+ * `store` is needed by the authoring adapter for shared selection state; the
495
+ * captured objects get stable structural-path ids in the live authoring
496
+ * adapter's projection (never a fabricated descriptor and never a foreign
497
+ * `userData` mutation).
498
+ */
499
+ export async function mountIngestGame(
500
+ store: EditorShellStore,
501
+ game: IngestGame,
502
+ gameContainer: HTMLElement,
503
+ opts: MountIngestOptions = {},
504
+ ): Promise<IngestMount> {
505
+ // The upstream games expect an element with id="container" to mount into.
506
+ const hostEl = document.createElement('div');
507
+ hostEl.id = 'container';
508
+ hostEl.style.cssText = `position:absolute;inset:0;width:100%;height:100%;overflow:hidden;${GAME_SURFACE_CONTAINMENT_CSS}`;
509
+ gameContainer.appendChild(hostEl);
510
+
511
+ // THE GAME'S PAGE IS THIS BOX, from before its first module runs. A game
512
+ // written to own a tab appends its canvas and every overlay to
513
+ // `document.body`; the realm hands it this element instead, and the
514
+ // containment CSS above makes the element the containing block for the
515
+ // `position:fixed` overlays that would otherwise paint over editor chrome.
516
+ // Host policy, not per-game CSS — see `game-realm-page.ts` for the four
517
+ // page-shaped assumptions this covers and why each one breaks an in-realm
518
+ // mount. Registered on the DEFAULT realm because an ingest descriptor's
519
+ // module urls carry no `?vgai-mount=` (binding-resolver.ts's
520
+ // `resolveIngestDescriptor` builds bare `fsImportPath` urls).
521
+ const realmPage = setGameSurface(hostEl);
522
+
523
+ // THE GAME'S OWN PAGE STYLESHEET, contained to this box. `hostEl` IS the
524
+ // game's page in-realm, so it is exactly the right scope root: the sheet's
525
+ // `html`/`body` rules land on it, everything else lands on what the game
526
+ // appends inside it, and the editor's document sees none of it
527
+ // (`scoped-game-css.ts` — the rules are served inside `@scope`). Awaited
528
+ // BEFORE the game's entry runs so the HUD's first paint is already styled.
529
+ // A project that declares no stylesheet mounts exactly as it did before.
530
+ markGameCssScope(hostEl);
531
+ const scopedCssProject = getCurrentProject();
532
+ if (scopedCssProject) await ensureScopedGameStyles(scopedCssProject.rootPath);
533
+
534
+ // DOM-shim: stub the elements a DOM-dependent game expects (e.g. #blocker),
535
+ // so its getElementById calls succeed without editing the game. A stub is
536
+ // MARKED so the served-bundle boot can retire it when the game's own staged
537
+ // page supplies that id (`served-html-boot.ts`): stubs are made before the
538
+ // declared DOM is staged, so this existence check cannot see the real
539
+ // element yet, and a stub left in place shadows it — measured on
540
+ // css3d_periodictable, whose TABLE/SPHERE buttons bound their listeners to
541
+ // invisible stubs while the visible buttons did nothing.
542
+ const stubEls: HTMLElement[] = [];
543
+ for (const id of game.domStubs ?? []) {
544
+ if (document.getElementById(id)) continue;
545
+ const stub = document.createElement('div');
546
+ stub.id = id;
547
+ stub.style.display = 'none';
548
+ stub.dataset[DOM_STUB_MARK] = '';
549
+ hostEl.appendChild(stub);
550
+ stubEls.push(stub);
551
+ }
552
+
553
+ // THE `three` THE GAME'S OWN MODULES WILL RESOLVE — and therefore the ONE
554
+ // whose `WebGLRenderer.prototype` is worth trapping. Under the packaged
555
+ // runtime the game's entry is served as `/@fs/<project>/…` by the
556
+ // PROJECT-rooted Vite (the only Vite there — the editor's shell is a
557
+ // prebuilt static bundle with its own `three` inlined), so a shell namespace
558
+ // traps a class nobody constructs. MEASURED on a packaged build against a
559
+ // ~30-line unmodified three.js game: three.js's own "Multiple instances of
560
+ // Three.js being imported" warning, then the mount failing by name 15s later
561
+ // with "the game never rendered, or it bundles its own (un-shared) copy of
562
+ // three". Resolved ONCE here and handed to everything below; see
563
+ // `../three-ingest-runtime.ts`. Under dev/hosted there is one graph
564
+ // and this is byte-identical to the static imports it replaces.
565
+ const runtime = await resolveThreeIngestRuntimeForEditor();
566
+ const projectThree = runtime.three;
567
+
568
+ // 1. Trap the shared three's renderer (the game's `import 'three'` resolves here).
569
+ // Also pass the shared `EffectComposer` addon ctor so a
570
+ // game's own composer is collected too — resize wiring below (D-C3).
571
+ // `isHostRenderer` keeps the EDITOR's own drawing out of the capture: the
572
+ // trap is on the shared prototype, so our renders reach it too — including
573
+ // the offscreen thumbnail/preview renderers the editor builds on demand.
574
+ const capture: SceneCaptureHandle = installSceneCapture(projectThree, runtime.EffectComposer, {
575
+ isHostRenderer,
576
+ // CSS3DRenderer is a shared Three addon exactly like EffectComposer: the
577
+ // host and an unmodified `three/addons/renderers/CSS3DRenderer.js` import
578
+ // resolve to this one class. Its prototype render carries the same scene +
579
+ // camera pair even though it never touches WebGLRenderer.
580
+ additionalRendererCtors: [runtime.CSS3DRenderer],
581
+ // ZERO INFERENCE, first leg: the game's own contract may name its world, in
582
+ // which case first-render-wins never runs. Read LAZILY — the contract is
583
+ // declared by the game's own modules, which have not executed yet at this
584
+ // line. A malformed declaration is refused by name below rather than
585
+ // silently falling back, so a wrong `world` is attributable.
586
+ declaredScene: () => {
587
+ const reading = readGameWorld(readIngestGameContract());
588
+ if (reading.malformed !== null) {
589
+ editorConsole.error(
590
+ `Ingest game "${game.id}" ${reading.malformed} — falling back to the MEASURED ` +
591
+ 'first-render adoption.',
592
+ 'ingest',
593
+ );
594
+ }
595
+ return reading.world;
596
+ },
597
+ // ZERO INFERENCE, second leg: whichever way the world was adopted, the
598
+ // provenance and every later distinct world become a recorded fact
599
+ // (`world-adoption.ts` → the `worldAdoption` facet), never a silent
600
+ // permanent commitment.
601
+ onWorldAdoption: worldAdoptionRecorder(game.id),
602
+ });
603
+
604
+ // 2. Asset-path adapter: redirect the game's relative asset paths to the served files.
605
+ if (game.assets) {
606
+ const map = game.assets;
607
+ projectThree.DefaultLoadingManager.setURLModifier((url) => {
608
+ for (const key of Object.keys(map)) if (url.includes(key)) return map[key]!;
609
+ return url;
610
+ });
611
+ }
612
+
613
+ // 3. Run the unmodified game; the trap captures on its first rendered frame.
614
+ // Signal COLD MOUNT before the entry executes: editor mounts are for
615
+ // inspection first, so a session-driven game MAY defer its side-effects
616
+ // (backend connection, narrative, audio) until the editor's play control
617
+ // dispatches 'vgai:ingest-play' (getIngestPlayControl, ingest/mount-ingest-root.ts).
618
+ // Rendering must continue regardless — capture needs a frame — and games
619
+ // that ignore the flag behave exactly as before (opt-in, never demanded).
620
+ (window as unknown as { __vgaiMountCold?: boolean }).__vgaiMountCold = true;
621
+ // THE BOOT WINDOW OPENS HERE (M29). Everything the page throws from this
622
+ // instant until the mount resolves belongs to this game's boot, and it is the
623
+ // fact that separates "crashed before ready" from "never became ready" — the
624
+ // two the old single sentence could not tell apart.
625
+ const bootWindowStartedAt = Date.now();
626
+ await game.load();
627
+ const timeoutMs = opts.captureTimeoutMs ?? 10_000;
628
+ // WHO ANSWERS "is it ready" — read once, right after the game's own modules
629
+ // have run and therefore after its contract exists. A declaration makes this
630
+ // root's readiness `declared`; its absence leaves the MEASURED waits below
631
+ // standing, and says so in the facet rather than silently.
632
+ const declaredReady = readGameReady(readIngestGameContract());
633
+ if (declaredReady.malformed !== null) {
634
+ editorConsole.error(
635
+ `Ingest game "${game.id}" ${declaredReady.malformed} — the host is measuring readiness ` +
636
+ 'instead.',
637
+ 'ingest',
638
+ );
639
+ }
640
+ const readinessSource = declaredReady.ready === null ? 'measured' : 'declared';
641
+ recordRootReadiness({
642
+ rootId: game.id,
643
+ mechanism: declaredReady.ready === null ? 'measured-wait' : 'contract-ready',
644
+ source: readinessSource,
645
+ state: 'waiting',
646
+ });
647
+ let rt: Awaited<ReturnType<SceneCaptureHandle['waitForCapture']>>;
648
+ try {
649
+ // The window is VISIBLE time (see `visible-capture-window.ts`): a tab that
650
+ // boots in the background parks here instead of dying, with the trap still
651
+ // installed, so the first frame after the human foregrounds the tab is the
652
+ // captured one. `setCaptureWait` is what keeps that park from reading as a
653
+ // hung mount — `vgai status` names it.
654
+ rt = await capture.waitForCapture({
655
+ timeoutMs,
656
+ onWait: (wait) => setCaptureWait(game.id, wait),
657
+ });
658
+ } catch (err) {
659
+ // Nothing was captured: the game's `three` never reached the host trap, so
660
+ // there is no live scene to author. Tear the host state down and fail by
661
+ // name.
662
+ capture.uninstall();
663
+ projectThree.DefaultLoadingManager.setURLModifier((u) => u);
664
+ for (const el of stubEls) el.remove();
665
+ clearGameSurface();
666
+ hostEl.remove();
667
+ clearWorldAdoption(game.id);
668
+ // M29 — THREE BLOCKERS, THREE SENTENCES. The old single line said "rendered
669
+ // no capturable frame within timeout" for all of them, which pointed a
670
+ // reader at the host's wait when the actual blocker was the game's own
671
+ // async-init throw (it misdirected two sweep measurements). The choice is
672
+ // made from facts already in hand: did anything throw during the boot
673
+ // window, and did the game declare readiness at all.
674
+ const description = describeMountFailure({
675
+ gameId: game.id,
676
+ timeoutMs,
677
+ readinessSource,
678
+ pageErrors: bootWindowPageErrors(bootWindowStartedAt),
679
+ cause: String(err),
680
+ });
681
+ recordRootReadiness({
682
+ rootId: game.id,
683
+ mechanism: declaredReady.ready === null ? 'measured-wait' : 'contract-ready',
684
+ source: readinessSource,
685
+ state: 'failed',
686
+ failure: description.kind,
687
+ });
688
+ throw new Error(description.message);
689
+ }
690
+
691
+ const gameDomRoot = adoptGameDomRoot(hostEl, rt.renderer.domElement);
692
+
693
+ // WHICH CANVAS IS THE GAME'S PICTURE — declaration first. The contract's
694
+ // `presentation` is the game's own statement; absent it, the captured
695
+ // renderer's element is the host's MEASUREMENT (a real read off the render
696
+ // trap, not the DOM-order guess every late reader used to make). Both are
697
+ // recorded with their provenance, which is what lets the screenshot,
698
+ // staleness and canvas-medium readers stop sniffing (`presentation-surface.ts`).
699
+ const declaredPresentation = readGamePresentation(readIngestGameContract());
700
+ if (declaredPresentation.malformed !== null) {
701
+ editorConsole.error(
702
+ `Ingest game "${game.id}" ${declaredPresentation.malformed} — the host is using the ` +
703
+ 'captured renderer’s canvas instead.',
704
+ 'ingest',
705
+ );
706
+ }
707
+ recordPresentationSurface(
708
+ game.id,
709
+ declaredPresentation.canvas ?? rt.renderer.domElement,
710
+ declaredPresentation.canvas ? 'declared' : 'measured',
711
+ );
712
+
713
+ // Pointer lock: a first-person game asks for it on its OWN element (a click
714
+ // handler on its canvas), which no
715
+ // window/document proxy can see. The gate is installed on the element
716
+ // method itself and scoped to registered game surfaces, so the editor
717
+ // viewport's own pointer lock is untouched.
718
+ installGamePointerLockGate();
719
+
720
+ // 4. Let the game finish building its world — its own `ready` when it
721
+ // declares one, the measured settle poll otherwise — then build the
722
+ // authoring adapter.
723
+ const readinessStood = await awaitWorldReady(game.id, rt.scene, declaredReady.ready);
724
+ recordRootReadiness({
725
+ rootId: game.id,
726
+ mechanism: readinessStood === 'declared' ? 'contract-ready' : 'measured-wait',
727
+ source: readinessStood,
728
+ state: 'ready',
729
+ });
730
+
731
+ // Loop gate (begin/endEdit): the scene-capture trap recorded the game's
732
+ // setAnimationLoop callback, so we freeze the game's OWN loop (set null) and
733
+ // resume it (re-set the recorded callback) for stable editing — even of dynamic
734
+ // objects. (Games driving their loop via raw requestAnimationFrame instead of
735
+ // setAnimationLoop have no recorded callback → pause is a no-op for them.)
736
+ const loop = {
737
+ pause: () => rt.renderer.setAnimationLoop?.(null),
738
+ resume: () => {
739
+ const cb = capture.getAnimationLoop(rt.renderer);
740
+ if (cb) rt.renderer.setAnimationLoop?.(cb);
741
+ },
742
+ };
743
+ // The game's own DATA writer, if it declares one — registered for EVERY
744
+ // ingest mount, including as `null`, because setting it is also what takes
745
+ // the previous mount's writer back down (see `ingest-data-writer.ts`
746
+ // §RESOURCE OWNERSHIP). It is only a loader here; nothing imports it until an
747
+ // edit needs it.
748
+ setIngestDataWriter(game.dataWriter ?? null);
749
+
750
+ // WHICH AUTHORING LANE — decided by MEASUREMENT, never by the game's id.
751
+ //
752
+ // A game whose own source the serve-time OID transform reached carries that
753
+ // stamp on the objects fiber constructed from it (`userData.oid`; the
754
+ // transform's R3F dialect emits `userData-oid`, which fiber pierces onto the
755
+ // object). For such a world the JSX callsite IS the object's source address,
756
+ // and the creation-site registry the structural lane writes through is
757
+ // structurally EMPTY — every `new` expression that built it lives in
758
+ // `node_modules`.
759
+ //
760
+ // TWO DOORS, and the SERVE-TIME one is asked first because it is already true
761
+ // at this instant on the coldest boot: the transform populates the index
762
+ // before the module it stamped is even evaluated, while the graph can only
763
+ // answer once fiber has committed. The graph door remains, for the case
764
+ // where no index exists to read (a warm server restart serving cached
765
+ // bytes). Structural only when BOTH say no — see
766
+ // `chooseIngestAuthoringLane`.
767
+ const sourceRoots = ingestSourceRoots(getCurrentProject()?.rootPath, game.sourceModules);
768
+ const servedStampedSource = indexStampsSourceUnder(await readServedOidIndex(), sourceRoots);
769
+ // The graph poll is the SECOND door, so it is only worth paying for when the
770
+ // first said no; a yes has already decided the lane.
771
+ const graphStamps = servedStampedSource ? 0 : await countStampedObjectsWhenCommitted(rt.scene);
772
+ const authoring =
773
+ chooseIngestAuthoringLane({ servedStampedSource, graphStamps }) === 'oid-source'
774
+ ? oidSourceThree(store, rt.scene, {
775
+ worldId: game.id,
776
+ loop,
777
+ camera: rt.camera,
778
+ journal: authoringJournal(game.id),
779
+ })
780
+ : structuralThree(store, rt.scene, {
781
+ loop,
782
+ camera: rt.camera,
783
+ journal: authoringJournal(game.id),
784
+ });
785
+
786
+ // D10/T7.6: the game-level play-state control surface (`Game.play.pause()`)
787
+ // calls THIS `setPaused` — separately from the authoring adapter's own
788
+ // edit-time freeze/unfreeze (which calls `loop.pause()`/`loop.resume()`
789
+ // directly, above), so a gizmo drag never trips the loop-gate honesty
790
+ // report `createIngestLoopGate` makes for an actual play/pause request.
791
+ // S-5: hand the in-page mount the SAME-REALM scheduling gate, so pausing
792
+ // freezes the game's `setInterval`/`setTimeout`/raw-rAF drivers too and the
793
+ // verdict is MEASURED rather than declared.
794
+ const loopGate = createIngestLoopGate(capture, rt.renderer, game.id, loop, {
795
+ realmGate: gameLoopGate(),
796
+ // The probe is ASYNC (it watches a real-time window), so the verdict lands
797
+ // well after `setPaused` returned. Nothing else would notify the store, and
798
+ // `collectState` is push-based — the control API would keep serving a
799
+ // snapshot taken before the measurement existed, reporting `loop: null`
800
+ // forever. Broadcast so `vgai status` sees the verdict it just produced.
801
+ onVerdict: () => store.notifyIngestEdit(),
802
+ });
803
+ // The game's DECLARED system surface (`window.vgaiGame.systems`), projected
804
+ // onto the host's ordinary `SystemAdapters.debug`. `activateCapturedThreeIngest`
805
+ // hands `mounted.systems` straight to `setActiveSystems`, so a shim that
806
+ // declares verbs makes `game.commands()`/`game.state()` work through the same
807
+ // bridge path first-party content uses — no ingest-specific door. A game that
808
+ // declares nothing yields `null` here and keeps the pre-contract floor.
809
+ const { systems, renderDebug, debugCollisions } = wireIngestSystems({
810
+ contractSystems: readIngestGameContract()?.systems,
811
+ surface: 'three',
812
+ runtime: rt,
813
+ setRenderPassHooks: (hooks) => capture.setRenderPassHooks(hooks),
814
+ });
815
+ // A name two feeders both declare is REFUSED when called, by a coded error
816
+ // naming both. Said here too — same as the canvas mount — because a refusal
817
+ // nobody sees until they call the name is a defect that ships.
818
+ for (const collision of debugCollisions) {
819
+ editorConsole.error(`Ingest game "${game.id}" — ${collision}; it will refuse`, 'ingest');
820
+ }
821
+ const mounted: MountedThreeRoot = {
822
+ kind: 'three',
823
+ scene: rt.scene,
824
+ camera: rt.camera,
825
+ drivesOwnLoop: true,
826
+ ...(Object.keys(systems).length > 0 ? { systems } : {}),
827
+ setPaused: (paused) => loopGate.setPaused(paused),
828
+ // Also resize any composer the game renders through, so
829
+ // its own (intentionally downsampled, e.g. `UnrealBloomPass`) render
830
+ // targets track the host pane instead of staying stale at mount size.
831
+ resize: (w, h) => {
832
+ // WHO OWNS THE PICTURE'S SHAPE. A game that registered its own `resize`
833
+ // listener has a size policy — letterboxing to a fixed aspect is the
834
+ // common one — and the realm now reports the
835
+ // PANE as `window.innerWidth`/`innerHeight`, so its own handler produces
836
+ // the right answer for the pane. Forcing `setSize(pane)` on top of that
837
+ // would stretch exactly the game that took the trouble to letterbox
838
+ // itself. So: tell the game, and let it size its renderer.
839
+ if (realmPage.gameResizesItself) {
840
+ realmPage.dispatchResize();
841
+ return;
842
+ }
843
+ // Otherwise the host owns it, exactly as before.
844
+ const size = hostSurfaceBackingSize(w, h);
845
+ rt.renderer.setSize(size.width, size.height, false);
846
+ capture.resizeComposers(size.width, size.height);
847
+ // S-4: re-assert the host's CSS box. `setSize(…, false)` deliberately
848
+ // never touches style, so this is the only thing that survives a game's
849
+ // own window-resize handler re-stamping pixels.
850
+ claimHostSurfaceBox(gameDomRoot);
851
+ },
852
+ dispose: () => {
853
+ // Drop the render-pass bracket and reject any armed capture BEFORE the
854
+ // renderer goes, so no wrapper outlives the mount on the game's objects.
855
+ capture.setRenderPassHooks(null);
856
+ // The bracket's second consumer, released through its own ONE teardown
857
+ // path — a screenshot waiting on a frame settles now rather than hanging
858
+ // out its timeout over a mount that is gone.
859
+ clearIngestFrameSource();
860
+ renderDebug?.dispose();
861
+ try {
862
+ rt.renderer.setAnimationLoop?.(null);
863
+ rt.renderer.dispose?.();
864
+ } catch {
865
+ /* ignore */
866
+ }
867
+ capture.uninstall();
868
+ projectThree.DefaultLoadingManager.setURLModifier((url) => url);
869
+ for (const el of stubEls) el.remove();
870
+ // A game that held pointer lock must not keep it past its own mount, and
871
+ // the realm must stop standing in for a surface that no longer exists.
872
+ releaseGamePointerLock();
873
+ clearGameSurface();
874
+ // RESOURCE OWNERSHIP: this mount recorded these three per-root facts, so
875
+ // this mount's ONE teardown path drops them. Per-root, never a blanket
876
+ // clear — a composite mounts roots independently and a sibling's answers
877
+ // are still true (the rule `clearMountFailureReport` already follows).
878
+ clearPresentationSurface(game.id);
879
+ clearRootReadiness(game.id);
880
+ clearWorldAdoption(game.id);
881
+ // Removing the surface removes the game's ENTIRE DOM with it — canvas,
882
+ // title wrapper, pause wrapper, game-over overlay — because the realm
883
+ // put every one of them inside it (game-realm-page.ts, reason 1).
884
+ hostEl.remove();
885
+ },
886
+ authoring,
887
+ };
888
+
889
+ return {
890
+ game: mounted,
891
+ authoring,
892
+ hostEl,
893
+ capture,
894
+ scene: rt.scene,
895
+ realmLoopVerdict: () => loopGate.lastVerdict(),
896
+ };
897
+ }