@vgai/engine 0.5.21 → 0.5.23

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 (339) hide show
  1. package/README.md +6 -3
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +2 -4
  4. package/dist/adapter/adapter-surface.d.ts +1 -1
  5. package/dist/adapter/adapter-surface.js +1 -1
  6. package/dist/adapter/authoring.d.ts +19 -21
  7. package/dist/adapter/authoring.d.ts.map +1 -1
  8. package/dist/adapter/authoring.js +2 -2
  9. package/dist/adapter/binding.d.ts +341 -0
  10. package/dist/adapter/binding.d.ts.map +1 -0
  11. package/dist/adapter/binding.js +148 -0
  12. package/dist/adapter/colyseus-networking-adapter.d.ts.map +1 -1
  13. package/dist/adapter/colyseus-networking-adapter.js +23 -4
  14. package/dist/adapter/entrypoint-selection-readers.d.ts +24 -0
  15. package/dist/adapter/entrypoint-selection-readers.d.ts.map +1 -0
  16. package/dist/adapter/entrypoint-selection-readers.js +63 -0
  17. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -1
  18. package/dist/adapter/entrypoint-selection-source.js +1 -44
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +1 -52
  21. package/dist/adapter/host-context.d.ts +46 -15
  22. package/dist/adapter/host-context.d.ts.map +1 -1
  23. package/dist/adapter/host-context.js +2 -9
  24. package/dist/adapter/index.d.ts +11 -12
  25. package/dist/adapter/index.d.ts.map +1 -1
  26. package/dist/adapter/index.js +9 -10
  27. package/dist/adapter/ingest/game-contract.d.ts +0 -3
  28. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  29. package/dist/adapter/ingest/game-contract.js +0 -3
  30. package/dist/adapter/ingest/scene-capture.d.ts +19 -23
  31. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  32. package/dist/adapter/ingest/scene-capture.js +143 -17
  33. package/dist/adapter/ingest/visible-capture-window.d.ts +34 -0
  34. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  35. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  36. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  37. package/dist/adapter/manifest-interpreter.js +50 -0
  38. package/dist/adapter/native-debug-module.d.ts +71 -3
  39. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  40. package/dist/adapter/native-debug-module.js +146 -10
  41. package/dist/adapter/rapier-physics-adapter.d.ts +31 -1
  42. package/dist/adapter/rapier-physics-adapter.d.ts.map +1 -1
  43. package/dist/adapter/rapier-physics-adapter.js +50 -36
  44. package/dist/adapter/renderer-config.d.ts +3 -3
  45. package/dist/adapter/renderer-config.js +3 -3
  46. package/dist/adapter/root-adapter.d.ts +7 -7
  47. package/dist/adapter/root-adapter.js +4 -4
  48. package/dist/adapter/system-adapter.d.ts +45 -7
  49. package/dist/adapter/system-adapter.d.ts.map +1 -1
  50. package/dist/adapter/system-seam-contract.d.ts +3 -3
  51. package/dist/adapter/system-seam-contract.js +3 -3
  52. package/dist/adapter/system-slot.d.ts +168 -0
  53. package/dist/adapter/system-slot.d.ts.map +1 -0
  54. package/dist/adapter/system-slot.js +229 -0
  55. package/dist/adapter/transform.d.ts +11 -1
  56. package/dist/adapter/transform.d.ts.map +1 -1
  57. package/dist/ai/navigation.d.ts +17 -1
  58. package/dist/ai/navigation.d.ts.map +1 -1
  59. package/dist/ai/navigation.js +26 -5
  60. package/dist/animation/anim-graph-types.d.ts +11 -3
  61. package/dist/animation/anim-graph-types.d.ts.map +1 -1
  62. package/dist/animation/theatre-clock-binding.d.ts +4 -4
  63. package/dist/animation/theatre-clock-binding.js +4 -4
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +2 -2
  66. package/dist/animation/xstate-animation-meta.d.ts +13 -0
  67. package/dist/animation/xstate-animation-meta.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-meta.js +14 -0
  69. package/dist/asset-formats/index.d.ts +4 -7
  70. package/dist/asset-formats/index.d.ts.map +1 -1
  71. package/dist/asset-formats/index.js +4 -7
  72. package/dist/asset-formats/render-env.d.ts +5 -8
  73. package/dist/asset-formats/render-env.d.ts.map +1 -1
  74. package/dist/asset-formats/render-env.js +3 -6
  75. package/dist/asset-parse-error.d.ts +2 -5
  76. package/dist/asset-parse-error.d.ts.map +1 -1
  77. package/dist/asset-parse-error.js +2 -5
  78. package/dist/audio/bus-mixer.d.ts +101 -0
  79. package/dist/audio/bus-mixer.d.ts.map +1 -0
  80. package/dist/audio/bus-mixer.js +115 -0
  81. package/dist/canvas-react/index.d.ts +5 -12
  82. package/dist/canvas-react/index.d.ts.map +1 -1
  83. package/dist/canvas-react/index.js +5 -12
  84. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  85. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  86. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  87. package/dist/core/countdown-timer.d.ts +118 -0
  88. package/dist/core/countdown-timer.d.ts.map +1 -0
  89. package/dist/core/countdown-timer.js +137 -0
  90. package/dist/core/deferred-commands.d.ts +127 -0
  91. package/dist/core/deferred-commands.d.ts.map +1 -0
  92. package/dist/core/deferred-commands.js +132 -0
  93. package/dist/core/frame-pacing.d.ts +1 -1
  94. package/dist/core/frame-pacing.js +2 -2
  95. package/dist/core/game-loop.d.ts +1 -3
  96. package/dist/core/game-loop.d.ts.map +1 -1
  97. package/dist/core/game-loop.js +1 -3
  98. package/dist/core/seeded-random.d.ts +1 -1
  99. package/dist/core/sim-clock.d.ts +19 -8
  100. package/dist/core/sim-clock.d.ts.map +1 -1
  101. package/dist/core/sim-clock.js +18 -7
  102. package/dist/core/types.d.ts +1 -3
  103. package/dist/core/types.d.ts.map +1 -1
  104. package/dist/defaults.d.ts +6 -14
  105. package/dist/defaults.d.ts.map +1 -1
  106. package/dist/defaults.js +6 -14
  107. package/dist/dev/instruments.d.ts +38 -11
  108. package/dist/dev/instruments.d.ts.map +1 -1
  109. package/dist/dev/instruments.js +81 -11
  110. package/dist/dev/register-render-vitals.d.ts +1 -4
  111. package/dist/dev/register-render-vitals.d.ts.map +1 -1
  112. package/dist/dev/register-render-vitals.js +1 -4
  113. package/dist/dev/render-debug-adapter.d.ts +2 -3
  114. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  115. package/dist/dev/render-debug-adapter.js +2 -3
  116. package/dist/dev/render-vitals.d.ts +3 -3
  117. package/dist/dev/render-vitals.js +3 -3
  118. package/dist/dev/webgl-frame-capture.d.ts +1 -1
  119. package/dist/dev/webgl-frame-capture.js +1 -1
  120. package/dist/index.d.ts +2 -5
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -4
  123. package/dist/input/host-pointer.d.ts +122 -0
  124. package/dist/input/host-pointer.d.ts.map +1 -0
  125. package/dist/input/host-pointer.js +160 -0
  126. package/dist/input/input-manager.d.ts +33 -5
  127. package/dist/input/input-manager.d.ts.map +1 -1
  128. package/dist/input/input-manager.js +48 -13
  129. package/dist/input/rebind-controller.d.ts.map +1 -1
  130. package/dist/input/rebind-controller.js +6 -8
  131. package/dist/input/schema.d.ts.map +1 -1
  132. package/dist/input/schema.js +56 -28
  133. package/dist/manifest/filename.d.ts.map +1 -1
  134. package/dist/manifest/filename.js +8 -12
  135. package/dist/manifest/load.d.ts +10 -10
  136. package/dist/manifest/load.d.ts.map +1 -1
  137. package/dist/manifest/load.js +9 -6
  138. package/dist/manifest/schema.d.ts +17 -21
  139. package/dist/manifest/schema.d.ts.map +1 -1
  140. package/dist/manifest/schema.js +45 -35
  141. package/dist/physics/collider-dimensions.d.ts +8 -12
  142. package/dist/physics/collider-dimensions.d.ts.map +1 -1
  143. package/dist/physics/collider-dimensions.js +8 -12
  144. package/dist/pixi/scene-capture.d.ts +11 -13
  145. package/dist/pixi/scene-capture.d.ts.map +1 -1
  146. package/dist/react/world-state.d.ts +1 -1
  147. package/dist/react/world-state.d.ts.map +1 -1
  148. package/dist/render/directional-shadow-fit.d.ts +88 -0
  149. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  150. package/dist/render/directional-shadow-fit.js +133 -0
  151. package/dist/render/environment-capture.d.ts +68 -0
  152. package/dist/render/environment-capture.d.ts.map +1 -0
  153. package/dist/render/environment-capture.js +90 -0
  154. package/dist/render/ibl-override-material.d.ts +97 -0
  155. package/dist/render/ibl-override-material.d.ts.map +1 -0
  156. package/dist/render/ibl-override-material.js +114 -0
  157. package/dist/render/light-camera-factory.d.ts +5 -7
  158. package/dist/render/light-camera-factory.d.ts.map +1 -1
  159. package/dist/render/light-camera-factory.js +8 -11
  160. package/dist/render/material-factory.js +1 -1
  161. package/dist/render/render-batch-system.d.ts.map +1 -1
  162. package/dist/render/render-features.d.ts +17 -6
  163. package/dist/render/render-features.d.ts.map +1 -1
  164. package/dist/render/render-features.js +0 -28
  165. package/dist/runtime/create-runtime.d.ts +34 -25
  166. package/dist/runtime/create-runtime.d.ts.map +1 -1
  167. package/dist/runtime/create-runtime.js +218 -93
  168. package/dist/runtime/debug-registry.d.ts +77 -8
  169. package/dist/runtime/debug-registry.d.ts.map +1 -1
  170. package/dist/runtime/debug-registry.js +3 -3
  171. package/dist/runtime/dev-build.d.ts +2 -2
  172. package/dist/runtime/dev-build.js +2 -2
  173. package/dist/runtime/game-input-seams.d.ts +51 -0
  174. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  175. package/dist/runtime/game-input-seams.js +86 -0
  176. package/dist/runtime/game.d.ts +104 -40
  177. package/dist/runtime/game.d.ts.map +1 -1
  178. package/dist/runtime/game.js +119 -61
  179. package/dist/runtime/mount-game.d.ts +0 -16
  180. package/dist/runtime/mount-game.d.ts.map +1 -1
  181. package/dist/runtime/mount-game.js +26 -41
  182. package/dist/runtime/mount-manifest.d.ts +22 -14
  183. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  184. package/dist/runtime/mount-manifest.js +22 -20
  185. package/dist/runtime/playtest.d.ts +22 -0
  186. package/dist/runtime/playtest.d.ts.map +1 -0
  187. package/dist/runtime/playtest.js +12 -0
  188. package/dist/runtime/state-bridge.d.ts +2 -3
  189. package/dist/runtime/state-bridge.d.ts.map +1 -1
  190. package/dist/runtime/state-bridge.js +1 -0
  191. package/dist/setup/setup-audio.d.ts +12 -0
  192. package/dist/setup/setup-audio.d.ts.map +1 -1
  193. package/dist/setup/setup-audio.js +9 -10
  194. package/dist/setup/setup-renderer.d.ts +2 -25
  195. package/dist/setup/setup-renderer.d.ts.map +1 -1
  196. package/dist/setup/setup-renderer.js +2 -31
  197. package/dist/world3d-react/index.d.ts +15 -25
  198. package/dist/world3d-react/index.d.ts.map +1 -1
  199. package/dist/world3d-react/index.js +15 -25
  200. package/dist/world3d-react/r3f-root-factory.d.ts +28 -34
  201. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  202. package/dist/world3d-react/r3f-root-factory.js +403 -57
  203. package/dist/world3d-react/rapier-physics-bridge.d.ts +30 -17
  204. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  205. package/dist/world3d-react/rapier-physics-bridge.js +143 -58
  206. package/package.json +1 -1
  207. package/schemas/engine-capabilities.json +16 -14
  208. package/schemas/vgai-project.schema.json +35 -26
  209. package/src/adapter/adapter-module.ts +2 -4
  210. package/src/adapter/adapter-surface.ts +1 -1
  211. package/src/adapter/authoring.ts +19 -21
  212. package/src/adapter/binding.ts +468 -0
  213. package/src/adapter/colyseus-networking-adapter.ts +20 -5
  214. package/src/adapter/entrypoint-selection-readers.ts +66 -0
  215. package/src/adapter/entrypoint-selection-source.ts +1 -40
  216. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +1 -48
  217. package/src/adapter/host-context.ts +45 -15
  218. package/src/adapter/index.ts +23 -11
  219. package/src/adapter/ingest/game-contract.ts +0 -3
  220. package/src/adapter/ingest/scene-capture.ts +190 -39
  221. package/src/adapter/ingest/visible-capture-window.ts +35 -0
  222. package/src/adapter/manifest-interpreter.ts +64 -0
  223. package/src/adapter/native-debug-module.ts +248 -15
  224. package/src/adapter/rapier-physics-adapter.ts +76 -20
  225. package/src/adapter/renderer-config.ts +3 -3
  226. package/src/adapter/root-adapter.ts +7 -7
  227. package/src/adapter/system-adapter.ts +43 -8
  228. package/src/adapter/system-seam-contract.ts +3 -3
  229. package/src/adapter/system-slot.ts +291 -0
  230. package/src/adapter/transform.ts +18 -1
  231. package/src/ai/navigation.ts +30 -3
  232. package/src/animation/anim-graph-types.ts +11 -3
  233. package/src/animation/theatre-clock-binding.ts +4 -4
  234. package/src/animation/xstate-animation-binding.ts +2 -2
  235. package/src/animation/xstate-animation-meta.ts +16 -0
  236. package/src/asset-formats/index.ts +4 -7
  237. package/src/asset-formats/render-env.ts +3 -6
  238. package/src/asset-parse-error.ts +2 -5
  239. package/src/audio/bus-mixer.ts +161 -0
  240. package/src/canvas-react/index.ts +5 -21
  241. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  242. package/src/core/countdown-timer.ts +188 -0
  243. package/src/core/deferred-commands.ts +174 -0
  244. package/src/core/frame-pacing.ts +2 -2
  245. package/src/core/game-loop.ts +1 -3
  246. package/src/core/seeded-random.ts +1 -1
  247. package/src/core/sim-clock.ts +19 -8
  248. package/src/core/types.ts +1 -3
  249. package/src/defaults.ts +6 -14
  250. package/src/dev/instruments.ts +108 -11
  251. package/src/dev/register-render-vitals.ts +1 -4
  252. package/src/dev/render-debug-adapter.ts +2 -3
  253. package/src/dev/render-vitals.ts +3 -3
  254. package/src/dev/webgl-frame-capture.ts +1 -1
  255. package/src/index.ts +2 -5
  256. package/src/input/host-pointer.ts +230 -0
  257. package/src/input/input-manager.ts +49 -13
  258. package/src/input/input-types.ts +1 -1
  259. package/src/input/rebind-controller.ts +6 -8
  260. package/src/input/schema.ts +216 -188
  261. package/src/manifest/filename.ts +8 -12
  262. package/src/manifest/load.ts +17 -13
  263. package/src/manifest/schema.ts +55 -47
  264. package/src/physics/collider-dimensions.ts +8 -12
  265. package/src/pixi/scene-capture.ts +12 -15
  266. package/src/react/world-state.tsx +1 -1
  267. package/src/render/directional-shadow-fit.ts +156 -0
  268. package/src/render/environment-capture.ts +102 -0
  269. package/src/render/ibl-override-material.ts +170 -0
  270. package/src/render/light-camera-factory.ts +8 -11
  271. package/src/render/material-factory.ts +1 -1
  272. package/src/render/render-batch-system.ts +14 -6
  273. package/src/render/render-features.ts +17 -35
  274. package/src/runtime/create-runtime.ts +270 -108
  275. package/src/runtime/debug-registry.ts +84 -8
  276. package/src/runtime/dev-build.ts +2 -2
  277. package/src/runtime/game-input-seams.ts +108 -0
  278. package/src/runtime/game.ts +231 -85
  279. package/src/runtime/mount-game.ts +31 -57
  280. package/src/runtime/mount-manifest.ts +47 -36
  281. package/src/runtime/playtest.ts +22 -0
  282. package/src/runtime/state-bridge.ts +3 -3
  283. package/src/setup/setup-audio.ts +21 -11
  284. package/src/setup/setup-renderer.ts +2 -61
  285. package/src/world3d-react/index.ts +15 -31
  286. package/src/world3d-react/r3f-root-factory.tsx +451 -60
  287. package/src/world3d-react/rapier-physics-bridge.tsx +154 -58
  288. package/dist/adapter/setup-three-root-adapter.d.ts +0 -86
  289. package/dist/adapter/setup-three-root-adapter.d.ts.map +0 -1
  290. package/dist/adapter/setup-three-root-adapter.js +0 -908
  291. package/dist/animation/clip-map.d.ts +0 -12
  292. package/dist/animation/clip-map.d.ts.map +0 -1
  293. package/dist/animation/clip-map.js +0 -31
  294. package/dist/asset-registry.d.ts +0 -38
  295. package/dist/asset-registry.d.ts.map +0 -1
  296. package/dist/asset-registry.js +0 -66
  297. package/dist/canvas-react/engine-bridge.d.ts +0 -45
  298. package/dist/canvas-react/engine-bridge.d.ts.map +0 -1
  299. package/dist/canvas-react/engine-bridge.js +0 -45
  300. package/dist/canvas-react/pixi-react-adapter.d.ts +0 -77
  301. package/dist/canvas-react/pixi-react-adapter.d.ts.map +0 -1
  302. package/dist/canvas-react/pixi-react-adapter.js +0 -294
  303. package/dist/canvas-react/world-context.d.ts +0 -98
  304. package/dist/canvas-react/world-context.d.ts.map +0 -1
  305. package/dist/canvas-react/world-context.js +0 -173
  306. package/dist/dev/debug-draw.d.ts +0 -24
  307. package/dist/dev/debug-draw.d.ts.map +0 -1
  308. package/dist/dev/debug-draw.js +0 -73
  309. package/dist/render/auto-batcher.d.ts +0 -34
  310. package/dist/render/auto-batcher.d.ts.map +0 -1
  311. package/dist/render/auto-batcher.js +0 -140
  312. package/dist/render/lod.d.ts +0 -13
  313. package/dist/render/lod.d.ts.map +0 -1
  314. package/dist/render/lod.js +0 -16
  315. package/dist/runtime/types.d.ts +0 -303
  316. package/dist/runtime/types.d.ts.map +0 -1
  317. package/dist/runtime/types.js +0 -1
  318. package/dist/world3d-react/engine-bridge.d.ts +0 -47
  319. package/dist/world3d-react/engine-bridge.d.ts.map +0 -1
  320. package/dist/world3d-react/engine-bridge.js +0 -73
  321. package/dist/world3d-react/r3f-adapter.d.ts +0 -58
  322. package/dist/world3d-react/r3f-adapter.d.ts.map +0 -1
  323. package/dist/world3d-react/r3f-adapter.js +0 -634
  324. package/dist/world3d-react/world-context.d.ts +0 -182
  325. package/dist/world3d-react/world-context.d.ts.map +0 -1
  326. package/dist/world3d-react/world-context.js +0 -235
  327. package/src/adapter/setup-three-root-adapter.ts +0 -1032
  328. package/src/animation/clip-map.ts +0 -34
  329. package/src/asset-registry.ts +0 -89
  330. package/src/canvas-react/engine-bridge.ts +0 -59
  331. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  332. package/src/canvas-react/world-context.ts +0 -253
  333. package/src/dev/debug-draw.ts +0 -80
  334. package/src/render/auto-batcher.ts +0 -168
  335. package/src/render/lod.ts +0 -17
  336. package/src/runtime/types.ts +0 -328
  337. package/src/world3d-react/engine-bridge.ts +0 -86
  338. package/src/world3d-react/r3f-adapter.tsx +0 -717
  339. package/src/world3d-react/world-context.ts +0 -358
