@vgai/engine 0.5.22 → 0.5.24

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 (303) 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-seams.d.ts +5 -0
  28. package/dist/adapter/ingest/game-contract-seams.d.ts.map +1 -1
  29. package/dist/adapter/ingest/game-contract-seams.js +1 -0
  30. package/dist/adapter/ingest/game-contract.d.ts +22 -3
  31. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  32. package/dist/adapter/ingest/game-contract.js +0 -3
  33. package/dist/adapter/ingest/scene-capture.d.ts +19 -23
  34. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  35. package/dist/adapter/ingest/scene-capture.js +143 -17
  36. package/dist/adapter/ingest/visible-capture-window.d.ts +34 -0
  37. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  38. package/dist/adapter/native-debug-module.d.ts +40 -5
  39. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  40. package/dist/adapter/native-debug-module.js +36 -9
  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 +2 -2
  45. package/dist/adapter/renderer-config.js +2 -2
  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/core/frame-pacing.d.ts +1 -1
  82. package/dist/core/frame-pacing.js +2 -2
  83. package/dist/core/game-loop.d.ts +1 -3
  84. package/dist/core/game-loop.d.ts.map +1 -1
  85. package/dist/core/game-loop.js +1 -3
  86. package/dist/core/seeded-random.d.ts +1 -1
  87. package/dist/core/sim-clock.d.ts +5 -6
  88. package/dist/core/sim-clock.d.ts.map +1 -1
  89. package/dist/core/sim-clock.js +4 -5
  90. package/dist/core/types.d.ts +1 -3
  91. package/dist/core/types.d.ts.map +1 -1
  92. package/dist/defaults.d.ts +6 -14
  93. package/dist/defaults.d.ts.map +1 -1
  94. package/dist/defaults.js +6 -14
  95. package/dist/dev/register-render-vitals.d.ts +1 -4
  96. package/dist/dev/register-render-vitals.d.ts.map +1 -1
  97. package/dist/dev/register-render-vitals.js +1 -4
  98. package/dist/dev/render-debug-adapter.d.ts +2 -3
  99. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  100. package/dist/dev/render-debug-adapter.js +2 -3
  101. package/dist/dev/render-vitals.d.ts +3 -3
  102. package/dist/dev/render-vitals.js +3 -3
  103. package/dist/dev/webgl-frame-capture.d.ts +1 -1
  104. package/dist/dev/webgl-frame-capture.js +1 -1
  105. package/dist/index.d.ts +2 -5
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +2 -4
  108. package/dist/input/host-pointer.d.ts +122 -0
  109. package/dist/input/host-pointer.d.ts.map +1 -0
  110. package/dist/input/host-pointer.js +160 -0
  111. package/dist/input/input-manager.d.ts +18 -5
  112. package/dist/input/input-manager.d.ts.map +1 -1
  113. package/dist/input/input-manager.js +33 -13
  114. package/dist/input/rebind-controller.d.ts.map +1 -1
  115. package/dist/input/rebind-controller.js +6 -8
  116. package/dist/input/schema.d.ts.map +1 -1
  117. package/dist/input/schema.js +56 -28
  118. package/dist/manifest/filename.d.ts.map +1 -1
  119. package/dist/manifest/filename.js +8 -12
  120. package/dist/manifest/load.d.ts +10 -10
  121. package/dist/manifest/load.d.ts.map +1 -1
  122. package/dist/manifest/load.js +9 -6
  123. package/dist/manifest/schema.d.ts +17 -21
  124. package/dist/manifest/schema.d.ts.map +1 -1
  125. package/dist/manifest/schema.js +45 -35
  126. package/dist/physics/collider-dimensions.d.ts +8 -12
  127. package/dist/physics/collider-dimensions.d.ts.map +1 -1
  128. package/dist/physics/collider-dimensions.js +8 -12
  129. package/dist/pixi/scene-capture.d.ts +11 -13
  130. package/dist/pixi/scene-capture.d.ts.map +1 -1
  131. package/dist/react/world-state.d.ts +1 -1
  132. package/dist/react/world-state.d.ts.map +1 -1
  133. package/dist/render/light-camera-factory.d.ts +5 -7
  134. package/dist/render/light-camera-factory.d.ts.map +1 -1
  135. package/dist/render/light-camera-factory.js +8 -11
  136. package/dist/render/material-factory.js +1 -1
  137. package/dist/render/render-batch-system.d.ts.map +1 -1
  138. package/dist/render/render-features.d.ts +17 -6
  139. package/dist/render/render-features.d.ts.map +1 -1
  140. package/dist/render/render-features.js +0 -28
  141. package/dist/runtime/create-runtime.d.ts +34 -25
  142. package/dist/runtime/create-runtime.d.ts.map +1 -1
  143. package/dist/runtime/create-runtime.js +218 -93
  144. package/dist/runtime/debug-bridge.d.ts +12 -0
  145. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  146. package/dist/runtime/debug-bridge.js +4 -0
  147. package/dist/runtime/debug-registry.d.ts +95 -8
  148. package/dist/runtime/debug-registry.d.ts.map +1 -1
  149. package/dist/runtime/debug-registry.js +24 -3
  150. package/dist/runtime/dev-build.d.ts +2 -2
  151. package/dist/runtime/dev-build.js +2 -2
  152. package/dist/runtime/game-input-seams.d.ts +2 -2
  153. package/dist/runtime/game-input-seams.d.ts.map +1 -1
  154. package/dist/runtime/game.d.ts +102 -41
  155. package/dist/runtime/game.d.ts.map +1 -1
  156. package/dist/runtime/game.js +80 -54
  157. package/dist/runtime/mount-game.d.ts +0 -16
  158. package/dist/runtime/mount-game.d.ts.map +1 -1
  159. package/dist/runtime/mount-game.js +8 -39
  160. package/dist/runtime/mount-manifest.d.ts +22 -14
  161. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  162. package/dist/runtime/mount-manifest.js +18 -18
  163. package/dist/runtime/playtest.d.ts +22 -0
  164. package/dist/runtime/playtest.d.ts.map +1 -0
  165. package/dist/runtime/playtest.js +12 -0
  166. package/dist/runtime/run-ticks-settled.d.ts +34 -0
  167. package/dist/runtime/run-ticks-settled.d.ts.map +1 -0
  168. package/dist/runtime/run-ticks-settled.js +59 -0
  169. package/dist/runtime/state-bridge.d.ts +2 -3
  170. package/dist/runtime/state-bridge.d.ts.map +1 -1
  171. package/dist/runtime/state-bridge.js +1 -0
  172. package/dist/setup/setup-audio.d.ts +12 -0
  173. package/dist/setup/setup-audio.d.ts.map +1 -1
  174. package/dist/setup/setup-audio.js +9 -10
  175. package/dist/setup/setup-renderer.d.ts +2 -25
  176. package/dist/setup/setup-renderer.d.ts.map +1 -1
  177. package/dist/setup/setup-renderer.js +2 -31
  178. package/dist/world3d-react/r3f-root-factory.d.ts +7 -7
  179. package/dist/world3d-react/r3f-root-factory.js +7 -7
  180. package/dist/world3d-react/rapier-physics-bridge.d.ts +9 -8
  181. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  182. package/dist/world3d-react/rapier-physics-bridge.js +100 -48
  183. package/package.json +1 -1
  184. package/schemas/engine-capabilities.json +16 -14
  185. package/schemas/vgai-project.schema.json +35 -26
  186. package/src/adapter/adapter-module.ts +2 -4
  187. package/src/adapter/adapter-surface.ts +1 -1
  188. package/src/adapter/authoring.ts +19 -21
  189. package/src/adapter/binding.ts +468 -0
  190. package/src/adapter/colyseus-networking-adapter.ts +20 -5
  191. package/src/adapter/entrypoint-selection-readers.ts +66 -0
  192. package/src/adapter/entrypoint-selection-source.ts +1 -40
  193. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +1 -48
  194. package/src/adapter/host-context.ts +45 -15
  195. package/src/adapter/index.ts +23 -11
  196. package/src/adapter/ingest/game-contract-seams.ts +1 -0
  197. package/src/adapter/ingest/game-contract.ts +22 -3
  198. package/src/adapter/ingest/scene-capture.ts +190 -39
  199. package/src/adapter/ingest/visible-capture-window.ts +35 -0
  200. package/src/adapter/native-debug-module.ts +66 -9
  201. package/src/adapter/rapier-physics-adapter.ts +76 -20
  202. package/src/adapter/renderer-config.ts +2 -2
  203. package/src/adapter/root-adapter.ts +7 -7
  204. package/src/adapter/system-adapter.ts +43 -8
  205. package/src/adapter/system-seam-contract.ts +3 -3
  206. package/src/adapter/system-slot.ts +291 -0
  207. package/src/adapter/transform.ts +18 -1
  208. package/src/ai/navigation.ts +30 -3
  209. package/src/animation/anim-graph-types.ts +11 -3
  210. package/src/animation/theatre-clock-binding.ts +4 -4
  211. package/src/animation/xstate-animation-binding.ts +2 -2
  212. package/src/animation/xstate-animation-meta.ts +16 -0
  213. package/src/asset-formats/index.ts +4 -7
  214. package/src/asset-formats/render-env.ts +3 -6
  215. package/src/asset-parse-error.ts +2 -5
  216. package/src/audio/bus-mixer.ts +161 -0
  217. package/src/core/frame-pacing.ts +2 -2
  218. package/src/core/game-loop.ts +1 -3
  219. package/src/core/seeded-random.ts +1 -1
  220. package/src/core/sim-clock.ts +5 -6
  221. package/src/core/types.ts +1 -3
  222. package/src/defaults.ts +6 -14
  223. package/src/dev/register-render-vitals.ts +1 -4
  224. package/src/dev/render-debug-adapter.ts +2 -3
  225. package/src/dev/render-vitals.ts +3 -3
  226. package/src/dev/webgl-frame-capture.ts +1 -1
  227. package/src/index.ts +2 -5
  228. package/src/input/host-pointer.ts +230 -0
  229. package/src/input/input-manager.ts +34 -13
  230. package/src/input/input-types.ts +1 -1
  231. package/src/input/rebind-controller.ts +6 -8
  232. package/src/input/schema.ts +216 -188
  233. package/src/manifest/filename.ts +8 -12
  234. package/src/manifest/load.ts +17 -13
  235. package/src/manifest/schema.ts +55 -47
  236. package/src/physics/collider-dimensions.ts +8 -12
  237. package/src/pixi/scene-capture.ts +12 -15
  238. package/src/react/world-state.tsx +1 -1
  239. package/src/render/light-camera-factory.ts +8 -11
  240. package/src/render/material-factory.ts +1 -1
  241. package/src/render/render-batch-system.ts +14 -6
  242. package/src/render/render-features.ts +17 -35
  243. package/src/runtime/create-runtime.ts +270 -108
  244. package/src/runtime/debug-bridge.ts +16 -0
  245. package/src/runtime/debug-registry.ts +121 -8
  246. package/src/runtime/dev-build.ts +2 -2
  247. package/src/runtime/game-input-seams.ts +2 -2
  248. package/src/runtime/game.ts +180 -80
  249. package/src/runtime/mount-game.ts +8 -56
  250. package/src/runtime/mount-manifest.ts +43 -34
  251. package/src/runtime/playtest.ts +22 -0
  252. package/src/runtime/run-ticks-settled.ts +72 -0
  253. package/src/runtime/state-bridge.ts +3 -3
  254. package/src/setup/setup-audio.ts +21 -11
  255. package/src/setup/setup-renderer.ts +2 -61
  256. package/src/world3d-react/r3f-root-factory.tsx +7 -7
  257. package/src/world3d-react/rapier-physics-bridge.tsx +107 -50
  258. package/dist/adapter/setup-three-root-adapter.d.ts +0 -86
  259. package/dist/adapter/setup-three-root-adapter.d.ts.map +0 -1
  260. package/dist/adapter/setup-three-root-adapter.js +0 -908
  261. package/dist/animation/clip-map.d.ts +0 -12
  262. package/dist/animation/clip-map.d.ts.map +0 -1
  263. package/dist/animation/clip-map.js +0 -31
  264. package/dist/asset-registry.d.ts +0 -38
  265. package/dist/asset-registry.d.ts.map +0 -1
  266. package/dist/asset-registry.js +0 -66
  267. package/dist/canvas-react/engine-bridge.d.ts +0 -45
  268. package/dist/canvas-react/engine-bridge.d.ts.map +0 -1
  269. package/dist/canvas-react/engine-bridge.js +0 -45
  270. package/dist/canvas-react/pixi-react-adapter.d.ts +0 -77
  271. package/dist/canvas-react/pixi-react-adapter.d.ts.map +0 -1
  272. package/dist/canvas-react/pixi-react-adapter.js +0 -294
  273. package/dist/canvas-react/world-context.d.ts +0 -98
  274. package/dist/canvas-react/world-context.d.ts.map +0 -1
  275. package/dist/canvas-react/world-context.js +0 -173
  276. package/dist/dev/debug-draw.d.ts +0 -24
  277. package/dist/dev/debug-draw.d.ts.map +0 -1
  278. package/dist/dev/debug-draw.js +0 -73
  279. package/dist/render/auto-batcher.d.ts +0 -34
  280. package/dist/render/auto-batcher.d.ts.map +0 -1
  281. package/dist/render/auto-batcher.js +0 -140
  282. package/dist/render/lod.d.ts +0 -13
  283. package/dist/render/lod.d.ts.map +0 -1
  284. package/dist/render/lod.js +0 -16
  285. package/dist/runtime/types.d.ts +0 -303
  286. package/dist/runtime/types.d.ts.map +0 -1
  287. package/dist/runtime/types.js +0 -1
  288. package/dist/world3d-react/engine-bridge.d.ts +0 -47
  289. package/dist/world3d-react/engine-bridge.d.ts.map +0 -1
  290. package/dist/world3d-react/engine-bridge.js +0 -73
  291. package/dist/world3d-react/r3f-adapter.d.ts +0 -58
  292. package/dist/world3d-react/r3f-adapter.d.ts.map +0 -1
  293. package/dist/world3d-react/r3f-adapter.js +0 -634
  294. package/dist/world3d-react/world-context.d.ts +0 -182
  295. package/dist/world3d-react/world-context.d.ts.map +0 -1
  296. package/dist/world3d-react/world-context.js +0 -235
  297. package/src/adapter/setup-three-root-adapter.ts +0 -1032
  298. package/src/animation/clip-map.ts +0 -34
  299. package/src/asset-registry.ts +0 -89
  300. package/src/dev/debug-draw.ts +0 -80
  301. package/src/render/auto-batcher.ts +0 -168
  302. package/src/render/lod.ts +0 -17
  303. package/src/runtime/types.ts +0 -328
