@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,1174 @@
1
+ import { onAssetReload } from '@volter/editor-core/project-asset-refresh';
2
+
3
+ /**
4
+ * R3F design session (W4) — mounts an entry-based R3F three world at DESIGN
5
+ * TIME so the editor's native viewport, gizmo, hierarchy, and inspector drive
6
+ * the LIVE fiber scene in EDIT mode, with writes going back to the `.tsx`
7
+ * source through `R3fSourceAuthoringAdapter`.
8
+ *
9
+ * The project mounts against a design host borrowing the viewport renderer
10
+ * for offscreen GPU work. Fiber's canvas and renderer lifecycle remain owned
11
+ * by the host; its simulation uses frameloop 'never'. No second context is
12
+ * created and no automatic game tick runs in Edit mode.
13
+ * The EDITOR's own renderer draws the scene: the fiber `THREE.Scene` is
14
+ * adopted into the store (`enterPlayScene`, the same swap play/ingest use —
15
+ * viewport `setScene`, objectMap from the adapter's stamped `entityId`s,
16
+ * composer rebuild), while `playState` stays 'stopped', so this is an
17
+ * EDIT-mode surface: hierarchy/pick/gizmo all work through the existing
18
+ * native-three machinery.
19
+ *
20
+ * Edit shows the AUTHORED pose and never advances content time. Physics preview
21
+ * is explicit simulation, just as animation preview is explicit transport;
22
+ * opening a scene must not hide it behind a build-quiescence wait plus 90
23
+ * invisible simulation steps. That is both the architecture's Edit ≠ Play
24
+ * rule and the ordinary Unity/Godot scene-editor contract.
25
+ *
26
+ * Component-only R3F modules use native Fast Refresh on this persistent root.
27
+ * Mixed exports still use the controlled entry-update/remount path. Completed
28
+ * HMR source hashes acknowledge matching collaboration revisions; a revision
29
+ * whose update was lost retains a bounded cold-remount fallback. The native
30
+ * authoring adapter observes reconciled child additions/removals.
31
+ *
32
+ * Play handoff mirrors `design-time-layers.ts`: entering play suspends the
33
+ * session (scene restored, fiber root disposed, Boundary disclosure node
34
+ * back in the composite); Stop queues one edit-mode rebuild which recreates
35
+ * the session fresh.
36
+ *
37
+ * DEBUG PLANE OWNERSHIP (ARCHITECTURE-CORE §Editor chrome — "the edit-time
38
+ * design session PUBLISHES its debug plane"), stated once, here:
39
+ *
40
+ * - OWNER: this session. While a design world is mounted and `playState` is
41
+ * 'stopped', it registers the design `Game`'s own `systemAdapters` — its
42
+ * game-scoped debug registry, carrying whatever the mounted world actually
43
+ * registered — through the SAME `setActiveSystems` door play/ingest/module
44
+ * modes use, under the UNNAMED (solo) seat. That is what lets the relay
45
+ * answer `list-gameplay-state`/`inspect-gameplay-state`/
46
+ * `list-debug-commands`/`invoke-debug-command` and `vgai eval`'s
47
+ * `game.state()`/`game.commands()` from the EDIT world. It publishes
48
+ * declarations, never a run: the loop is still never advanced.
49
+ * - SHARERS: none, ever, at the same instant. Play mode registers its mount
50
+ * under its OWN mount id, so a seat held by both would make
51
+ * `systemsForInstance(undefined)` ambiguous and break every unaddressed
52
+ * `vgai eval` call. The store subscription below therefore withdraws this
53
+ * seat the moment `playState` leaves 'stopped' — synchronously, at
54
+ * `store.setPlayState('playing')`, long before play's own
55
+ * `setActiveSystems` lands — and republishes when play ends without a
56
+ * rebuild. (When play DID suspend this session, Stop queues the rebuild and
57
+ * the FRESH session publishes; this one only withdraws.)
58
+ * - TEARDOWN: `withdrawPlane()` is the one path that ends it, and it is
59
+ * idempotent. Its callers are the play-entry subscription, `disposeMounted`
60
+ * (so an HMR remount can never leave the disposed game's stripped registry
61
+ * published), and the returned disposer.
62
+ */
63
+
64
+ import { setActiveSystems, updateInstanceSystems } from '@volter/editor-core/authoring/active-systems';
65
+ import {
66
+ BoundaryAuthoringAdapter,
67
+ type BoundaryRootInfo,
68
+ } from '@volter/editor-core/authoring/boundary-authoring-adapter';
69
+ import type { CompositeAuthoringAdapter } from '@volter/editor-core/authoring/composite-authoring-adapter';
70
+ import {
71
+ collaborationSnapshot,
72
+ subscribeCollaborationRevision,
73
+ } from '@volter/editor-core/collaboration-client';
74
+ import { editorConsole } from '@volter/editor-core/editor-console';
75
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
76
+ import { adjudicateThreeEntry } from '../../host/entry-adjudication';
77
+ import { onPlayTransitionSettled } from '@volter/editor-core/live-transition';
78
+ import { fetchRawGameManifest } from '@volter/editor-core/manifest-project';
79
+ import { getCurrentProject } from '@volter/editor-core/project-manager';
80
+ import { activeRealmServices } from '../../host/realm-services';
81
+ import { pickGameCamera } from '@volter/editor-core/scene-framing';
82
+ import { tierSourceWriteBackend } from '@volter/editor-core/ui-source/tier-source-write-backend';
83
+ import type {
84
+ MountedThreeRoot,
85
+ RootAdapter,
86
+ SystemAdapters,
87
+ } from '@volter/editor-project/adapter';
88
+ import type { GameThreeHostContext } from '@volter/game-runtime/runtime/host-context';
89
+ import { nodeKeyedPhysics } from '@volter/editor-project/adapter';
90
+ import { declaredRoots, rootById } from '@volter/editor-project/adapter/manifest-interpreter';
91
+ import {
92
+ installNativeDebugBindings,
93
+ installNativeSystemsBindings,
94
+ type NativeDebugBinding,
95
+ type NativeSystemsBinding,
96
+ nativeDebugBindingFromEntryModule,
97
+ nativeSystemsBindingFromEntryModule,
98
+ } from '@volter/game-runtime/adapter/native-debug-module';
99
+ import { createAssetCache } from '@volter/threejs-runtime/assets';
100
+ import { createGameLoop } from '@volter/game-runtime/core/game-loop';
101
+ import { registerThreeRoot } from '@volter/game-runtime/runtime/create-runtime';
102
+ import { createGame, type GameInternal } from '@volter/game-runtime/runtime/game';
103
+ import {
104
+ beginProjectMountEpoch,
105
+ viteUpdateImportPath,
106
+ } from '@volter/editor-sdk/session/project-module-url';
107
+ import * as THREE from 'three';
108
+ import { createDesignTimeRenderer } from './design-time-renderer';
109
+
110
+ /**
111
+ * Which three world is FOCUSED — the project's three root.
112
+ *
113
+ * The `find` is EXACT, not a first-wins pick: a project declares at most one
114
+ * world root per medium, and `GameManifestSchema`'s `roots` `superRefine`
115
+ * rejects a manifest with a second `three` root outright. So there is nothing
116
+ * for this to choose between — it returns the one three root, or `null` when
117
+ * the project has none.
118
+ *
119
+ * Every three root is entry-based, so focus is a property of the manifest
120
+ * alone — nothing about what is currently loaded can move it. It lives HERE,
121
+ * in this integration, because it names a surface: the kit's edit-mode
122
+ * installer asks `active-adapter.ts`'s factory seam by surface instead.
123
+ */
124
+ function resolveThreeRootId(
125
+ roots: readonly { readonly id: string; readonly surface: string }[],
126
+ ): string | null {
127
+ return roots.find((w) => w.surface === 'three')?.id ?? null;
128
+ }
129
+
130
+ import {
131
+ type EditModeRootSpec,
132
+ parseEditModeManifest,
133
+ queueEditModeRebuild,
134
+ } from '@volter/editor-core/authoring/edit-mode-authoring';
135
+ import { liveGestureActive, whenLiveGestureIdle } from '@volter/editor-core/authoring/live-gesture-lock';
136
+ import {
137
+ addMountFailureReport,
138
+ clearMountFailureReport,
139
+ formatMountFailureMessage,
140
+ } from '@volter/editor-core/authoring/mount-failure-report';
141
+ import { SelectionRemountHandoff } from '../../host/authoring/selection-remount-handoff';
142
+ import {
143
+ type RefreshSource,
144
+ SourceRefreshRevisions,
145
+ } from '../../host/authoring/source-refresh-revisions';
146
+ import type { R3fSourceAuthoringAdapter } from './r3f-source-authoring-adapter';
147
+ import { isThreeScene } from './three-scene-identity';
148
+
149
+ // The world root's stage is recreated when the workspace changes.
150
+ // The outgoing panel restores the editor scene and the incoming panel loads
151
+ // it again before remounting the same R3F world; both operations clear the
152
+ // shared EditorShellStore selection. Keep a same-store handoff outside that reset
153
+ // path. WeakMap prevents a closed project/store from being retained.
154
+ const selectionRemountHandoff = new SelectionRemountHandoff<EditorShellStore>();
155
+
156
+ /**
157
+ * A three entry that is neither a `RootAdapter` nor a default-exported
158
+ * component used to return `null` from the design session's import, which
159
+ * then `return () => {}` — a blank Scene, `mountFailures: []`, no console
160
+ * error. The coverage warning ("seven editor providers missing") is not
161
+ * loudness. Name the root and the file so the empty viewport says why.
162
+ */
163
+ export function unresolvedDesignEntryError(
164
+ worldId: string,
165
+ entry: string,
166
+ exported: readonly string[],
167
+ ): Error {
168
+ return new Error(
169
+ `three root "${worldId}" failed to mount: ${entry} did not export a RootAdapter or a ` +
170
+ `default React component (exports: ${exported.join(', ') || '(nothing)'}).`,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * The one loudness write for a design-mount failure. `failToBoundary` (and
176
+ * tests) go through this so a blank Scene cannot lose the named report.
177
+ */
178
+ export function reportDesignMountFailure(worldId: string, err: unknown): string {
179
+ const message = formatMountFailureMessage(err);
180
+ editorConsole.error(`[r3f-design] world "${worldId}" failed to mount: ${message}`, 'authoring');
181
+ addMountFailureReport({
182
+ worldId,
183
+ kind: 'three',
184
+ identity: 'default-three',
185
+ message,
186
+ });
187
+ return message;
188
+ }
189
+
190
+ /**
191
+ * Load the component Edit mounts and the manifest root's static module surface
192
+ * in one realm epoch. They are frequently the same file; translated players
193
+ * deliberately split them so Edit can open an authored scene without booting
194
+ * the player shell. In that split case, declarations still belong to the root
195
+ * entry and must not be looked up on the scene module.
196
+ */
197
+ export async function loadR3FDesignEntryModules(
198
+ realm: Pick<Awaited<ReturnType<typeof activeRealmServices>>, 'loadEntryModule'>,
199
+ worldId: string,
200
+ designEntry: string,
201
+ rootEntry: string,
202
+ ): Promise<{
203
+ designModule: Record<string, unknown>;
204
+ rootModule: Record<string, unknown>;
205
+ }> {
206
+ const designModule = await realm.loadEntryModule(designEntry, worldId, 'three');
207
+ const rootModule =
208
+ rootEntry === designEntry
209
+ ? designModule
210
+ : await realm.loadEntryModule(rootEntry, worldId, 'three');
211
+ return { designModule, rootModule };
212
+ }
213
+
214
+ /** Complete and install one design root's declared observation/system plane. */
215
+ export function registerR3FDesignRoot(
216
+ game: GameInternal,
217
+ adapter: RootAdapter,
218
+ mounted: MountedThreeRoot,
219
+ worldId: string,
220
+ entryDebug: NativeDebugBinding | null,
221
+ entrySystems: NativeSystemsBinding | null,
222
+ ): void {
223
+ registerThreeRoot(game, adapter, mounted, { id: worldId });
224
+ installNativeDebugBindings(game, entryDebug ? [entryDebug] : []);
225
+ installNativeSystemsBindings(game, entrySystems ? [entrySystems] : []);
226
+ }
227
+
228
+ /**
229
+ * The design host borrows the viewport renderer (see the module doc comment).
230
+ *
231
+ * It carries a real `Game` whose loop is NEVER started. Without one, every
232
+ * game-owned service would be missing, and behavior written the idiomatic
233
+ * way — a hook reading the game handle (`useOptionalGame()?.input`) at the
234
+ * top, as the starter's player controller does — would find nothing at
235
+ * design time. The pre-D26 shape hid this by accident: a
236
+ * a component reads `ctx.input` in `useFrame`, and design time never ticks.
237
+ *
238
+ * A Game here is not a shim — it is the real object, just inert: nothing
239
+ * advances the loop, so no frame, physics step or `useFrame` callback ever
240
+ * runs, and design time stays the still scene this session's contract
241
+ * promises. It also gets its OWN debug registry (registries are game-scoped),
242
+ * so design-time providers can't collide with the play game's.
243
+ *
244
+ * Input is explicitly DISABLED: `new InputManager()` binds window-level
245
+ * keyboard/mouse listeners in its constructor, and design time is not play
246
+ * (T6.3's invariant). `dispose()` unbinds them — the caller must call it.
247
+ *
248
+ * Its renderer is `createDesignTimeRenderer`, which isolates Fiber configuration
249
+ * and permits real offscreen work without transferring canvas ownership.
250
+ */
251
+ function createDesignHost(renderer: THREE.WebGLRenderer): {
252
+ host: GameThreeHostContext;
253
+ game: GameInternal;
254
+ dispose: () => void;
255
+ } {
256
+ const canvas = document.createElement('canvas');
257
+ const borrowedRenderer = createDesignTimeRenderer(canvas, renderer);
258
+ const assets = createAssetCache();
259
+ const game = createGame({ loop: createGameLoop({ update: () => {} }), assets });
260
+ game.input.setEnabled(false);
261
+ return {
262
+ game,
263
+ host: {
264
+ three: THREE,
265
+ surface: { canvas, width: 1280, height: 720 },
266
+ renderer: borrowedRenderer,
267
+ assets,
268
+ headless: false,
269
+ game,
270
+ },
271
+ dispose: () => game.dispose(),
272
+ };
273
+ }
274
+
275
+ function boundaryInfo(world: EditModeRootSpec): BoundaryRootInfo {
276
+ return {
277
+ id: world.id,
278
+ kind: world.surface,
279
+ adapter: world.adapter,
280
+ entryOrScenePath: world.entry ?? world.scene,
281
+ zOrder: world.zOrder ?? 0,
282
+ pausable: world.pausable ?? true,
283
+ };
284
+ }
285
+
286
+ function rebuildWhenPlayStops(store: EditorShellStore): () => void {
287
+ let disposed = false;
288
+ let requested = false;
289
+ const request = () => {
290
+ if (disposed || requested || store.playState !== 'stopped') return;
291
+ requested = true;
292
+ queueEditModeRebuild();
293
+ };
294
+ const unsubscribe = store.subscribe(request);
295
+ request();
296
+ return () => {
297
+ disposed = true;
298
+ unsubscribe();
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Mount the R3F design session for the focused ENTRY-BASED three world (if
304
+ * this project has one and this session can serve it). Returns a disposer.
305
+ * A project whose Three world is a `setup`-export entry no-ops.
306
+ *
307
+ * BROWSER (hosted, no dev server) sessions run this too. Three dev-server
308
+ * dependencies had to be replaced first:
309
+ * - the entry import was a `/@fs/<abs path>` URL only Vite can serve;
310
+ * - source write-back posted to `/__ui-source/*` →
311
+ * the session's recorder reads/writes the same files through
312
+ * storage, running the SAME `planSourceEdit` the server does;
313
+ * - the remount trigger was Vite's `vgai:r3f-entry-update` HMR event →
314
+ * `StorageBackend.watch`, which in a server-less editor reports the
315
+ * editor's own writes (exactly the signal absorb-by-remount needs).
316
+ * A hosted EXAMPLE (`?project=<id>`) is still excluded: it is opened read-only
317
+ * and has no writable storage root, so there is nothing honest to write back
318
+ * to.
319
+ */
320
+ export async function mountR3FDesignSession(
321
+ store: EditorShellStore,
322
+ composite: CompositeAuthoringAdapter,
323
+ renderer: THREE.WebGLRenderer,
324
+ ): Promise<() => void> {
325
+ const project = getCurrentProject();
326
+ if (!project) return () => {};
327
+ const rawManifest = await fetchRawGameManifest();
328
+
329
+ // Mirror design-time-layers' entry guard: while play is already running,
330
+ // mount nothing and wait for Stop's rebuild.
331
+ if (store.playState !== 'stopped') {
332
+ return rebuildWhenPlayStops(store);
333
+ }
334
+
335
+ const manifest = parseEditModeManifest(rawManifest);
336
+ if (!manifest) return () => {};
337
+ const threeRootId = resolveThreeRootId(declaredRoots(manifest));
338
+ const world = threeRootId ? rootById(manifest, threeRootId) : undefined;
339
+ if (!world) return () => {};
340
+ // WHAT THIS SESSION MOUNTS, and why it is not always the root's `entry`.
341
+ //
342
+ // Usually the two are the same file: `entry` IS the world component. A root
343
+ // that declares `world` says otherwise — its `entry` mounts the whole GAME
344
+ // (its own composition, HUD and loop), while the named component is the
345
+ // thing an AUTHOR edits. Edit mounts that; Play mounts the game
346
+ // (`ingest/deferred-ingest-play.ts`). Reading the fact here is what makes
347
+ // "a scene is a single root" true without this module knowing anything
348
+ // about WHY a given game has a shell — an ingested game's own App.tsx and a
349
+ // translated Unity port's scene HOST are the two shipped cases, and the
350
+ // second is why the field is not an ingest one: a Unity port's `entry`
351
+ // starts the built player on EditorBuildSettings index 0, which for the FPS
352
+ // microgame is a uGUI menu with three three-surface nodes and not one
353
+ // renderer among them. Edit opened on that and drew nothing at all.
354
+ const designEntry = world.world?.entry ?? world.entry;
355
+ const designExport = world.world?.export;
356
+ if (!designEntry || !/\.(tsx|jsx)$/.test(designEntry)) {
357
+ return () => {};
358
+ }
359
+ // These two independent graphs are the expensive local-development lane:
360
+ // the game's own entry and the editor's full native-Three authoring adapter.
361
+ // Start the latter here and await it beside the entry below. Keeping the
362
+ // adapter as a static import serialized both graphs on a cold browser even
363
+ // though neither depends on the other.
364
+ const sourceAdapterModule = import('./r3f-source-authoring-adapter');
365
+ const writeBackend = tierSourceWriteBackend();
366
+
367
+ let torndown = false;
368
+ let suspended = false;
369
+ let mounted: MountedThreeRoot | null = null;
370
+ let adapter: R3fSourceAuthoringAdapter | null = null;
371
+ let sceneAdopted = false;
372
+ let adoptedScene: THREE.Scene | null = null;
373
+ let remountQueued = false;
374
+ // One-shot guard for the transient-failure retry below.
375
+ let remountRetried = false;
376
+ let disposeHot: (() => void) | undefined;
377
+ let disposeDesignHost: (() => void) | undefined;
378
+ let unsubscribeSystemAdapters: (() => void) | undefined;
379
+ let unsubStore: (() => void) | undefined;
380
+ /** The mounted design world's own `Game` — the debug plane's source. */
381
+ let designGame: GameInternal | null = null;
382
+ /** The bag currently registered under the solo seat, or `null` when this
383
+ * session holds no seat. Doubles as the idempotence flag for the pair
384
+ * below — see this module's DEBUG PLANE OWNERSHIP note. */
385
+ let publishedSystems: SystemAdapters | null = null;
386
+
387
+ const worldId = world.id;
388
+ const entry = designEntry;
389
+
390
+ /** Take the solo `setActiveSystems` seat for the edit world. No-op unless a
391
+ * design world is mounted, this session is live, and play is stopped. */
392
+ const publishPlane = (): void => {
393
+ if (publishedSystems || !designGame) return;
394
+ if (torndown || suspended || store.playState !== 'stopped') return;
395
+ publishedSystems = designGame.systemAdapters;
396
+ setActiveSystems(publishedSystems);
397
+ };
398
+
399
+ /** Release it. The ONE teardown path; idempotent. */
400
+ const withdrawPlane = (): void => {
401
+ if (!publishedSystems) return;
402
+ publishedSystems = null;
403
+ setActiveSystems(null);
404
+ };
405
+
406
+ const importEntry = async (): Promise<{
407
+ adapter: RootAdapter;
408
+ entryDebug: NativeDebugBinding | null;
409
+ entrySystems: NativeSystemsBinding | null;
410
+ components?: unknown;
411
+ } | null> => {
412
+ // PD-3: a design remount IS a new mount generation, so it opens a new
413
+ // mount epoch — and therefore its own realm value over that epoch. WHERE
414
+ // the entry comes from (dev `/@fs`, or the packaged project graph) is the
415
+ // realm's one answer; this session used to carry its own copy of it.
416
+ const realm = await activeRealmServices(project.rootPath, beginProjectMountEpoch());
417
+ // This whole session is scoped to a THREE root (`resolveThreeRootId`
418
+ // above), so the declared surface is known outright.
419
+ const { designModule: mod, rootModule } = await loadR3FDesignEntryModules(
420
+ realm,
421
+ worldId,
422
+ entry,
423
+ world.entry ?? entry,
424
+ );
425
+ // The manifest root entry owns this root's static debug/system surface,
426
+ // even when Edit mounts a narrower `world.world.entry` scene component.
427
+ // Harvesting the scene namespace silently dropped stable root-level slots
428
+ // (translated Unity physics is one) from the stopped design session.
429
+ const entryDebug = nativeDebugBindingFromEntryModule(worldId, rootModule);
430
+ const entrySystems = nativeSystemsBindingFromEntryModule(worldId, rootModule, 'three');
431
+ // WHAT that module means is `entry-adjudication.ts`'s one answer — the
432
+ // same one play mode and the runtime mount get, which is what keeps design
433
+ // mode from disagreeing with them about a world file. `world.export` names
434
+ // ONE component in a module that exports several (a game's `App.tsx`
435
+ // exporting `Scene` beside `Hud` and `App`); it is handed to the SAME
436
+ // resolver a default export is, and a name the module does not export is a
437
+ // NAMED failure there, never a silent fall-back to `default` (which for
438
+ // such a module would mount the whole game into the Scene view).
439
+ const adapter = await adjudicateThreeEntry(mod, worldId, {
440
+ ...(designExport === undefined ? {} : { namedExport: designExport }),
441
+ entryPath: entry,
442
+ });
443
+ if (!adapter) {
444
+ throw unresolvedDesignEntryError(worldId, entry, Object.keys(mod));
445
+ }
446
+ // A named world component is one component out of its module; the module's
447
+ // OTHER exports are not this world's component table.
448
+ if (designExport !== undefined) return { adapter, entryDebug, entrySystems };
449
+ return {
450
+ adapter,
451
+ entryDebug,
452
+ entrySystems,
453
+ components: mod['components'] ?? mod['behaviors'],
454
+ };
455
+ };
456
+
457
+ /**
458
+ * A mounted design world and the host resources that must die WITH it.
459
+ *
460
+ * `mountRoot` hands this back rather than installing it, because a remount
461
+ * mounts the NEW world before tearing the OLD one down: assigning
462
+ * `disposeDesignHost` inside `mountRoot` (what it used to do) meant the
463
+ * teardown that followed disposed the *incoming* Game and leaked the
464
+ * outgoing one's window listeners. `game.dispose()` strips the debug
465
+ * registry, so with the plane published that mis-wiring showed up as a
466
+ * design session that answered nothing after the first source edit.
467
+ * Ownership is `adoptMount`'s, and only after `disposeMounted`.
468
+ */
469
+ interface DesignMount {
470
+ root: MountedThreeRoot;
471
+ game: GameInternal;
472
+ disposeHost: () => void;
473
+ }
474
+
475
+ const mountRoot = async (
476
+ adapterExport: RootAdapter,
477
+ entryDebug: NativeDebugBinding | null,
478
+ entrySystems: NativeSystemsBinding | null,
479
+ ): Promise<DesignMount> => {
480
+ const { host, game: hostGame, dispose: disposeHost } = createDesignHost(renderer);
481
+ let result: MountedThreeRoot;
482
+ try {
483
+ result = await adapterExport.mount(host);
484
+ } catch (err) {
485
+ disposeHost();
486
+ throw err;
487
+ }
488
+ if (result.kind !== 'three' || !isThreeScene(result.scene)) {
489
+ result.dispose();
490
+ disposeHost();
491
+ throw new Error(`entry adapter for world "${worldId}" did not mount a three scene`);
492
+ }
493
+ // This is a real Game shell, so its real root registry must own the mount.
494
+ // `game.systemAdapters` intentionally folds only registered roots; leaving
495
+ // the design mount detached stranded every React-effect contribution
496
+ // (Rapier, game debug providers, and future native systems) on
497
+ // `mounted.systems` even though the component registered successfully.
498
+ registerR3FDesignRoot(hostGame, adapterExport, result, worldId, entryDebug, entrySystems);
499
+ return { root: result, game: hostGame, disposeHost };
500
+ };
501
+
502
+ /** Install a freshly-mounted design world as THE live one. The previous one
503
+ * must already be down (`disposeMounted`) — this overwrites its disposer. */
504
+ const adoptMount = (next: DesignMount): void => {
505
+ mounted = next.root;
506
+ designGame = next.game;
507
+ // Physics/debug contributions register from React effects, which may land
508
+ // after adapter.mount() returns. Keep the edit-mode plane and Inspector
509
+ // projection on the Game's CURRENT aggregate just as Play mode does; a
510
+ // one-time `game.systemAdapters` snapshot permanently reported those
511
+ // late contributions as implemented-empty.
512
+ unsubscribeSystemAdapters?.();
513
+ const adoptedGame = next.game;
514
+ unsubscribeSystemAdapters = adoptedGame.subscribeSystemAdapters?.(() => {
515
+ if (designGame !== adoptedGame) return;
516
+ const systems = adoptedGame.systemAdapters;
517
+ if (publishedSystems) {
518
+ publishedSystems = systems;
519
+ updateInstanceSystems(systems);
520
+ }
521
+ store.notifyIngestEdit();
522
+ });
523
+ // The design Game outlives `mount()` (the world holds its ctx) and must go
524
+ // down with the mount, or every HMR remount leaks another window-listener
525
+ // set and another debug registry.
526
+ disposeDesignHost = next.disposeHost;
527
+ };
528
+
529
+ const disposeMounted = (): void => {
530
+ // Before anything is disposed: a published bag must never outlive the
531
+ // registry behind it (`game.dispose()` strips it), or a remount would
532
+ // leave the relay reading a dead plane.
533
+ withdrawPlane();
534
+ unsubscribeSystemAdapters?.();
535
+ unsubscribeSystemAdapters = undefined;
536
+ designGame = null;
537
+ try {
538
+ mounted?.dispose();
539
+ } catch (err) {
540
+ editorConsole.error(`[r3f-design] dispose failed: ${err}`, 'authoring');
541
+ }
542
+ mounted = null;
543
+ try {
544
+ disposeDesignHost?.();
545
+ } catch (err) {
546
+ editorConsole.error(`[r3f-design] design host dispose failed: ${err}`, 'authoring');
547
+ }
548
+ disposeDesignHost = undefined;
549
+ };
550
+
551
+ const restoreEditorScene = (): void => {
552
+ if (!sceneAdopted) return;
553
+ sceneAdopted = false;
554
+ const scene = adoptedScene;
555
+ adoptedScene = null;
556
+ // Release OUR adoption specifically: at the deferred play hand-off (see
557
+ // the suspend deferral below) play has already adopted the live game
558
+ // scene OVER this one, and a blind exitPlayScene would pop PLAY's frame
559
+ // instead of ours (editor-store.releaseAdoptedScene).
560
+ if (scene) store.releaseAdoptedScene(scene);
561
+ else store.exitPlayScene();
562
+ };
563
+
564
+ const suspendForPlay = (): void => {
565
+ suspended = true;
566
+ restoreEditorScene();
567
+ disposeMounted();
568
+ composite.replaceChild(
569
+ worldId,
570
+ new BoundaryAuthoringAdapter(
571
+ store,
572
+ boundaryInfo(world),
573
+ 'R3F design session suspended by play mode — Stop restores it.',
574
+ ),
575
+ );
576
+ store.notifyIngestEdit();
577
+ };
578
+
579
+ const failToBoundary = (err: unknown): void => {
580
+ const message = reportDesignMountFailure(worldId, err);
581
+ composite.replaceChild(
582
+ worldId,
583
+ new BoundaryAuthoringAdapter(store, boundaryInfo(world), message),
584
+ );
585
+ store.notifyIngestEdit();
586
+ };
587
+
588
+ // ---------------------------------------------------------- initial mount
589
+ // Stall watchdog: the import/bundle step can HANG without throwing (realm
590
+ // services, the in-browser bundler, esbuild-wasm's own binary download on a
591
+ // slow network) — and a hang is invisible: the editor chrome runs at full
592
+ // FPS over an empty scene with a clean console. A human sat on exactly that
593
+ // for 2.5 minutes and gave up (runhuman pass 42). Narrate the wait so a
594
+ // stall is a diagnosable report, never silence.
595
+ const stallTimer = setTimeout(() => {
596
+ editorConsole.warn(
597
+ `[r3f-design] world "${worldId}" is still importing after 20s (${entry}). ` +
598
+ 'The in-browser compiler or a module download may be stalled on a slow ' +
599
+ 'connection — it keeps trying; reload the tab if nothing appears.',
600
+ 'authoring',
601
+ );
602
+ }, 20_000);
603
+ try {
604
+ const [loaded, { R3fSourceAuthoringAdapter }, persistence] = await Promise.all([
605
+ importEntry(),
606
+ sourceAdapterModule,
607
+ writeBackend,
608
+ ]);
609
+ clearTimeout(stallTimer);
610
+ if (!loaded) {
611
+ editorConsole.warn(
612
+ `[r3f-design] world "${worldId}" produced no design entry (${entry}) — ` +
613
+ 'the scene stays empty. This is a bug worth reporting; reload the tab to retry.',
614
+ 'authoring',
615
+ );
616
+ return () => {};
617
+ }
618
+ if (torndown) return () => {};
619
+ // Restart can enter Play while this design import is in flight.
620
+ if (store.playState !== 'stopped') return rebuildWhenPlayStops(store);
621
+ const first = await mountRoot(loaded.adapter, loaded.entryDebug, loaded.entrySystems);
622
+ adoptMount(first);
623
+ if (torndown || store.playState !== 'stopped') {
624
+ disposeMounted();
625
+ return torndown ? () => {} : rebuildWhenPlayStops(store);
626
+ }
627
+ adapter = new R3fSourceAuthoringAdapter(store, first.root.scene, {
628
+ worldId,
629
+ entryPath: entry,
630
+ writeBackend: persistence,
631
+ physics: () => nodeKeyedPhysics(designGame?.systemAdapters.physics),
632
+ });
633
+ composite.replaceChild(worldId, adapter);
634
+ // The hierarchy can become interactive as soon as the composite child is
635
+ // replaced, while this async mount is still completing. Preserve a user
636
+ // selection made in that window across enterPlayScene(), which clears the
637
+ // store selection while adopting the mounted scene. OID-signature ids are
638
+ // stable across the boundary, so only restore ids the new composite owns.
639
+ const selectedIds = selectionRemountHandoff.take(store, store.selectedEntityIds);
640
+ // The world's own colour pipeline travels WITH the adoption: this session
641
+ // mounted it against a renderer that draws nothing, so the declaration has
642
+ // to reach the viewport's renderer through the adoption instead (see
643
+ // `MountedThreeRoot.rendererConfig` and `EditorShellStore.adoptedImageConfig`).
644
+ store.enterPlayScene(first.root.scene, first.root.rendererConfig);
645
+ sceneAdopted = true;
646
+ adoptedScene = first.root.scene;
647
+ // FIRST LOOK. Adoption alone leaves the viewport on its construction-time
648
+ // default pose (`editor-viewport.ts`'s `camera.position.set(10, 10, 10)`
649
+ // looking at the origin), which is a statement about nothing: a
650
+ // world-scale world simply contains that point, and the reader's opening
651
+ // frame is the inside of whatever geometry happens to sit there. Measured
652
+ // on the racing game — the first look, and the doctor's own
653
+ // `01-first-look.png`, came back a flat field of canyon rock, with the
654
+ // live camera still reading exactly (10, 10, 10) -> (0, 0, 0).
655
+ //
656
+ // This is byte-for-byte the ask `ingest/mount-three-ingest-root.ts` makes
657
+ // after ITS adoption, and deliberately so: the answer was already built
658
+ // there. The viewport's auto-frame window seeds from the world's own
659
+ // camera when it has one — gated by `scene-framing.ts`'s
660
+ // `seededViewShowsWorld`, so a camera posed inside geometry is refused
661
+ // rather than adopted — and otherwise (and on refusal) falls back to
662
+ // `frameableContentBounds` framing, which always shows something. The
663
+ // lookup is passed LIVE, never resolved here: a design-mounted tree
664
+ // resolves its `<Suspense>` content after this point, so a camera read
665
+ // once at mount would miss the case this exists for.
666
+ //
667
+ // FIRST mount only, deliberately: `remount()` below re-adopts on every
668
+ // source edit, and re-framing there would yank the reader's camera out
669
+ // from under them on every save. A first look is opened once.
670
+ store.focusOnScene(() => pickGameCamera(null, first.root.scene));
671
+ const alive = selectedIds.filter((id) => composite.hierarchy.node(id) !== null);
672
+ if (alive.length > 0) store.selectMultiple(alive);
673
+ clearMountFailureReport(worldId);
674
+ publishPlane();
675
+ store.notifyIngestEdit();
676
+ editorConsole.log(
677
+ `[r3f-design] world "${worldId}" mounted for design-time authoring (${entry})`,
678
+ 'authoring',
679
+ );
680
+ } catch (err) {
681
+ clearTimeout(stallTimer);
682
+ // PD-1: report the failure and FALL THROUGH — a first mount that throws
683
+ // must not kill the session. This used to `return`, which skipped the
684
+ // `vgai:r3f-entry-update` subscription and the play-handoff store
685
+ // subscription installed below, so the world stayed a Boundary
686
+ // ("Unavailable" in the hierarchy) with a stale error report for the rest
687
+ // of the page's life: fixing the source recovered NOTHING, and the only
688
+ // exit was a hard reload of the editor tab. A failed remount already
689
+ // behaved correctly (`failToBoundary` inside `remount`, subscriptions
690
+ // intact) — the asymmetry was the whole defect. `remount` handles the
691
+ // no-adapter-yet state this leaves behind.
692
+ failToBoundary(err);
693
+ }
694
+
695
+ /**
696
+ * A SLOW remount says where its time went; a fast one says nothing.
697
+ *
698
+ * An edit's whole cost is these three phases, and until this line existed
699
+ * the only way to attribute them was a human with a stopwatch reporting a
700
+ * single number (runhuman passes 96-99 measured 22-49s that way, and
701
+ * splitting it took offline reconstruction). `import` covers reading the
702
+ * project's sources and bundling them — `browser-transpile.ts` breaks that
703
+ * down further on the same threshold; `mount` is building the new React
704
+ * world; `settle` is waiting for an in-flight gesture and the write pipeline
705
+ * before the swap, which is deliberate and bounded but should be ~0 for an
706
+ * ordinary edit; `adopt` is everything AFTER the swap — disposing the
707
+ * outgoing world, adopting the scene, rebuilding the object map and the
708
+ * notify that re-renders every panel.
709
+ *
710
+ * `adopt` is here because the first version of this line stopped measuring
711
+ * at the swap, so a tester counting 3-5 seconds by hand saw NO line at all
712
+ * (runhuman pass 101): the phases it covered really were under the
713
+ * threshold, and the cost was in the tail it excluded. A partial measurement
714
+ * that reads as "fast" is worse than none.
715
+ *
716
+ * Threshold, not always-on: an edit that already feels instant does not need
717
+ * to narrate itself.
718
+ */
719
+ const reportSlowRemount = (
720
+ id: string,
721
+ startedAt: number,
722
+ importedAt: number,
723
+ mountedAt: number,
724
+ settledAt: number,
725
+ ): void => {
726
+ const total = performance.now() - startedAt;
727
+ if (total < 400) return;
728
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb, same channel as the stall notes above
729
+ console.info(
730
+ `[r3f-design] world "${id}" remounted in ${total.toFixed(0)}ms ` +
731
+ `(import ${(importedAt - startedAt).toFixed(0)}ms, ` +
732
+ `mount ${(mountedAt - importedAt).toFixed(0)}ms, ` +
733
+ `settle ${(settledAt - mountedAt).toFixed(0)}ms, ` +
734
+ `adopt ${(performance.now() - settledAt).toFixed(0)}ms)`,
735
+ );
736
+ };
737
+
738
+ // ---------------------------------------------------------------- remount
739
+ const remount = async (): Promise<void> => {
740
+ if (torndown || suspended) return;
741
+ const selectedIds = [...store.selectedEntityIds];
742
+ // Same stall watchdog as the initial mount, per stage: after a crashed
743
+ // mount the NEXT remount was observed to hang silently — no "mounted", no
744
+ // "failed" — leaving the world Unavailable with a clean console.
745
+ const startedAtWriteStamp = writeStamp;
746
+ const historyBusyAtStart = store.projectHistory?.getSnapshot().busy === true;
747
+ let stage: 'importing' | 'mounting' = 'importing';
748
+ const stallTimer = setTimeout(() => {
749
+ editorConsole.warn(
750
+ `[r3f-design] world "${worldId}" remount is still ${stage} after 20s (${entry}) — ` +
751
+ 'it keeps trying; reload the tab if nothing appears.',
752
+ 'authoring',
753
+ );
754
+ }, 20_000);
755
+ const remountStartedAt = performance.now();
756
+ let importedAt = remountStartedAt;
757
+ let mountedAt = remountStartedAt;
758
+ let settledAt = remountStartedAt;
759
+ try {
760
+ const loaded = await importEntry();
761
+ importedAt = performance.now();
762
+ if (!loaded || torndown || suspended) {
763
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
764
+ console.info(
765
+ `[r3f-design] world "${worldId}" remount abandoned after import (${!loaded ? 'no entry' : torndown ? 'torn down' : 'suspended'})`,
766
+ );
767
+ return;
768
+ }
769
+ stage = 'mounting';
770
+ const next = await mountRoot(loaded.adapter, loaded.entryDebug, loaded.entrySystems);
771
+ mountedAt = performance.now();
772
+ // THE SWAP WAITS FOR THE HAND — AND FOR THE HAND'S WRITE. Adopting a
773
+ // fresh world mid-drag moves the gesture's objects out from under it,
774
+ // and adopting between a release and its write landing snaps the object
775
+ // to PRE-drag source for a beat before the write's own remount corrects
776
+ // it — the "snap back, then it went back to where I released it" every
777
+ // rapid drag showed (runhuman passes 49/54/55). The build above ran in
778
+ // parallel; only the swap holds: first for the gesture, then for the
779
+ // project write pipeline to drain. If anything landed new source since
780
+ // this bundle was read, this mount is STALE — drop it and let the
781
+ // remount those writes queued deliver.
782
+ // SETTLE LOOP: a lock taken in the same task that released the previous
783
+ // one (pointer-up ends the drag lock; the gesture's write-hold begins
784
+ // immediately after) can race a waiter that already resolved — re-check
785
+ // both gates until one pass finds both quiet.
786
+ for (let settle = 0; settle < 50; settle += 1) {
787
+ await whenLiveGestureIdle();
788
+ await whenProjectHistoryIdle();
789
+ if (!liveGestureActive() && store.projectHistory?.getSnapshot().busy !== true) break;
790
+ }
791
+ settledAt = performance.now();
792
+ if (!torndown && !suspended && writeStamp !== startedAtWriteStamp) {
793
+ next.root.dispose();
794
+ next.disposeHost();
795
+ // AND SCHEDULE THE ONE THAT DELIVERS THEM. Dropping the stale mount
796
+ // used to just return, trusting the newer write's own scheduleRemount
797
+ // — and under a burst that trust did not hold: three library drops
798
+ // 0.28s apart produced ONE remount, all three writes reached the file
799
+ // and only the first reached the scene, permanently, with nothing in
800
+ // the console (Opus reproduction, 2026-09-01; at ~1s spacing the last
801
+ // of three was stranded, at ~2.4s all three landed — the window is the
802
+ // bundle+mount time). Convergence is this session's job: whatever the
803
+ // interleaving, the last write gets a mount.
804
+ scheduleRemount();
805
+ return;
806
+ }
807
+ if (torndown || suspended) {
808
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
809
+ console.info(
810
+ `[r3f-design] world "${worldId}" remount abandoned after mount (${torndown ? 'torn down' : 'suspended'})`,
811
+ );
812
+ next.root.dispose();
813
+ next.disposeHost();
814
+ return;
815
+ }
816
+ // READ THE USER'S SELECTION BEFORE THE SWAP. `restoreEditorScene()`
817
+ // releases this session's adoption, and `exitPlayScene` restores the
818
+ // selection snapshot the PREVIOUS adoption pushed — so a click made
819
+ // while this remount ran was already overwritten by the time the
820
+ // mid-remount guard below read the store, and the guard never fired:
821
+ // gizmo-move tree A, click tree B during the 2.5 s remount, and the
822
+ // selection snapped back to A 255 ms after the adopt (Opus
823
+ // reproduction on preview-b53, 2/2 with a non-reverting control; the
824
+ // human typed their Position X into the wrong tree — runhuman pass 128).
825
+ const selectionAtSwap = new Set(store.selectedEntityIds);
826
+ restoreEditorScene();
827
+ // The OUTGOING world goes down first, with its OWN host — then the
828
+ // incoming one is adopted. Reversing these disposes the incoming Game.
829
+ disposeMounted();
830
+ adoptMount(next);
831
+ if (adapter) {
832
+ adapter.adoptScene(next.root.scene);
833
+ // A FAILED remount left a Boundary in the composite (`failToBoundary`);
834
+ // a later successful one adopted the fresh scene into this adapter
835
+ // but never put the adapter back, so the hierarchy read "Unavailable"
836
+ // forever while the world had in fact remounted — what looked like a
837
+ // post-crash hang was this (measured after the crash auto-undo).
838
+ const current = composite
839
+ .childAdapters()
840
+ .find((child) => child.worldId === worldId)?.adapter;
841
+ if (current !== adapter) {
842
+ composite.replaceChild(worldId, adapter);
843
+ editorConsole.log(
844
+ `[r3f-design] world "${worldId}" recovered and remounted for design-time authoring (${entry})`,
845
+ 'authoring',
846
+ );
847
+ }
848
+ } else {
849
+ // PD-1 recovery leg: the FIRST mount failed, so the composite still
850
+ // holds this world's Boundary and no source-authoring adapter exists
851
+ // yet. Build it now — otherwise the world would mount invisibly
852
+ // (scene adopted, hierarchy still "Unavailable"), which is the same
853
+ // dead end from the user's side.
854
+ const [{ R3fSourceAuthoringAdapter }, persistence] = await Promise.all([
855
+ sourceAdapterModule,
856
+ writeBackend,
857
+ ]);
858
+ adapter = new R3fSourceAuthoringAdapter(store, next.root.scene, {
859
+ worldId,
860
+ entryPath: entry,
861
+ writeBackend: persistence,
862
+ physics: () => nodeKeyedPhysics(designGame?.systemAdapters.physics),
863
+ });
864
+ composite.replaceChild(worldId, adapter);
865
+ editorConsole.log(
866
+ `[r3f-design] world "${worldId}" recovered and mounted for design-time authoring (${entry})`,
867
+ 'authoring',
868
+ );
869
+ }
870
+ // A click landing DURING the remount is the newer intent: restoring the
871
+ // ids captured at write time stomped it — a human coloring box A then
872
+ // clicking box B watched the selection "jump back to the one I just
873
+ // colored" on every color edit (runhuman pass 35). Only restore when
874
+ // the user did not select something else while the remount ran.
875
+ const selectionChangedMidRemount =
876
+ selectionAtSwap.size > 0 &&
877
+ (selectionAtSwap.size !== selectedIds.length ||
878
+ selectedIds.some((id) => !selectionAtSwap.has(id)));
879
+ store.enterPlayScene(next.root.scene, next.root.rendererConfig);
880
+ sceneAdopted = true;
881
+ adoptedScene = next.root.scene;
882
+ // W4e — selection survives: oid-signature ids re-resolve onto the fresh
883
+ // objects (exitPlayScene cleared the store selection; restore it).
884
+ const restoreIds = selectionChangedMidRemount ? [...selectionAtSwap] : selectedIds;
885
+ const alive = restoreIds.filter((id) => adapter?.hierarchy.node(id) !== null);
886
+ if (alive.length > 0) store.selectMultiple(alive);
887
+ // PD-1: this world is mounted again — retract its failure report so the
888
+ // status item (and `vgai status`) can go back to healthy.
889
+ clearMountFailureReport(worldId);
890
+ // The plane follows the FRESH game (`disposeMounted` withdrew the old
891
+ // one's), so a stat added by the edit that triggered this remount is
892
+ // readable without entering play.
893
+ publishPlane();
894
+ store.notifyIngestEdit();
895
+ reportSlowRemount(worldId, remountStartedAt, importedAt, mountedAt, settledAt);
896
+ refreshRevisions.mounted();
897
+ clearTimeout(revisionFallback);
898
+ remountRetried = false;
899
+ } catch (err) {
900
+ failToBoundary(err);
901
+ // ONE automatic retry per failure burst: a read racing a write commit
902
+ // clears within milliseconds, and
903
+ // without this a single unlucky remount stranded the author on an
904
+ // unmounted world until a manual page reload (runhuman passes 19/21).
905
+ // A persistent failure fails again immediately — and if the editor's
906
+ // own edit caused it, that edit is undone (below) rather than left in
907
+ // source, where a reload would fail the same way.
908
+ if (!remountRetried && !torndown && !suspended) {
909
+ remountRetried = true;
910
+ setTimeout(() => {
911
+ if (!torndown && !suspended) scheduleRemount();
912
+ }, 400);
913
+ } else if (!torndown && !suspended) {
914
+ // Only a TRUSTED failure may trigger the auto-undo: one whose bundle
915
+ // was built from settled source (no write landed during the mount,
916
+ // no write in flight when it started). Under a rapid drag burst a
917
+ // half-written module can evaluate and throw INSIDE the fiber — the
918
+ // message says "fiber crashed" but the edit is fine, and undoing it
919
+ // reverted a good drag about one burst in seven (runhuman pass 58).
920
+ // An untrusted failure just remounts again once things settle.
921
+ if (writeStamp === startedAtWriteStamp && !historyBusyAtStart) {
922
+ void undoCrashingEdit(err);
923
+ } else {
924
+ remountRetried = false;
925
+ scheduleRemount();
926
+ }
927
+ }
928
+ } finally {
929
+ clearTimeout(stallTimer);
930
+ // A write that landed while this mount was adopting is not covered by
931
+ // the stale check above (it runs before the adopt): same convergence
932
+ // rule, checked once more on the way out.
933
+ if (!torndown && !suspended && writeStamp !== startedAtWriteStamp) scheduleRemount();
934
+ }
935
+ };
936
+
937
+ /** Resolve when the project history (the sha-guarded write pipeline) has no
938
+ * in-flight work — bounded, so a wedged pipeline degrades to the stale-drop
939
+ * guard instead of holding the swap forever. */
940
+ const whenProjectHistoryIdle = async (): Promise<void> => {
941
+ const history = store.projectHistory;
942
+ if (!history) return;
943
+ const deadline = Date.now() + 10_000;
944
+ while (history.getSnapshot().busy && Date.now() < deadline) {
945
+ await new Promise<void>((resolve) => {
946
+ const timer = setTimeout(resolve, 250);
947
+ const unsubscribe = history.subscribe(() => {
948
+ clearTimeout(timer);
949
+ unsubscribe();
950
+ resolve();
951
+ });
952
+ });
953
+ }
954
+ };
955
+
956
+ /**
957
+ * A source edit that crashes the world must not stay in source: a human
958
+ * set a grid size the component could not draw, the world failed, and the
959
+ * failure was still there after a reload — nothing they could reach undid
960
+ * it (runhuman pass 48). When the remount fails persistently and the
961
+ * project's newest history entry is seconds old, undo it and say so. The
962
+ * undo is sha-guarded, so a file changed by anything else refuses rather
963
+ * than clobbers; an unrelated failure with no recent edit leaves history
964
+ * alone.
965
+ */
966
+ const undoCrashingEdit = async (err: unknown): Promise<void> => {
967
+ // ONLY a genuine world crash earns an auto-undo: the world's module
968
+ // mounted and its render threw ("fiber crashed"). A bundling/read failure
969
+ // under a rapid write burst is a TRANSIENT (the import raced the next
970
+ // write), and undoing there silently reverted the user's own drag seconds
971
+ // after release — "they move without us doing anything" (runhuman pass
972
+ // 57's multi-select snap-backs were this feature misfiring, not the
973
+ // gesture pipeline). Transients converge on the next scheduled remount.
974
+ const message = err instanceof Error ? err.message : String(err);
975
+ if (!message.includes('fiber crashed')) return;
976
+ // AN ASSET THAT WOULD NOT LOAD IS NOT THE EDIT'S FAULT. A render that
977
+ // threw because a project file came back as HTML or failed to fetch
978
+ // crashed the fiber all the same,
979
+ // and undoing the author's last edit for it reverted every edit of a
980
+ // session while the real cause stayed (runhuman pass 138: the crash at
981
+ // t+0, then "everything reverted on its own"). Name the asset instead;
982
+ // the late-claim path in `storage-served-game-files.ts` remounts.
983
+ if (/Could not load |Failed to fetch|Unexpected token '<'|not valid JSON/.test(message)) {
984
+ editorConsole.error(
985
+ `World "${worldId}" failed to load a project asset (${message}). The edit is kept; ` +
986
+ 'the world remounts once the asset can be served.',
987
+ 'authoring',
988
+ );
989
+ return;
990
+ }
991
+ const history = store.projectHistory;
992
+ if (!history) return;
993
+ const snapshot = history.getSnapshot();
994
+ const last = snapshot.canUndo ? snapshot.transactions[snapshot.cursor - 1] : undefined;
995
+ if (!last || Date.now() - last.timestamp > 15_000) {
996
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
997
+ console.info(
998
+ `[r3f-design] world "${worldId}" crash auto-undo skipped (${!last ? (snapshot.canUndo ? 'no entry' : `cannot undo: busy=${snapshot.busy} blocked=${snapshot.blocked}`) : 'last edit too old'})`,
999
+ );
1000
+ return;
1001
+ }
1002
+ const undone = await history.undo().catch(() => false);
1003
+ if (!undone) {
1004
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
1005
+ console.info(`[r3f-design] world "${worldId}" crash auto-undo refused by history`);
1006
+ return;
1007
+ }
1008
+ remountRetried = false;
1009
+ editorConsole.error(
1010
+ `Undid “${last.label}”: that edit crashed world "${worldId}" (${
1011
+ err instanceof Error ? err.message : String(err)
1012
+ }). The previous source is restored.`,
1013
+ 'authoring',
1014
+ );
1015
+ scheduleRemount();
1016
+ };
1017
+
1018
+ /** Bumped on every source-change signal; a mount that started before the
1019
+ * latest bump was built from stale bytes. */
1020
+ let writeStamp = 0;
1021
+ const refreshRevisions = new SourceRefreshRevisions();
1022
+ let revisionFallback: ReturnType<typeof setTimeout> | undefined;
1023
+
1024
+ const scheduleRemount = (): void => {
1025
+ if (torndown || suspended) {
1026
+ // Diagnostic, not noise: a remount request that lands on a torn-down or
1027
+ // suspended session is the one state a "world stays Unavailable"
1028
+ // report cannot explain from the console otherwise.
1029
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
1030
+ console.info(
1031
+ `[r3f-design] world "${worldId}" remount skipped: ${torndown ? 'session torn down' : 'suspended for play'}`,
1032
+ );
1033
+ return;
1034
+ }
1035
+ if (remountQueued) return;
1036
+ remountQueued = true;
1037
+ // Small debounce: one remount per write burst (a gesture can touch the
1038
+ // file more than once in quick succession).
1039
+ setTimeout(() => {
1040
+ remountQueued = false;
1041
+ void remount();
1042
+ }, 80);
1043
+ };
1044
+
1045
+ const stopAssetReload = onAssetReload(() => {
1046
+ writeStamp += 1;
1047
+ scheduleRemount();
1048
+ });
1049
+
1050
+ const hot = import.meta.hot;
1051
+ if (hot) {
1052
+ const onUpdate = (): void => {
1053
+ writeStamp += 1;
1054
+ scheduleRemount();
1055
+ };
1056
+ const onSource = (source: RefreshSource): void => refreshRevisions.source(source);
1057
+ const onRefreshed = async (event: {
1058
+ updates: { acceptedPath: string; timestamp: number; explicitImportRequired?: boolean }[];
1059
+ }): Promise<void> => {
1060
+ if (torndown || suspended) return;
1061
+ const relevant = event.updates.filter((update) => refreshRevisions.matches(update));
1062
+ if (!relevant.length) return;
1063
+ if (
1064
+ !mounted ||
1065
+ composite.childAdapters().find((child) => child.worldId === worldId)?.adapter !== adapter
1066
+ ) {
1067
+ // Fast Refresh cannot adopt a root that failed its initial mount.
1068
+ scheduleRemount();
1069
+ return;
1070
+ }
1071
+ const results = await Promise.allSettled(
1072
+ relevant.map(
1073
+ (update) =>
1074
+ import(/* @vite-ignore */ viteUpdateImportPath(update, import.meta.env.BASE_URL)),
1075
+ ),
1076
+ );
1077
+ // Every mounted variant must evaluate. A successful sibling does not
1078
+ // acknowledge a failed instance of the same source file.
1079
+ if (results.every((result) => result.status === 'fulfilled'))
1080
+ refreshRevisions.complete(relevant);
1081
+ if (!refreshRevisions.needsRemount()) clearTimeout(revisionFallback);
1082
+ // The native adapter observes child additions/removals. Property-only
1083
+ // refreshes still need an inspector/viewport notification.
1084
+ store.notifyIngestEdit();
1085
+ };
1086
+ hot.on('vgai:r3f-entry-update', onUpdate);
1087
+ hot.on('vgai:r3f-refresh-source', onSource);
1088
+ hot.on('vite:afterUpdate', onRefreshed);
1089
+ disposeHot = () => {
1090
+ hot.off('vgai:r3f-entry-update', onUpdate);
1091
+ hot.off('vgai:r3f-refresh-source', onSource);
1092
+ hot.off('vite:afterUpdate', onRefreshed);
1093
+ clearTimeout(revisionFallback);
1094
+ };
1095
+ }
1096
+
1097
+ // Collaboration is the reliable fallback when a share tunnel loses Vite's
1098
+ // websocket. Suppress only revisions whose exact bytes completed HMR.
1099
+ let lastRevision = collaborationSnapshot()?.revision ?? 0;
1100
+ const disposeRevisionSub = subscribeCollaborationRevision((revision) => {
1101
+ writeStamp += 1;
1102
+ const snapshot = collaborationSnapshot();
1103
+ const revisions = snapshot?.revisions.filter((r) => r.revision > lastRevision) ?? [];
1104
+ if (revisions.length !== revision - lastRevision) refreshRevisions.missing();
1105
+ lastRevision = revision;
1106
+ const resources = revisions.flatMap((r) => r.resources);
1107
+ refreshRevisions.revision(resources);
1108
+ if (resources.length && !refreshRevisions.needsRemount()) return;
1109
+ // SSE can arrive before or after Vite. Give the matching update a bounded
1110
+ // chance to finish; a dropped/disconnected socket retains cold recovery.
1111
+ clearTimeout(revisionFallback);
1112
+ revisionFallback = setTimeout(() => {
1113
+ if (resources.length && !refreshRevisions.needsRemount()) return;
1114
+ writeStamp += 1;
1115
+ scheduleRemount();
1116
+ }, 2_000);
1117
+ });
1118
+
1119
+ // ------------------------------------------------------------ play handoff
1120
+ let stopRebuildRequested = false;
1121
+ let suspendQueued = false;
1122
+ unsubStore = store.subscribe(() => {
1123
+ if (torndown) return;
1124
+ // Seat handover, BEFORE the deferred visual suspend below: play mode
1125
+ // registers under its own mount id, so holding both seats would make an
1126
+ // unaddressed `vgai eval` ambiguous. This fires at
1127
+ // `store.setPlayState('playing')`, which precedes play's own
1128
+ // `setActiveSystems` — so the two never overlap in either direction.
1129
+ if (store.playState === 'stopped') publishPlane();
1130
+ else withdrawPlane();
1131
+ if (!suspended && !suspendQueued && store.playState !== 'stopped') {
1132
+ // Keep the adopted design scene alive UNDER the play-entry transition.
1133
+ // Suspending at the playState flip swapped the viewport back to the
1134
+ // placeholder editor scene mid-flight — the whole screen flashed the
1135
+ // scene's default clear color until the cross-fade caught up. The
1136
+ // transition settling (cross-fade done, or torn down by an early Stop)
1137
+ // is the correct hand-off point; the guards re-check state because
1138
+ // settle can also mean "play already ended" — in that case the design
1139
+ // session simply stays live, no suspend/rebuild churn at all.
1140
+ suspendQueued = true;
1141
+ onPlayTransitionSettled(() => {
1142
+ suspendQueued = false;
1143
+ if (torndown || suspended || store.playState === 'stopped') return;
1144
+ suspendForPlay();
1145
+ });
1146
+ return;
1147
+ }
1148
+ if (suspended && !stopRebuildRequested && store.playState === 'stopped') {
1149
+ stopRebuildRequested = true;
1150
+ queueEditModeRebuild();
1151
+ }
1152
+ });
1153
+
1154
+ return () => {
1155
+ torndown = true;
1156
+ // biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
1157
+ console.info(`[r3f-design] world "${worldId}" design session torn down`);
1158
+ withdrawPlane();
1159
+ disposeHot?.();
1160
+ stopAssetReload();
1161
+ disposeRevisionSub();
1162
+ clearTimeout(revisionFallback);
1163
+ unsubStore?.();
1164
+ // Normal design-session replacement (including Classic/Glass composition
1165
+ // changes) should preserve editor-global selection. Play suspension is a
1166
+ // different scene lifecycle and deliberately keeps exitPlayScene's normal
1167
+ // clearing semantics.
1168
+ if (!suspended && store.playState === 'stopped' && store.selectedEntityIds.size > 0) {
1169
+ selectionRemountHandoff.remember(store, store.selectedEntityIds);
1170
+ }
1171
+ restoreEditorScene();
1172
+ disposeMounted();
1173
+ };
1174
+ }