@@ -1,5 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { assertNever } from '../adapter/adapter-surface';
3
+ import { createRootBinding, type RootBinding, type RootDeclaration } from '../adapter/binding';
3
4
  import type { CanvasHostContext, DomHostContext, ThreeHostContext } from '../adapter/host-context';
4
5
  import type {
5
6
  MountedPixiRoot,
@@ -7,10 +8,12 @@ import type {
7
8
  MountedRoot,
8
9
  MountedThreeRoot,
9
10
  RootAdapter,
11
+ SurfaceAdapter,
10
12
  } from '../adapter/root-adapter';
11
13
  import { createAssetCache } from '../assets';
12
14
  import { createGameLoop } from '../core/game-loop';
13
15
  import { createHostRenderer } from '../setup/setup-renderer';
16
+ import { getDebugRegistry } from './debug-registry';
14
17
  // TYPE-ONLY: `create-runtime.ts` must never value-import `pixi.js` — a canvas
15
18
  // `RootMountSpec`'s `adapter` is supplied ALREADY-CONSTRUCTED by the caller, so
16
19
  // the host only ever needs the seam's types.
@@ -19,36 +22,11 @@ import {
19
22
  createRootInstance,
20
23
  type Game,
21
24
  type GameInternal,
22
- isFirstPartyMounted,
23
25
  type RootInstance,
24
26
  } from './game';
25
27
  import { createInputRouter, type RouterAdapterRoot, stackOrder } from './input-router';
28
+ import type { PlaytestContext } from './playtest';
26
29
  import { installRenderControlHarness, isRenderModeRequested } from './render-control';
27
- import type { PlaytestContext } from './types';
28
-
29
- /**
30
- * Register a three world for a freshly-mounted game onto the Game shell. Shared
31
- * by `createGameRuntime` and every headless test harness
32
- * (`test/game-root.test.ts`, `test/frame-order.test.ts`,
33
- * `test/game-two-roots.test.ts`) so there is exactly one code path for this
34
- * wiring. `physics`/`collisions`/`camera`/`frame` are populated from the mount's
35
- * first-party `GameContext`/ `MountedSetupThreeRoot` when available, left
36
- * `undefined` otherwise (an external adapter's mount has none of these
37
- * first-party handles — `frame` in particular is what makes
38
- * `GameInternal.runFrame` fall back to a single opaque
39
- * `mounted.update` call per substep for such a world).
40
- */
41
- function firstPartyExtras(
42
- mounted: MountedRoot,
43
- ): Pick<Parameters<typeof createRootInstance>[0], 'physics' | 'collisions' | 'camera' | 'frame'> {
44
- if (!isFirstPartyMounted(mounted)) return {};
45
- return {
46
- physics: mounted.ctx.physics,
47
- collisions: mounted.ctx.collisions,
48
- camera: mounted.ctx.camera,
49
- frame: mounted.frame,
50
- };
51
- }
52
30
 
53
31
  /**
54
32
  * Register a three world onto the Game shell. `opts.id` defaults to
@@ -59,21 +37,68 @@ export function registerThreeRoot(
59
37
  game: GameInternal,
60
38
  adapter: RootAdapter,
61
39
  mounted: MountedThreeRoot,
62
- opts?: { id?: string | undefined; pausable?: boolean | undefined },
40
+ opts?: RegisterRootOptions,
63
41
  ): RootInstance {
42
+ const pausable = opts?.pausable ?? true;
64
43
  const world = createRootInstance({
65
44
  id: opts?.id ?? 'main',
66
45
  kind: 'three',
67
- pausable: opts?.pausable ?? true,
46
+ pausable,
68
47
  adapter,
69
48
  mounted,
70
49
  scene: mounted.scene as THREE.Scene,
71
- ...firstPartyExtras(mounted),
50
+ binding: bindRoot(
51
+ game,
52
+ opts?.declaration,
53
+ {
54
+ surface: 'three',
55
+ adapter: adapter as RootAdapter<'three'>,
56
+ },
57
+ mounted,
58
+ pausable,
59
+ ),
72
60
  });
73
61
  game.registerRoot(world);
74
62
  return world;
75
63
  }
76
64
 
65
+ /** What every `register<Surface>Root` takes beyond the mount itself. */
66
+ export interface RegisterRootOptions {
67
+ readonly id?: string | undefined;
68
+ readonly pausable?: boolean | undefined;
69
+ /**
70
+ * The DECLARATION half of this root's binding, from the resolver that
71
+ * produced `adapter` (`adapter/binding.ts`). Absent for a bare harness
72
+ * registration or a `mountManifestRoots` caller who supplied an
73
+ * already-constructed adapter — see `RootInstance.binding` for why that
74
+ * legitimately yields `null` rather than a fabricated binding.
75
+ */
76
+ readonly declaration?: RootDeclaration | undefined;
77
+ }
78
+
79
+ /**
80
+ * Complete the root's binding at the first moment it CAN be completed: the
81
+ * declaration is what the resolver knew, `mounted`/`adapter`/`pausable` are
82
+ * what mounting produced, and the debug registry is the game's own. Nothing
83
+ * here loads or fabricates — see `createRootBinding`.
84
+ */
85
+ function bindRoot(
86
+ game: GameInternal,
87
+ declaration: RootDeclaration | undefined,
88
+ adapter: SurfaceAdapter,
89
+ mounted: MountedRoot,
90
+ pausable: boolean,
91
+ ): RootBinding | null {
92
+ if (!declaration) return null;
93
+ return createRootBinding({
94
+ ...declaration,
95
+ adapter,
96
+ mounted,
97
+ pausable,
98
+ debugRegistry: getDebugRegistry(game),
99
+ });
100
+ }
101
+
77
102
  /**
78
103
  * Register a canvas world onto the Game shell — the pixi analog of
79
104
  * {@link registerThreeRoot}. `adapter` is the seam's own
@@ -85,16 +110,18 @@ export function registerPixiRoot(
85
110
  game: GameInternal,
86
111
  adapter: RootAdapter<'canvas'>,
87
112
  mounted: MountedPixiRoot,
88
- opts?: { id?: string | undefined; pausable?: boolean | undefined },
113
+ opts?: RegisterRootOptions,
89
114
  ): RootInstance {
90
115
  const id = opts?.id ?? 'main';
116
+ const pausable = opts?.pausable ?? true;
91
117
  const world = createRootInstance({
92
118
  id,
93
119
  kind: 'canvas',
94
- pausable: opts?.pausable ?? true,
120
+ pausable,
95
121
  adapter,
96
122
  mounted,
97
123
  stage: mounted.stage,
124
+ binding: bindRoot(game, opts?.declaration, { surface: 'canvas', adapter }, mounted, pausable),
98
125
  });
99
126
  game.registerRoot(world);
100
127
  return world;
@@ -116,15 +143,17 @@ export function registerReactRoot(
116
143
  adapter: RootAdapter<'dom'>,
117
144
  mounted: MountedReactRoot,
118
145
  container: HTMLElement,
119
- opts?: { id?: string | undefined; pausable?: boolean | undefined },
146
+ opts?: RegisterRootOptions,
120
147
  ): RootInstance {
148
+ const pausable = opts?.pausable ?? true;
121
149
  const world = createRootInstance({
122
150
  id: opts?.id ?? 'main',
123
151
  kind: 'dom',
124
- pausable: opts?.pausable ?? true,
152
+ pausable,
125
153
  adapter,
126
154
  mounted,
127
155
  container,
156
+ binding: bindRoot(game, opts?.declaration, { surface: 'dom', adapter }, mounted, pausable),
128
157
  });
129
158
  game.registerRoot(world);
130
159
  return world;
@@ -156,6 +185,14 @@ export interface RootMountSpecBase {
156
185
  * `input-router.ts`'s `resolveClaimingRoot`.
157
186
  */
158
187
  readonly hitTest?: ((x: number, y: number) => boolean) | undefined;
188
+ /**
189
+ * The DECLARATION half of this root's binding, from whoever resolved
190
+ * `adapter` — see `adapter/binding.ts`'s {@link RootDeclaration}. Carried on
191
+ * the spec because the resolver knows it and the host is the only one who
192
+ * can complete the binding (it needs `mounted`, which does not exist until
193
+ * this spec is mounted).
194
+ */
195
+ readonly declaration?: RootDeclaration | undefined;
159
196
  }