@@ -12,9 +12,9 @@
12
12
  import type * as PIXI from 'pixi.js';
13
13
  import type * as THREE from 'three';
14
14
  import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
15
+ import type { RootBinding } from '../adapter/binding';
15
16
  import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
16
17
  import type { MountedRoot } from '../adapter/root-adapter';
17
- import type { MountedSetupThreeRoot } from '../adapter/setup-three-root-adapter';
18
18
  import type { SystemAdapters } from '../adapter/system-adapter';
19
19
  import type { AssetCache } from '../assets';
20
20
  import type { createGameLoop } from '../core/game-loop';
@@ -41,15 +41,15 @@ import {
41
41
  registerDebugRegistry,
42
42
  } from './debug-registry';
43
43
  import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
44
+ import type { PlaytestContext } from './playtest';
44
45
  import { createStateBridge, type GameStateBridge } from './state-bridge';
45
- import type { GameContext, PlaytestContext } from './types';
46
46
 
47
47
  /** The one loop type — `createGameLoop`'s return shape (fixed-step sim,
48
- * display-rate presentation since WO-P13). */
48
+ * display-rate presentation). */
49
49
  export type GameLoop = ReturnType<typeof createGameLoop>;
50
50
 
51
51
  /**
52
- * WO-P13 — the tail of `PHASE_ORDER` that presents rather than simulates, and
52
+ * The tail of `PHASE_ORDER` that presents rather than simulates, and
53
53
  * therefore runs once per DISPLAY frame (`runRenderFrameImpl`) rather than
54
54
  * once per fixed substep. The same two phases `runTicks`' `skipRenderPhases`
55
55
  * fast-forward skips and `render-control.ts`'s `renderOnce()` drives — one
@@ -68,10 +68,10 @@ const DISPLAY_RATE_PHASES: readonly SystemPhaseName[] = PHASE_ORDER.filter(
68
68
  * freezes. A paused-and-pausable world's `render` phase still runs (with `dt`
69
69
  * forced to `0`, so time-based render effects — e.g. a post-processing pass with
70
70
  * its own internal clock — don't silently keep animating under a "frozen" scene)
71
- * — simulation freezes, the screen does not go black. WO-P13 changed only the
72
- * CADENCE of that: once per display frame under a host that drives
73
- * `runRenderFrame`, once per substep under one that still renders inside
74
- * `runFrame`. `GameLoop.timeScale` (unaffected by this) remains the orthogonal
71
+ * — simulation freezes, the screen does not go black. That render runs once per
72
+ * display frame under a host that drives `runRenderFrame`, and once per substep
73
+ * under one that still renders inside
74
+ * `runFrame`. `GameLoop.timeScale` remains the orthogonal
75
75
  * "speed up/slow down" axis — pausing never touches it, so the host's
76
76
  * accumulator/rAF loop keeps ticking at its normal cadence, which is what makes
77
77
  * "paused still renders" possible.
@@ -119,7 +119,7 @@ export interface PlayState {
119
119
  * `adapter/root-adapter.ts`'s kind-tagged `MountedRoot` types can name it
120
120
  * without an import cycle back to this file. This re-export keeps every
121
121
  * existing `import type { AdapterSurface } from '../runtime/game'` call site
122
- * (`ecs/behavior.ts`, `ecs/component-manager.ts`) compiling unchanged.
122
+ * compiling unchanged.
123
123
  */