160
197
 
161
198
  export interface ThreeRootMountSpec extends RootMountSpecBase {
@@ -185,9 +222,8 @@ export interface PixiRootMountSpec extends RootMountSpecBase {
185
222
  * opaque mount with nothing to tick).
186
223
  *
187
224
  * Deliberately carries NO `firstParty: true` brand: that brand specifically
188
- * means "has a `.ctx: GameContext`" (`isFirstPartyMounted`,
189
- * `runtime/game.ts`) a react world has no Rapier/ComponentManager/
190
- * GameContext at all, so branding it first-party would be a type lie.
225
+ * a react world has no physics or component runtime at all, so branding it
226
+ * first-party would be a type lie.
191
227
  * `Game.registerRoot`'s "no state bridge" console warning explicitly exempts
192
228
  * `kind: 'dom'` (§7.1-15): a react world has no `observe` BY DESIGN — that
193
229
  * hook is scoped to `useRootObservation` (the ingested/foreign-world case),
@@ -223,7 +259,7 @@ export interface ReactRootAdapter extends RootAdapter<'dom'> {
223
259
  mount(host: DomHostContext): Promise<MountedReactGame>;
224
260
  }
225
261
 
226
- /** WO-34, same reasoning as {@link PixiRootMountSpec} above: the seam's
262
+ /** Same reasoning as {@link PixiRootMountSpec} above: the seam's
227
263
  * `RootAdapter<'dom'>` is what a DOM root actually guarantees. */
228
264
  export interface ReactRootMountSpec extends RootMountSpecBase {
229
265
  readonly kind: 'dom';
@@ -249,9 +285,8 @@ export interface RootsRuntimeConfig {
249
285
  /**
250
286
  * Mirrors `ThreeHostContext.headless` (Node conformance tests — no GPU): skips
251
287
  * real `WebGLRenderer` construction for every three world in this
252
- * session (a stand-in renderer is used instead, exactly as
253
- * `SetupThreeRootAdapter.mount` already special-cases `host.headless`
254
- * internally). Pixijs roots are unaffected — Pixi already falls back to
288
+ * session (a stand-in renderer is used instead; adapters special-case
289
+ * `host.headless` themselves). Pixijs roots are unaffected — Pixi already falls back to
255
290
  * a 2D canvas renderer with no GPU. Never set `true` in a real host.
256
291
  */
257
292
  headless?: boolean | undefined;
@@ -301,11 +336,9 @@ export interface RootsRuntimeConfig {
301
336
  /**
302
337
  * Handle returned by createGameRuntime() for controlling the running game.
303
338
  *
304
- * This is the GENERIC host handle — it has no first-party concepts (no
305
- * `GameContext`, no `GameSetupFn`). First-party features (the live `GameContext`,
306
- * warm-restart hot reload) are reached through `game.defaultRoot.mounted`
307
- * and narrowed to `MountedSetupThreeRoot`
308
- * (the editor does this for HMR/physics-sync — those are inherently first-party).
339
+ * This is the GENERIC host handle — it has no first-party concepts. A
340
+ * root's own surface is reached through `game.defaultRoot.mounted`, typed
341
+ * as the mounted-root contract (`MountedRoot`).
309
342
  *
310
343
  * Root access is explicit through `game.roots`, `game.world(id)`, and
311
344
  * `game.defaultRoot`; the session does not project a surface-specific alias.
@@ -341,9 +374,8 @@ export async function createGameRuntime(config: RootsRuntimeConfig): Promise<Gam
341
374
  // ---------------------------------------------------------------------------
342
375
 
343
376
  /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) three
344
- * roots mirrors `SetupThreeRootAdapter.mount`'s own `headlessComposer`
345
- * pattern (`adapter/setup-three-root-adapter.ts`): a headless first-party
346
- * mount never calls a render-phase method on `host.renderer` at all, so
377
+ * roots: a headless mount never calls a render-phase method on
378
+ * `host.renderer` at all, so
347
379
  * this only needs to satisfy the handful of calls THIS file itself makes
348
380
  * (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
349
381
  * `forceContextLoss` at teardown) — never a real GL call. Node conformance
@@ -372,6 +404,45 @@ interface MountedAdapterRoot {
372
404
  readonly renderer: THREE.WebGLRenderer | undefined;
373
405
  }
374
406
 
407
+ /**
408
+ * Reclaim ONE mounted root: its adapter, the renderer this file constructed for
409
+ * it (including the WebGL context — a browser keeps only a handful alive, so
410
+ * `dispose()` alone is not enough), and its stacked surface element.
411
+ *
412
+ * ONE body, TWO callers, deliberately: the session's `fullCleanup` (a normal
413
+ * stop) and `mountAllRootSpecs`'s partial-mount ROLLBACK (a later root's mount
414
+ * rejected, so the roots that already mounted must not survive the rejection).
415
+ * Those two must never drift — a rollback that reclaims less than a stop is how
416
+ * a failed play leaves a live renderer and a lost GL context behind with no
417
+ * handle anywhere able to reach it.
418
+ *
419
+ * Throws only what the adapter's own `dispose()` throws; every caller wraps it
420
+ * per entry so one bad root cannot abort the rest of the teardown.
421
+ */
422
+ function disposeMountedRoot(entry: MountedAdapterRoot, container: HTMLElement): void {
423
+ try {
424
+ entry.mounted.dispose();
425
+ } finally {
426
+ // Renderer + surface release run even when the adapter's dispose threw:
427
+ // the GL context is the scarce resource, and the caller still sees the
428
+ // error (this rethrows through the `finally`).
429
+ entry.renderer?.dispose();
430
+ entry.renderer?.forceContextLoss();
431
+ // Remove from the element's CURRENT parent, not the mount-time
432
+ // `container`: the editor's Game panel re-parents the live surfaces
433
+ // when its mount element swaps (fill <-> device preset, W2c), and
434
+ // `container.removeChild` would throw NotFoundError after such a move.
435
+ // `container` remains the fallback for hosts whose element stand-ins
436
+ // never wire `parentNode` (headless unit fixtures); an element already
437
+ // detached by such a host is a no-op via the catch.
438
+ try {
439
+ (entry.element.parentNode ?? container).removeChild(entry.element);
440
+ } catch {
441
+ /* already detached — nothing to remove */
442
+ }
443
+ }
444
+ }
445
+
375
446
  interface OneRootResult {
376
447
  readonly mountedEntry: MountedAdapterRoot;
377
448
  readonly routerEntry: RouterAdapterRoot;
@@ -456,7 +527,11 @@ async function mountOneThreeRoot(
456
527
  game,
457
528
  };
458
529
  const mounted = await spec.adapter.mount(host);
459
- registerThreeRoot(game, spec.adapter, mounted, { id: spec.id, pausable: spec.pausable });
530
+ registerThreeRoot(game, spec.adapter, mounted, {
531
+ id: spec.id,
532
+ pausable: spec.pausable,
533
+ declaration: spec.declaration,
534
+ });
460
535
  return {
461
536
  mountedEntry: { id: spec.id, kind: 'three', element: canvas, mounted, renderer },
462
537
  routerEntry: { id: spec.id, zOrder: spec.zOrder ?? 0, canvas, hitTest: spec.hitTest },
@@ -468,7 +543,7 @@ async function mountOneThreeRoot(
468
543
  * `mountOneThreeRoot` above.
469
544
  *
470
545
  * Unlike `mountOneThreeRoot`, this needs no explicit `canvas.style.width`/
471
- * `.height` re-assertion (E4.R1). `pixi-surface.ts` constructs its
546
+ * `.height` re-assertion. The canvas surface constructs its
472
547
  * `Application` with `autoDensity: true`, which makes PIXI ITSELF re-stamp
473
548
  * `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
474
549
  * width/height passed to `resize()`) on every `app.renderer.resize()` call —
@@ -500,6 +575,7 @@ async function mountOnePixiRoot(
500
575
  registerPixiRoot(game, spec.adapter, mounted, {
501
576
  id: spec.id,
502
577
  pausable: spec.pausable,
578
+ declaration: spec.declaration,
503
579
  });
504
580
  return {
505
581
  mountedEntry: {
@@ -560,6 +636,7 @@ async function mountOneReactRoot(
560
636
  registerReactRoot(game, spec.adapter, mounted, layer, {
561
637
  id: spec.id,
562
638
  pausable: spec.pausable,
639
+ declaration: spec.declaration,
563
640
  });
564
641
  return {
565
642
  mountedEntry: {
@@ -579,8 +656,8 @@ async function mountOneReactRoot(
579
656
  * per D5 §1, z-order/ties exactly matching
580
657
  * `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
581
658
  * via `registerThreeRoot`/`registerPixiRoot`/`registerReactRoot` — the
582
- * SAME wiring `test/game-three-plus-pixi.test.ts` proves by hand for the
583
- * three/pixi pair. Worlds MOUNT in `roots` ARRAY order ("manifest
659
+ * SAME wiring `test/create-runtime-worlds.test.ts` proves for the
660
+ * three/canvas pair. Worlds MOUNT in `roots` ARRAY order ("manifest
584
661
  * declaration order" — the frame/registration axis), independent of
585
662
  * `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
586
663
  * and both are honored correctly.
@@ -628,6 +705,10 @@ function installDefaultRootDevGlobals(game: GameInternal): () => void {
628
705
  * `isBottom`, plus the surface lookup and bottom-id needed to derive them. */
629
706
  interface MountAllRootsInputs extends Omit<OneRootContext, 'canvas' | 'isBottom'> {
630
707
  readonly surfacesById: Map<string, HTMLElement>;
708
+ /** The host element the surfaces were stacked into — needed only by the
709
+ * partial-mount rollback, which reclaims them through the same
710
+ * {@link disposeMountedRoot} body a normal stop uses. */
711
+ readonly container: HTMLElement;
631
712
  }
632
713
 
633
714
  interface MountAllRootsResult {
@@ -650,36 +731,60 @@ async function mountAllRootSpecs(
650
731
  bottomId: string | undefined,
651
732
  inputs: MountAllRootsInputs,
652
733
  ): Promise<MountAllRootsResult> {
653
- const { surfacesById, ...shared } = inputs;
734
+ const { surfacesById, container, ...shared } = inputs;
654
735
  const mountedEntries: MountedAdapterRoot[] = [];
655
736
  const routerEntries: RouterAdapterRoot[] = [];
656
737
 
657
- for (const spec of mountSpecs) {
658
- const isBottom = spec.id === bottomId;
659
- if (spec.kind === 'dom') {
660
- const layer = surfacesById.get(spec.id)!;
661
- const { mountedEntry } = await mountOneReactRoot(spec, shared.game, layer);
738
+ try {
739
+ for (const spec of mountSpecs) {
740
+ const isBottom = spec.id === bottomId;
741
+ if (spec.kind === 'dom') {
742
+ const layer = surfacesById.get(spec.id)!;
743
+ const { mountedEntry } = await mountOneReactRoot(spec, shared.game, layer);
744
+ mountedEntries.push(mountedEntry);
745
+ continue;
746
+ }
747
+ const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
748
+ const oneCtx: OneRootContext = { ...shared, canvas, isBottom };
749
+ let mountedEntry: MountedAdapterRoot;
750
+ let routerEntry: RouterAdapterRoot;
751
+ if (spec.kind === 'three') {
752
+ ({ mountedEntry, routerEntry } = await mountOneThreeRoot(spec, oneCtx));
753
+ } else if (spec.kind === 'canvas') {
754
+ ({ mountedEntry, routerEntry } = await mountOnePixiRoot(spec, oneCtx));
755
+ } else {
756
+ // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
757
+ // early `continue` above, so only a hypothetical 4th `AdapterSurface` can
758
+ // reach here — fail loudly rather than silently defaulting. `spec`
759
+ // itself (not `spec.kind`) is what TS has narrowed to `never`, since
760
+ // `RootMountSpec` is a discriminated union at the object level.
761
+ assertNever(spec, 'create-runtime mount loop');
762
+ }
662
763
  mountedEntries.push(mountedEntry);
663
- continue;
764
+ routerEntries.push(routerEntry);
664
765
  }
665
- const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
666
- const oneCtx: OneRootContext = { ...shared, canvas, isBottom };
667
- let mountedEntry: MountedAdapterRoot;
668
- let routerEntry: RouterAdapterRoot;
669
- if (spec.kind === 'three') {
670
- ({ mountedEntry, routerEntry } = await mountOneThreeRoot(spec, oneCtx));
671
- } else if (spec.kind === 'canvas') {
672
- ({ mountedEntry, routerEntry } = await mountOnePixiRoot(spec, oneCtx));
673
- } else {
674
- // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
675
- // early `continue` above, so only a hypothetical 4th `AdapterSurface` can
676
- // reach here — fail loudly rather than silently defaulting. `spec`
677
- // itself (not `spec.kind`) is what TS has narrowed to `never`, since
678
- // `RootMountSpec` is a discriminated union at the object level.
679
- assertNever(spec, 'create-runtime mount loop');
766
+ } catch (err) {
767
+ // PARTIAL MOUNT ROLLBACK. Roots mount sequentially, so a rejection from
768
+ // root N leaves roots 1..N-1 fully live — renderer, WebGL context, R3F
769
+ // tree, adapter registrations — and NOTHING ever gets a handle to them:
770
+ // this function throws instead of returning, so no `GameSession` (and
771
+ // therefore no `stop()`) is ever constructed. Reclaim them here, in
772
+ // REVERSE mount order (the inverse of the order they were built in), then
773
+ // rethrow the ORIGINAL error the mount failure is what the caller must
774
+ // see, never a teardown error raised while cleaning up after it.
775
+ for (let i = mountedEntries.length - 1; i >= 0; i--) {
776
+ const entry = mountedEntries[i]!;
777
+ try {
778
+ disposeMountedRoot(entry, container);
779
+ } catch (disposeErr) {
780
+ // biome-ignore lint/suspicious/noConsole: a rollback failure must be visible; the original mount error is still what we rethrow
781
+ console.error(
782
+ `createGameRuntime: rolling back root "${entry.id}" after a mount failure threw:`,
783
+ disposeErr,
784
+ );
785
+ }
680
786
  }
681
- mountedEntries.push(mountedEntry);
682
- routerEntries.push(routerEntry);
787
+ throw err;
683
788
  }
684
789
  return { mountedEntries, routerEntries };
685
790
  }
@@ -735,6 +840,37 @@ function installSessionRenderHarness(
735
840
  };
736
841
  }
737
842
 
843
+ /**
844
+ * Reclaim what `createRootsGameRuntime` built for ITSELF when the mount
845
+ * rejects. `mountAllRootSpecs` has already rolled back every root that DID
846
+ * mount, so what is left is the Game shell (holding whatever registrations
847
+ * those roots made into it) and the stacked surface elements of the roots that
848
+ * never got to mount. Neither is reachable afterwards: the mount throws instead
849
+ * of returning, so no `GameSession` — and therefore no `stop()` — ever exists.
850
+ *
851
+ * Never throws: the MOUNT error is what the caller must see, so a failure while
852
+ * cleaning up after it is reported and swallowed.
853
+ */
854
+ function reclaimAfterMountFailure(
855
+ game: GameInternal,
856
+ surfaces: Iterable<HTMLElement>,
857
+ container: HTMLElement,
858
+ ): void {
859
+ try {
860
+ game.dispose();
861
+ } catch (disposeErr) {
862
+ // biome-ignore lint/suspicious/noConsole: a failed rollback must be visible; the original mount error is what the caller gets
863
+ console.error('createGameRuntime: disposing the game after a mount failure threw:', disposeErr);
864
+ }
865
+ for (const surface of surfaces) {
866
+ try {
867
+ (surface.parentNode ?? container).removeChild(surface);
868
+ } catch {
869
+ /* already detached by the per-root rollback */
870
+ }
871
+ }
872
+ }
873
+
738
874
  async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
739
875
  const { container, roots: specs, width, height, headless = false, seed, playtest } = config;
740
876
  if (specs.length === 0) {
@@ -836,14 +972,14 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
836
972
  // host's explicit `simulateSubsteps`/`renderOnce` calls — wall-clock time
837
973
  // passing while a screenshot is taken must not move the world.
838
974
  externalDrive: renderMode,
839
- // WO-P13 — sim and presentation are wired to different callbacks here:
975
+ // Sim and presentation are wired to different callbacks here:
840
976
  // `update` runs the gameplay phases once per consumed fixed substep with
841
977
  // `preRender`/`render` withheld, and `render` runs those two once per real
842
978
  // display frame with the interpolation alpha. Both closures are invoked
843
979
  // only from the loop's own rAF arm, which `externalDrive` never arms — so
844
980
  // a capture/offline-export page (`renderMode`) reaches neither, and its
845
981
  // `simulateSubsteps` → `game.runFrame(fixedDt)` path stays frame-exact,
846
- // rendering inside the substep exactly as it did before this flip.
982
+ // rendering inside the substep.
847
983
  update: (dt) => {
848
984
  if (started) game.runFrame(dt, { skipRenderPhases: true });
849
985
  },
@@ -858,16 +994,24 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
858
994
  // `createRootsGameRuntime`'s own cyclomatic complexity down (E4) — same
859
995
  // reason `mountOneThreeRoot`/`mountOnePixiRoot` are already split out
860
996
  // below.
861
- const { mountedEntries, routerEntries } = await mountAllRootSpecs(mountSpecs, bottomId, {
862
- game,
863
- surfacesById,
864
- w,
865
- h,
866
- dpr,
867
- headless,
868
- assets,
869
- antialias: config.antialias,
870
- });
997
+ let mounted: MountAllRootsResult;
998
+ try {
999
+ mounted = await mountAllRootSpecs(mountSpecs, bottomId, {
1000
+ game,
1001
+ surfacesById,
1002
+ container,
1003
+ w,
1004
+ h,
1005
+ dpr,
1006
+ headless,
1007
+ assets,
1008
+ antialias: config.antialias,
1009
+ });
1010
+ } catch (err) {
1011
+ reclaimAfterMountFailure(game, surfacesById.values(), container);
1012
+ throw err;
1013
+ }
1014
+ const { mountedEntries, routerEntries } = mounted;
871
1015
 
872
1016
  // --- Delegating input router (D5 §2a) ---
873
1017
  const router = createInputRouter(container, routerEntries);
@@ -894,37 +1038,55 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
894
1038
  });
895
1039
  let stopping = false;
896
1040
 
1041
+ /**
1042
+ * Reclaim EVERYTHING this session owns, and let no single failure stop that.
1043
+ *
1044
+ * Teardown used to be one `try` over the whole body, so a throwing
1045
+ * `entry.mounted.dispose()` on root 1 skipped root 2's disposal, both
1046
+ * renderers' `forceContextLoss`, `game.dispose()` and the two global
1047
+ * retractions — while `stopComplete` still resolved and the editor still
1048
+ * reported the instance reclaimed. Every step is now isolated: a failure is
1049
+ * COLLECTED and reported, never allowed to abort the steps after it.
1050
+ */
897
1051
  function fullCleanup(): void {
898
1052
  if (stopping) return;
899
1053
  stopping = true;
1054
+ const failures: { readonly step: string; readonly error: unknown }[] = [];
1055
+ /** Run one teardown step; record its failure and keep going. */
1056
+ const step = (name: string, run: () => void): void => {
1057
+ try {
1058
+ run();
1059
+ } catch (error) {
1060
+ failures.push({ step: name, error });
1061
+ }
1062
+ };
900
1063
  try {
901
- loop.stop();
902
- router.dispose();
1064
+ step('loop.stop', () => loop.stop());
1065
+ step('router.dispose', () => router.dispose());
903
1066
  for (const entry of mountedEntries) {
904
- entry.mounted.dispose();
905
- entry.renderer?.dispose();
906
- entry.renderer?.forceContextLoss();
907
- // Remove from the element's CURRENT parent, not the mount-time
908
- // `container`: the editor's Game panel re-parents the live surfaces
909
- // when its mount element swaps (fill <-> device preset, W2c), and
910
- // `container.removeChild` would throw NotFoundError after such a move.
911
- // `container` remains the fallback for hosts whose element stand-ins
912
- // never wire `parentNode` (headless unit fixtures); an element already
913
- // detached by such a host is a no-op via the catch.
914
- try {
915
- (entry.element.parentNode ?? container).removeChild(entry.element);
916
- } catch {
917
- /* already detached — nothing to remove */
918
- }
1067
+ step(`root "${entry.id}"`, () => disposeMountedRoot(entry, container));
919
1068
  }
920
- game.dispose();
921
- retractDevGlobals();
922
- retractRenderHarness?.();
1069
+ step('game.dispose', () => game.dispose());
1070
+ step('dev globals', () => retractDevGlobals());
1071
+ step('render harness', () => retractRenderHarness?.());
923
1072
  } finally {
924
1073
  void Promise.allSettled(
925
1074
  mountedEntries.map((entry) => entry.mounted.disposeComplete ?? Promise.resolve()),
926
1075
  ).then(() => resolveStopComplete());
927
1076
  }
1077
+ if (failures.length > 0) {
1078
+ // LOUD, and after everything else has been reclaimed. `stop()` is called
1079
+ // from hosts that must not be left half-torn-down by an early throw, so
1080
+ // the report comes last — but it IS a report: a silent partial teardown
1081
+ // is the failure this whole restructure exists to end. The editor's
1082
+ // `exitPlayMode` catches it and prints it to the editor console.
1083
+ throw new AggregateError(
1084
+ failures.map((f) => f.error),
1085
+ `Game session teardown failed in ${failures.length} step(s): ${failures
1086
+ .map((f) => f.step)
1087
+ .join(', ')}. Every other step still ran.`,
1088
+ );
1089
+ }
928
1090
  }
929
1091
 
930
1092
  return {