124
124
  export type AdapterSurface = AdapterSurfaceLeaf;
125
125
 
@@ -145,6 +145,24 @@ export interface RootFrameHooks {
145
145
  /**
146
146
  * A single world instance: the unit of adaptation.
147
147
  */
148
+ /**
149
+ * One `SystemAdapters` slot a mounted root's `systems` table answered with
150
+ * `absent(reason)` (`adapter/system-slot.ts`) — a POSITIVE absence.
151
+ *
152
+ * Nothing is installed for it. It exists so a reader can tell "this game has no
153
+ * physics" from "nobody looked", which the adapter bag alone cannot do: an
154
+ * unfilled slot has the same shape for both, and only one of them is a work
155
+ * order.
156
+ */
157
+ export interface DeclaredSystemAbsence {
158
+ /** The root whose declaration answered — absences are declared per root, and
159
+ * the slot is only truly absent when NO root fills it. */
160
+ readonly rootId: string;
161
+ readonly slot: keyof SystemAdapters;
162
+ /** The game's own words: what was searched, and what was found. */
163
+ readonly reason: string;
164
+ }
165
+
148
166
  export interface RootInstance {
149
167
  /** Manifest root id. */
150
168
  readonly id: string;
@@ -198,6 +216,39 @@ export interface RootInstance {
198
216
  * instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
199
217
  * all — see `runFrame`). */
200
218
  readonly frame?: RootFrameHooks | undefined;
219
+ /** True from the moment this world's `mounted.dispose()` runs. There is no
220
+ * `unregisterRoot`, so a disposed world stays in `Game.roots`; `runFrame`
221
+ * reads this to skip it entirely — frame hooks AND the opaque
222
+ * `mounted.update` fallback (see the disposed-world guard in
223
+ * `createRootInstance`). */
224
+ readonly disposed: boolean;
225
+ /**
226
+ * THIS ROOT'S WHOLE DECLARATION, BOUND — `adapter/binding.ts`'s
227
+ * {@link RootBinding}, assembled at registration from this instance's own
228
+ * `adapter`/`mounted`/`pausable` plus the {@link RootDeclaration} the
229
+ * resolver produced. Every member of it is a reference to something already
230
+ * reachable through the fields above; it is the ONE place the five protocol
231
+ * families are addressed by name rather than re-assembled per consumer.
232
+ *
233
+ * `null` in exactly ONE honest case, and never as a degrade: the
234
+ * registration carried NO declaration — a bare
235
+ * `registerThreeRoot(game, adapter, mounted)` from a test harness, or a
236
+ * `mountManifestRoots` caller that supplied an already-constructed adapter
237
+ * with no entry namespace behind it. There is no manifest root, no parsed
238
+ * definition and no entry surface to bind, and fabricating them would be
239
+ * the anti-shim rule broken at the seam that exists to hold it. Every root
240
+ * the editor resolves through `resolveAllRoots`/`resolveAllRootEntries`
241
+ * carries a declaration, so its binding is non-null.
242
+ *
243
+ * A mount that exposes no `authoring` does NOT null this out; it costs that
244
+ * root its `projection` and `truth` members only (see
245
+ * {@link RootBinding.projection}). That distinction is load-bearing: the
246
+ * editor supplies native TSX roots' authoring itself, so treating an
247
+ * absent `mounted.authoring` as an absent BINDING made `binding` null for
248
+ * every first-party root and left four perfectly constructible families
249
+ * unreadable.
250
+ */
251
+ readonly binding: RootBinding | null;
201
252
  }
202
253
 
203
254
  /**
@@ -236,6 +287,9 @@ export interface RootInstanceInit {
236
287
  readonly physics2d?: Physics2DRegistry | undefined;
237
288
  readonly camera?: unknown;
238
289
  readonly frame?: RootFrameHooks | undefined;
290
+ /** This root's bound declaration — see {@link RootInstance.binding} for
291
+ * when it is legitimately absent. */
292
+ readonly binding?: RootBinding | null | undefined;
239
293
  }
240
294
 
241
295
  /**
@@ -259,6 +313,7 @@ export function createRootInstance(init: RootInstanceInit): RootInstance {
259
313
  physics2d,
260
314
  camera,
261
315
  frame,
316
+ binding = null,
262
317
  } = init;
263
318
 
264
319
  // A three world with no scene has nothing for `threeScene()` to return —
@@ -302,11 +357,11 @@ export function createRootInstance(init: RootInstanceInit): RootInstance {
302
357
  // read, so this wraps `mounted.dispose` in place (mutating the SAME mount
303
358
  // object every holder of `mounted` shares — calling `mounted.dispose()`
304
359
  // directly, exactly like calling `world.mounted.dispose()`, trips this)
305
- // to flip a private flag, and wraps the frame hooks so they silently no-op
306
- // once that flag is set. `runFrame` itself needs no knowledge of disposal
307
- // a disposed world is skipped by construction from the next call
308
- // onward. (The opaque-world `mounted.update` fallback path is unaffected
309
- // by this guard no opaque-mount two-world scenario exists yet to need it.)
360
+ // to flip a flag this instance exposes as `disposed`, and wraps the frame
361
+ // hooks so they silently no-op once it is set. `runFrame` reads `disposed`
362
+ // and skips the world outright, which is what also covers the OPAQUE
363
+ // `mounted.update` fallback — the path every three root takes now that a
364
+ // mount is a plain `RootAdapter` with no phase-partitioned entry point.
310
365
  let disposed = false;
311
366
  const originalDispose = mounted.dispose.bind(mounted);
312
367
  mounted.dispose = () => {
@@ -337,6 +392,10 @@ export function createRootInstance(init: RootInstanceInit): RootInstance {
337
392
  physics2d,
338
393
  camera,
339
394
  frame: guardedFrame,
395
+ binding,
396
+ get disposed(): boolean {
397
+ return disposed;
398
+ },
340
399
  threeScene(): THREE.Scene {
341
400
  if (kind !== 'three' || !scene) {
342
401
  throw new Error(
@@ -374,26 +433,9 @@ export function createRootInstance(init: RootInstanceInit): RootInstance {
374
433
  };
375
434
  }
376
435
 
377
- /**
378
- * Type guard for whether a `MountedThreeRoot` is a first-party
379
- * `SetupThreeRootAdapter` mount (has a live `GameContext` at `.ctx`). Used to
380
- * decide whether a world's `physics`/`collisions`/`camera` can be populated
381
- * from it — an external
382
- * adapter's mount has none of these first-party handles.
383
- *
384
- * Checks the `firstParty: true` brand (checklist item 1), NOT `'ctx' in
385
- * mounted` — a foreign mount may carry an unrelated `ctx` key, so a
386
- * structural `'ctx' in mounted` check would misfire. The probe is a plain property read, not an
387
- * `instanceof`/value import of `setup-three-root-adapter.ts` — the
388
- * `MountedSetupThreeRoot` import above stays type-only.
389
- */
390
- export function isFirstPartyMounted(mounted: MountedRoot): mounted is MountedSetupThreeRoot {
391
- return (mounted as { firstParty?: unknown }).firstParty === true;
392
- }
393
-
394
436
  /**
395
437
  * One world's half of a debris disposal, as {@link disposeDebrisSubtree} needs
396
- * it. A first-party `GameContext` satisfies this structurally.
438
+ * it.
397
439
  *
398
440
  * `physics`/`rapierWorld` are OPTIONAL: an owner that builds no first-party
399
441
  * Rapier runtime (an R3F world) carries neither, and {@link
@@ -401,8 +443,8 @@ export function isFirstPartyMounted(mounted: MountedRoot): mounted is MountedSet
401
443
  * unconditionally.
402
444
  */
403
445
  export interface DebrisOwner {
404
- readonly physics?: GameContext['physics'];
405
- readonly rapierWorld?: GameContext['rapierWorld'];
446
+ readonly physics?: PhysicsRegistry;
447
+ readonly rapierWorld?: import('@dimforge/rapier3d-compat').World;
406
448
  }
407
449
 
408
450
  /**
@@ -412,7 +454,7 @@ export interface DebrisOwner {
412
454
  */
413
455
  function readRapierHandles(owner: DebrisOwner): {
414
456
  physics: NonNullable<DebrisOwner['physics']>;
415
- rapierWorld: GameContext['rapierWorld'];
457
+ rapierWorld: NonNullable<DebrisOwner['rapierWorld']>;
416
458
  } | null {
417
459
  const { physics, rapierWorld } = owner;
418
460
  if (!physics || !rapierWorld) return null;
@@ -424,15 +466,14 @@ function readRapierHandles(owner: DebrisOwner): {
424
466
  * only *when*; this is the *what*, and it lives here because it is the runtime
425
467
  * that knows about Rapier and shared geometry.
426
468
  *
427
- * The order mirrors `adapter/setup-three-root-adapter.ts`'s world teardown for
469
+ * The order mirrors `adapter/world3d-react/r3f-root-factory.tsx`'s world teardown for
428
470
  * ONE subtree. Within the physics step,
429
471
  * `rapierWorld.removeRigidBody(body)` comes BEFORE `physics.remove(node)`: the
430
472
  * registry's `remove()` only drops index entries, so reversing the two leaks
431
473
  * the Rapier body.
432
474
  *
433
- * `owners` is every world that could own part of the subtree `createGame`
434
- * passes all of its first-party three roots as their `GameContext`, a bare
435
- * adapter mount passes its own ctx. `physics.get` is a no-op for a node the
475
+ * `owners` is every world that could own part of the subtree. `physics.get`
476
+ * is a no-op for a node the
436
477
  * owner does not own, so offering the subtree to each is safe.
437
478
  *
438
479
  * Safe on an object already removed or already disposed: `removeFromParent` on
@@ -473,13 +514,17 @@ export function disposeDebrisSubtree(
473
514
  }
474
515
 
475
516
  /**
476
- * The {@link DebrisOwner}s one registered world contributes: its `GameContext`
477
- * when the mount is first-party, else nothing. Written as a standalone function
517
+ * The {@link DebrisOwner}s one registered world contributes. Written as a
518
+ * standalone function
478
519
  * so `createGame`'s clock disposer is one line and the "which mounts count"
479
520
  * rule has exactly one home.
480
521
  */
481
- function debrisOwnersOf(world: RootInstance): DebrisOwner[] {
482
- return isFirstPartyMounted(world.mounted) ? [world.mounted.ctx] : [];
522
+ function debrisOwnersOf(_world: RootInstance): DebrisOwner[] {
523
+ // No mount carries first-party Rapier handles: physics is a declared
524
+ // system built inside the world's own tree, and the library owns its
525
+ // bodies' lifecycles there. The owner list stays as the disposal seam's
526
+ // shape; today it is always empty.
527
+ return [];
483
528
  }
484
529
 
485
530
  /**
@@ -499,13 +544,13 @@ export interface Game {
499
544
  * Within each phase,
500
545
  * `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
501
546
  * world's engine systems/component ticks/world-bound game systems (e.g.
502
- * `ctx.systems.add`, which stays world-bound to the default world — see
503
- * `runtime/types.ts`). Empty for every existing game (nothing registers
547
+ * `ctx.systems.add`, which stays world-bound to the default world).
548
+ * Empty for every existing game (nothing registers
504
549
  * against it), so an empty runner has no frame cost beyond dispatch.
505
550
  */
506
551
  readonly systems: SystemRunner;
507
552
  /** Declaration-ordered. This is the same array reference `registerRoot`
508
- * mutates, not a snapshot, so holders (e.g. `GameContext.roots`) observe
553
+ * mutates, not a snapshot, so holders observe
509
554
  * later registrations. */
510
555
  readonly roots: ReadonlyArray<RootInstance>;
511
556
  world(id: string): RootInstance | null;
@@ -552,7 +597,22 @@ export interface Game {
552
597
  * `Game` doubles in tests; `installNativeSystemsBindings` refuses loudly
553
598
  * when the hosting Game lacks it.
554
599
  */
555
- installDeclaredSystemAdapters?(rootId: string, slots: Readonly<Partial<SystemAdapters>>): void;
600
+ installDeclaredSystemAdapters?(
601
+ rootId: string,
602
+ slots: Readonly<Partial<SystemAdapters>>,
603
+ absent?: readonly DeclaredSystemAbsence[],
604
+ ): void;
605
+ /**
606
+ * Every slot a mounted root's `systems` table answered with `absent(reason)`,
607
+ * across all roots — the game-scoped read of the positive absences.
608
+ *
609
+ * This is what separates "this game has no networking" from "nobody looked",
610
+ * and it is the ONLY source for that distinction on a native mount: the
611
+ * adapter bag ({@link systemAdapters}) can only say a slot is unfilled, which
612
+ * is the same shape for both. Empty means no root declared any absence — NOT
613
+ * that every slot is answered.
614
+ */
615
+ readonly declaredSystemAbsences?: readonly DeclaredSystemAbsence[];
556
616
  /** The one game-owned `InputManager`, shared by every first-party root. */
557
617
  readonly input: InputManager;
558
618
  /**
@@ -567,7 +627,7 @@ export interface Game {
567
627
  /** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
568
628
  readonly play: PlayState;
569
629
  /**
570
- * WO-P13 — the interpolation alpha the most recent display frame presented
630
+ * The interpolation alpha the most recent display frame presented
571
631
  * at: `accumulator / fixedDt`, in `[0, 1]`. `0` means "exactly on the last
572
632
  * completed fixed state", `0.5` means "halfway to the next one".
573
633
  *
@@ -579,7 +639,7 @@ export interface Game {
579
639
  */
580
640
  readonly renderAlpha: number;
581
641
  /**
582
- * WO-P13 — the `RenderStepped`-shaped host: register a callback that runs
642
+ * The `RenderStepped`-shaped host: register a callback that runs
583
643
  * ONCE PER DISPLAY FRAME, before that frame's `preRender`/`render` phases,
584
644
  * with `(alpha, displayDt)`.
585
645
  *
@@ -654,19 +714,19 @@ export interface GameInternal extends Game {
654
714
  * touches no other world at all — a natural no-op while not paused, since
655
715
  * the frozen set is then empty.
656
716
  *
657
- * WO-P13 addendum: `opts.skipRenderPhases` omits `preRender`+`render` from
717
+ * `opts.skipRenderPhases` omits `preRender`+`render` from
658
718
  * the pass — every other phase, and `endFrame`, run exactly as always. A
659
719
  * host that drives presentation at DISPLAY rate sets it on every substep
660
720
  * and calls {@link runRenderFrame} once per real frame instead
661
721
  * (`create-runtime.ts`). A host that does
662
- * not set it keeps the pre-WO-P13 shape verbatim — which is why
722
+ * not set it renders inside the substep — which is why
663
723
  * `externalDrive` capture (`render-control.ts`'s `simulateSubsteps`, which
664
- * calls `runFrame(fixedDt)` with no opts) is frame-exact and unchanged.
724
+ * calls `runFrame(fixedDt)` with no opts) is frame-exact.
665
725
  * `runTicks` sets it per tick for its `render: 'none' | 'last'` modes.
666
726
  */
667
727
  runFrame(dt: number, opts?: { skipRenderPhases?: boolean }): void;
668
728
  /**
669
- * WO-P13 — run ONE display frame's presentation pass: the registered
729
+ * Run ONE display frame's presentation pass: the registered
670
730
  * `onRenderStep` callbacks, then the `preRender` and `render` phases across
671
731
  * game-scoped systems and every host-driven world, in the same
672
732
  * game-systems-then-worlds order {@link runFrame} uses.
@@ -717,6 +777,18 @@ export interface GameInternal extends Game {
717
777
  * absorbs it exactly as it would absorb any other slow-frame gap (up to
718
778
  * 8 substeps, additional time dropped) — `runTicks` does not need to
719
779
  * (and does not) touch the accumulator itself to make this safe.
780
+ * One consequence worth its own sentence: because the burst is one
781
+ * synchronous JS turn, no React commit can interleave it — a scene
782
+ * RELOAD triggered inside the burst (a translated
783
+ * `reload_current_scene`, a `SceneManager.LoadScene`) leaves the
784
+ * incoming world unmounted for the burst's remaining ticks, which the
785
+ * outgoing world's replacement therefore never simulates. Measured on
786
+ * the starter-kit port: a 20-tick burst spanning a reload left the
787
+ * fresh scene with zero ticks (its census had no player), while the
788
+ * same 20 ticks driven as 1-tick calls interleaved the commit and
789
+ * matched the source engine, whose reloads happen between frames. A
790
+ * caller that can trigger remounts mid-window drives 1-tick bursts
791
+ * (the fidelity driver's `advanceTicks` is the worked example).
720
792
  * - **`opts.render`** (default `'last'`): `'last'` skips the `preRender`/
721
793
  * `render` phases for ticks `0..n-2` and runs the full phase list
722
794
  * (including `preRender`/`render`) on the final tick only — the GGPO
@@ -741,8 +813,8 @@ export interface GameInternal extends Game {
741
813
  runTicks(n: number, opts?: RunTicksOptions): void;
742
814
  /**
743
815
  * ADAPTER-MOUNT surface, not game-facing: an adapter's `mount()` calls
744
- * this (via `host.game` — the classic `SetupThreeRootAdapter` and the R3F
745
- * `createR3FRootContext` both do) to load the game-owned input map ONCE,
816
+ * this (via `host.game` — the R3F
817
+ * `createR3FRootContext` does) to load the game-owned input map ONCE,
746
818
  * even when several roots ask for the same path; competing paths throw
747
819
  * (input is game-owned, so roots cannot load competing maps). GAME code
748
820
  * never calls this — a project's map loads automatically at mount from
@@ -789,7 +861,7 @@ export function createGame(opts: {
789
861
  const input = new InputManager();
790
862
  const stateBridge = createStateBridge();
791
863
  // D15 (T-D15.1) — the game-scoped seeded-random surface every world's
792
- // `ctx.random` aliases (see `setup-three-root-adapter.ts`'s `ctx.random =
864
+ // `ctx.random` aliases (see `world3d-react/r3f-root-factory.tsx`'s `ctx.random =
793
865
  // ...`, wired the same way `ctx.debug` is just below). Constructed
794
866
  // unconditionally (cheap — a handful of closures) regardless of whether
795
867
  // this project ever declares `determinism.seededRandom`; only the BOOT
@@ -822,7 +894,7 @@ export function createGame(opts: {
822
894
  // the CURRENT values.
823
895
  let tick = 0;
824
896
  let simT = 0;
825
- // WO-P13 — the display-rate half. `renderAlpha` is the last alpha
897
+ // The display-rate half. `renderAlpha` is the last alpha
826
898
  // `runRenderFrameImpl` presented at (0 until a host drives one); the set is
827
899
  // the `RenderStepped`-shaped registry `Game.onRenderStep` feeds. Deliberately
828
900
  // NOT beside `tick`/`simT` in meaning: neither of these ever advances sim
@@ -838,7 +910,7 @@ export function createGame(opts: {
838
910
  //
839
911
  // The disposer is supplied HERE rather than inside the clock because
840
912
  // `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
841
- // geometry. It mirrors `setup-three-root-adapter.ts`'s world teardown
913
+ // geometry. It mirrors `world3d-react/r3f-root-factory.tsx`'s world teardown
842
914
  // ordering for ONE subtree.
843
915
  const simClock: SimClockInternal = createSimClock({
844
916
  // Every mount that could own part of the subtree is offered it:
@@ -902,6 +974,10 @@ export function createGame(opts: {
902
974
  * component registration for a declared slot is shadowed (with the
903
975
  * standard warning) rather than silently winning by running later. */
904
976
  const declaredSystemAdapters = new Map<string, Readonly<Partial<SystemAdapters>>>();
977
+ /** Per-root POSITIVE ABSENCES from the same declaration — `absent(reason)`
978
+ * slots. Nothing binds them (there is nothing to bind); they are kept so a
979
+ * reader can tell an answered absence from an unasked question. */
980
+ const declaredAbsences = new Map<string, readonly DeclaredSystemAbsence[]>();
905
981
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-source × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
906
982
  function computeSystemAdapters(): SystemAdapters {
907
983
  // Debug is game-scoped: React hooks, probes, and the built-in time
@@ -994,6 +1070,13 @@ export function createGame(opts: {
994
1070
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fans out TWO independent capability gates (loop, audio) with the same "call it, else report once" shape per world — splitting the two gates into separate loops would duplicate the fan-out, not reduce real complexity
995
1071
  function setRootGates(next: boolean): void {
996
1072
  for (const world of roots) {
1073
+ // A disposed world stays in `roots` (there is no `unregisterRoot`), and
1074
+ // its adapter's `setPaused`/`systems.audio` reach a renderer, ticker or
1075
+ // audio graph that `dispose()` already released — the same reason
1076
+ // `runFrame`/`runRenderFrame`/`runTicks` skip it. Without this, a
1077
+ // pause/resume after a partial teardown calls into freed resources, and
1078
+ // the audio leg re-mutes a context that no longer exists.
1079
+ if (world.disposed) continue;
997
1080
  if (!world.pausable) continue; // pausable:false roots are untouched by design
998
1081
  if (world.mounted.drivesOwnLoop) {
999
1082
  if (world.mounted.setPaused) {
@@ -1028,14 +1111,14 @@ export function createGame(opts: {
1028
1111
  // declare `determinism.seededRandom`).
1029
1112
  if (rngTrapEnabled) rngTrap.enable();
1030
1113
  profiler.beginFrame();
1031
- // D15/T-D15.4 + WO-P13: the render-phase skip. Two callers set it, for the
1114
+ // The render-phase skip. Two callers set it, for the
1032
1115
  // same reason — this substep is not the thing that paints. `runTicks`'s
1033
1116
  // `render: 'none'|'last'` fast-forward sets it on every tick it doesn't
1034
1117
  // want to paint (see `runTicks`'s doc comment on `GameInternal`), and a
1035
1118
  // DISPLAY-RATE host sets it on every substep because `runRenderFrame`
1036
- // paints once per real frame instead. Unlike `onlyFrozen` below it is no
1037
- // longer internal-only: it is part of the public `GameInternal.runFrame`
1038
- // signature, since an external host is now one of those two callers.
1119
+ // paints once per real frame instead. Unlike `onlyFrozen` below it is not
1120
+ // internal-only: it is part of the public `GameInternal.runFrame`
1121
+ // signature, since an external host is one of those two callers.
1039
1122
  const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
1040
1123
  // `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
1041
1124
  // signature — no external caller sets it) — `Game.play.step()` below is the
@@ -1053,6 +1136,7 @@ export function createGame(opts: {
1053
1136
  inputFrameActive = false;
1054
1137
  for (let i = 0; i < n; i++) {
1055
1138
  const world = roots[i]!;
1139
+ if (world.disposed) continue;
1056
1140
  if (onlyFrozen) {
1057
1141
  if (paused && world.pausable && !world.mounted.drivesOwnLoop) {
1058
1142
  inputFrameActive = true;
@@ -1082,6 +1166,7 @@ export function createGame(opts: {
1082
1166
  if (!onlyFrozen) {
1083
1167
  for (let i = 0; i < n; i++) {
1084
1168
  const world = roots[i]!;
1169
+ if (world.disposed) continue;
1085
1170
  if (!world.mounted.drivesOwnLoop) continue;
1086
1171
  if (!paused || !world.pausable || !world.mounted.setPaused) {
1087
1172
  advanced = true;
@@ -1106,6 +1191,7 @@ export function createGame(opts: {
1106
1191
  if (inputFrameActive) systems.runPhase(phase, dt);
1107
1192
  for (let i = 0; i < n; i++) {
1108
1193
  const world = roots[i]!;
1194
+ if (world.disposed) continue;
1109
1195
  if (world.mounted.drivesOwnLoop) continue;
1110
1196
  if (!(paused && world.pausable)) continue; // only the frozen set
1111
1197
  try {
@@ -1122,6 +1208,7 @@ export function createGame(opts: {
1122
1208
  }
1123
1209
  for (let i = 0; i < n; i++) {
1124
1210
  const world = roots[i]!;
1211
+ if (world.disposed) continue;
1125
1212
  if (world.mounted.drivesOwnLoop) continue;
1126
1213
  if (!(paused && world.pausable)) continue;
1127
1214
  advanced = true;
@@ -1158,6 +1245,7 @@ export function createGame(opts: {
1158
1245
  systems.runPhase(phase, dt);
1159
1246
  for (let i = 0; i < n; i++) {
1160
1247
  const world = roots[i]!;
1248
+ if (world.disposed) continue;
1161
1249
  if (world.mounted.drivesOwnLoop) continue;
1162
1250
  // D10/T7.6: a `pausable` world under an active pause
1163
1251
  // skips every phase except `render` — its render still runs, every
@@ -1185,6 +1273,7 @@ export function createGame(opts: {
1185
1273
  }
1186
1274
  for (let i = 0; i < n; i++) {
1187
1275
  const world = roots[i]!;
1276
+ if (world.disposed) continue;
1188
1277
  if (world.mounted.drivesOwnLoop) continue;
1189
1278
  // A fully-frozen world gets no `endFrame`/opaque-`update` call either —
1190
1279
  // there is nothing to "end the frame" of when nothing ran this substep.
@@ -1238,7 +1327,7 @@ export function createGame(opts: {
1238
1327
  }
1239
1328
 
1240
1329
  /**
1241
- * WO-P13 — one display frame's PRESENTATION pass. See
1330
+ * One display frame's PRESENTATION pass. See
1242
1331
  * `GameInternal.runRenderFrame`'s doc comment for the contract; this is the
1243
1332
  * `preRender`+`render` slice of `runFrameImpl`'s phase loop, lifted out and
1244
1333
  * driven by the loop's own per-real-frame callback instead of by the substep
@@ -1286,6 +1375,7 @@ export function createGame(opts: {
1286
1375
  systems.runPhase(phase, displayDt);
1287
1376
  for (let i = 0; i < n; i++) {
1288
1377
  const world = roots[i]!;
1378
+ if (world.disposed) continue;
1289
1379
  if (world.mounted.drivesOwnLoop) continue;
1290
1380
  // D10/T7.6, carried over verbatim in substance: a frozen world still
1291
1381
  // renders (a paused viewport stays painted) but with `dt` forced to
@@ -1334,7 +1424,14 @@ export function createGame(opts: {
1334
1424
  notifySystemAdaptersChanged() {
1335
1425
  for (const listener of systemAdapterListeners) listener();
1336
1426
  },
1337
- installDeclaredSystemAdapters(rootId: string, slots: Readonly<Partial<SystemAdapters>>) {
1427
+ get declaredSystemAbsences() {
1428
+ return [...declaredAbsences.values()].flat();
1429
+ },
1430
+ installDeclaredSystemAdapters(
1431
+ rootId: string,
1432
+ slots: Readonly<Partial<SystemAdapters>>,
1433
+ absent: readonly DeclaredSystemAbsence[] = [],
1434
+ ) {
1338
1435
  if (!roots.some((w) => w.id === rootId)) {
1339
1436
  throw new Error(
1340
1437
  `installDeclaredSystemAdapters: no mounted root is named "${rootId}" ` +
@@ -1348,6 +1445,11 @@ export function createGame(opts: {
1348
1445
  );
1349
1446
  }
1350
1447
  declaredSystemAdapters.set(rootId, slots);
1448
+ // ALWAYS set, never merge: a re-install (an HMR of the entry's `systems`
1449
+ // table) that dropped an `absent()` marker must also drop the recorded
1450
+ // absence, or the coverage report keeps printing a game statement the
1451
+ // game no longer makes.
1452
+ declaredAbsences.set(rootId, absent);
1351
1453
  gameInternal.notifySystemAdaptersChanged();
1352
1454
  },
1353
1455
  input,
@@ -1376,6 +1478,10 @@ export function createGame(opts: {
1376
1478
  // not paused, nothing here runs — step() is a whole-call no-op.
1377
1479
  if (paused) {
1378
1480
  for (const world of roots) {
1481
+ // Same disposed-world guard `runFrameImpl` (and `setRootGates`)
1482
+ // make: a disposed world's `mounted.step` drives a loop whose
1483
+ // resources are already freed.
1484
+ if (world.disposed) continue;
1379
1485
  if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
1380
1486
  if (!world.mounted.setPaused) continue; // never gated — still running
1381
1487
  if (world.mounted.step) {
@@ -1402,8 +1508,8 @@ export function createGame(opts: {
1402
1508
  // `endFrame` called once per registration) and double-dispose-wrapped
1403
1509
  // (`createRootInstance` wraps `mounted.dispose` in place — a second
1404
1510
  // wrap would flip `disposed` and call through on ITS OWN wrapped
1405
- // `originalDispose`, which is harmless today only by accident of
1406
- // `SetupThreeRootAdapter.dispose` being idempotent; a foreign adapter
1511
+ // `originalDispose`, which would be harmless only if the adapter's
1512
+ // dispose happened to be idempotent; a foreign adapter
1407
1513
  // has no such guarantee). Reject it outright instead.
1408
1514
  if (roots.some((w) => w.mounted === world.mounted)) {
1409
1515
  throw new Error(
@@ -1415,14 +1521,14 @@ export function createGame(opts: {
1415
1521
  roots.push(world);
1416
1522
  gameInternal.notifySystemAdaptersChanged();
1417
1523
  // Notify state-bridge subscribers THE INSTANT the world list changes —
1418
- // not just at the next completed `runFrame`. Root-cause fix for the
1419
- // "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI (gate
1420
- // 4): `mountAllRootSpecs` (`create-runtime.ts`) mounts roots
1524
+ // not just at the next completed `runFrame`. Otherwise a UI that lists
1525
+ // worlds hangs on an empty list: `mountAllRootSpecs`
1526
+ // (`create-runtime.ts`) mounts roots
1421
1527
  // SEQUENTIALLY, and a react world's `adapter.mount()`
1422
1528
  // (`resolveDefaultReactAdapter`/`mountOneReactRoot`) renders its tree —
1423
1529
  // synchronously in some React builds, but React 19's concurrent
1424
1530
  // renderer does NOT guarantee a synchronous first commit (see
1425
- // `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
1531
+ // `r3f-root-factory.tsx`'s own doc comment on `onCreated`) — BEFORE the
1426
1532
  // caller calls `registerRoot` for that very world. A `useWorldState`
1427
1533
  // selector reading `g.roots` can therefore render for the first time
1428
1534
  // while `roots` is still missing entries that register moments later.
@@ -1466,17 +1572,11 @@ export function createGame(opts: {
1466
1572
  // world mount), eroding the signal for a genuinely un-observable
1467
1573
  // ingested world.
1468
1574
  // A HOST-DRIVEN mount (`drivesOwnLoop: false`, notably the R3F adapter)
1469
- // is observable through `Game.state` even though it does not carry the
1470
- // classic scene adapter's `firstParty` brand: it ticks inside
1471
- // `runFrame`, so the frame-versioned bridge covers it. Requiring a
1575
+ // is observable through `Game.state` when it ticks inside
1576
+ // `runFrame` the frame-versioned bridge covers it. Requiring a
1472
1577
  // foreign-world `observe` bridge there produces a false warning while
1473
1578
  // the canonical bridge is already live.
1474
- if (
1475
- !isFirstPartyMounted(world.mounted) &&
1476
- world.mounted.drivesOwnLoop &&
1477
- world.kind !== 'dom' &&
1478
- !world.mounted.observe
1479
- ) {
1579
+ if (world.mounted.drivesOwnLoop && world.kind !== 'dom' && !world.mounted.observe) {
1480
1580
  console.warn(
1481
1581
  `[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
1482
1582
  'no state bridge — this mounted game has no `observe` (RootStateObserver); ' +
@@ -1572,7 +1672,7 @@ export function createGame(opts: {
1572
1672
  // root's `mounted.dispose()`, whereas a per-root teardown may be ending
1573
1673
  // just one sub-session while sibling roots keep running (see the
1574
1674
  // disposed-world guard in `runFrame`). It used to be disposed from
1575
- // `setup-three-root-adapter.ts`'s teardown, which meant disposing one of
1675
+ // `world3d-react/r3f-root-factory.tsx`'s teardown, which meant disposing one of
1576
1676
  // two three roots froze `now()` for the whole Game, rejected the other
1577
1677
  // world's pending `delay`s and turned its `after()` calls into silent
1578
1678
  // no-ops. Note the asymmetry that gives the bug away: `seededRandom` and