@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
@@ -195,6 +195,41 @@ export interface VisibleCaptureWindow {
195
195
  cancel(): void;
196
196
  }
197
197
 
198
+ /**
199
+ * How a caller configures ONE such wait — the options both `waitForCapture`s
200
+ * take (`adapter/ingest/scene-capture.ts` on the three surface,
201
+ * `pixi/scene-capture.ts` on the canvas surface). Passing a bare number
202
+ * instead is `{ timeoutMs }`, the shape every existing caller uses.
203
+ *
204
+ * It lives HERE, with the window, rather than once per lane, because every
205
+ * field is a parameter of the mechanism above and none of them is a parameter
206
+ * of a surface: `timeoutMs` is {@link startVisibleCaptureWindow}'s `budgetMs`,
207
+ * `visibility` is its {@link VisibilityClock}, and `onWait` hands out the
208
+ * {@link VisibleCaptureWindow} it returns. The two lanes had identical copies
209
+ * of this — which is what a shape with no lane-specific field looks like when
210
+ * it is declared per lane — while both already imported those three names from
211
+ * this module. Genuinely per-surface shapes (`CapturedRuntime` vs
212
+ * `CapturedRuntime2D`: a three scene/renderer against a Pixi stage/app) stay in
213
+ * their own lanes and keep their own names.
214
+ */
215
+ export interface CaptureWaitOptions {
216
+ /** The capture window, in VISIBLE milliseconds (default 10s). */
217
+ timeoutMs?: number | undefined;
218
+ /** Injected in tests; defaults to the document's own visibility. */
219
+ visibility?: VisibilityClock | undefined;
220
+ /**
221
+ * Called with a LIVE view of the wait when it begins, and with `null` the
222
+ * moment it ends (captured, expired, or the window was cancelled).
223
+ *
224
+ * A wait parked on a hidden tab is otherwise indistinguishable from a hung
225
+ * mount: nothing renders, nothing fails, and every door reports silence.
226
+ * This is the seam the editor publishes to `vgai status` so the answer is
227
+ * "waiting for the first visible frame — the tab is hidden", not a countdown
228
+ * that is not running.
229
+ */
230
+ onWait?: ((wait: VisibleCaptureWindow | null) => void) | undefined;
231
+ }
232
+
198
233
  /**
199
234
  * Start a window that calls `onExpire` after `budgetMs` of VISIBLE time.
200
235
  * While browser frame presentation is suspended the timer is disarmed entirely
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The manifest's SOLE INTERPRETER (owner ruling, 2026-08-20 — see
3
+ * docs/ARCHITECTURE-CORE.md §adapter). `vgai.project.json` is static
4
+ * inventory; the adapter seam is the ONE layer that turns it into execution
5
+ * bindings, and host code consumes the manifest through THESE derivations —
6
+ * never by reading `manifest.roots` directly (the guard is
7
+ * `packages/engine/test/manifest-sole-interpreter.test.ts`).
8
+ *
9
+ * Why a doorway and not a convention: one deriver means one direction of
10
+ * truth (manifest → adapter → host). When the project's own `vgai.adapter.ts`
11
+ * later interposes on a derivation, every host path that asked the
12
+ * interpreter gets the project's answer for free; a path that read the raw
13
+ * field would silently keep the mechanical one.
14
+ *
15
+ * The module-free audiences named by the ruling (a bundled CLI's raw
16
+ * discovery over unvalidated files, Vite config time, synchronous
17
+ * dep-optimization, and the loader that CONSTRUCTS the resolution) remain
18
+ * direct readers by design; everything else asks here.
19
+ *
20
+ * These functions are pure and synchronous over an already-loaded manifest —
21
+ * interpreting inventory never evaluates project modules, so any host realm
22
+ * may import this. They are structurally generic so both the loader's
23
+ * `ResolvedGameManifest` and the editor's lenient `EditModeManifest` view
24
+ * flow through the same doorway.
25
+ */
26
+
27
+ /**
28
+ * The declared adapter roots, in mount order (the loader already sorted by
29
+ * `zOrder`, ties by declaration order). This is the mount plan, the readiness
30
+ * roster, and the enumeration every other derivation composes from.
31
+ */
32
+ export function declaredRoots<Root>(manifest: {
33
+ readonly roots: readonly Root[];
34
+ }): readonly Root[] {
35
+ return manifest.roots;
36
+ }
37
+
38
+ /** The root declaring `id`, or undefined — never a silent first-wins pick. */
39
+ export function rootById<Root extends { readonly id: string }>(
40
+ manifest: { readonly roots: readonly Root[] },
41
+ id: string,
42
+ ): Root | undefined {
43
+ return manifest.roots.find((root) => root.id === id);
44
+ }
45
+
46
+ /** Whether any declared root mounts on `surface` (e.g. the editor's
47
+ * "does this project have a Three world" gates). The parameter is the closed
48
+ * surface union, not `string` — a typo'd literal must stay a compile error,
49
+ * not a silent forever-false. */
50
+ export function hasRootOnSurface(
51
+ manifest: { readonly roots: readonly { readonly surface: string }[] },
52
+ surface: 'three' | 'canvas' | 'dom',
53
+ ): boolean {
54
+ return manifest.roots.some((root) => root.surface === surface);
55
+ }
56
+
57
+ /** The declared ingest roots (`adapter.type === 'ingest'`), in mount order.
58
+ * Composite-manifest rules (exactly one, ≥2 is a loud named error) belong to
59
+ * the call sites that state them. */
60
+ export function ingestRoots<
61
+ Root extends { readonly adapter: { readonly type: string } },
62
+ >(manifest: { readonly roots: readonly Root[] }): Root[] {
63
+ return manifest.roots.filter((root) => root.adapter.type === 'ingest');
64
+ }
@@ -20,6 +20,14 @@ import {
20
20
  getDebugRegistry,
21
21
  } from '../runtime/debug-registry';
22
22
  import type { Game } from '../runtime/game';
23
+ import {
24
+ CONTRACT_SYSTEM_SLOTS,
25
+ type ContractSurface,
26
+ type ContractSystemEmptySlot,
27
+ projectContractSystemAdapters,
28
+ } from './ingest/contract-system-adapters';
29
+ import type { VgaiGameSystemAdapters } from './ingest/game-contract';
30
+ import type { SystemAdapters } from './system-adapter';
23
31
 
24
32
  export const NATIVE_INPUT_VALUE_TYPES = [
25
33
  'digital',
@@ -31,11 +39,31 @@ export const NATIVE_INPUT_VALUE_TYPES = [
31
39
  export type NativeInputValueType = (typeof NATIVE_INPUT_VALUE_TYPES)[number];
32
40
  export type NativeInputValue = boolean | number | { readonly x: number; readonly y: number };
33
41
 
42
+ /** A table may be a THUNK, evaluated once at install (after every project
43
+ * module has run), so a game whose declarations accrete during module
44
+ * evaluation — the dev-tools registry's `stat()`/`cheat()` lines beside each
45
+ * mechanic — hands a complete snapshot without ordering its own imports
46
+ * around the debugger. */
47
+ type NativeTable<T> = Readonly<Record<string, T>> | (() => Readonly<Record<string, T>>);
48
+
49
+ /** A command is a plain function, or `{ description, run }` when it wants the
50
+ * session's `game.commands()` listing to say what it does. Argument
51
+ * validation stays inside the function — the game's words at the game's
52
+ * door. */
53
+ export type NativeCommandEntry =
54
+ | ((...args: unknown[]) => unknown)
55
+ | { readonly description?: string; readonly run: (...args: unknown[]) => unknown };
56
+
34
57
  export interface NativeDebugModule {
35
- readonly commands?: Readonly<Record<string, (...args: unknown[]) => unknown>>;
36
- readonly state?: Readonly<Record<string, () => unknown>>;
58
+ readonly commands?: NativeTable<NativeCommandEntry>;
59
+ readonly state?: NativeTable<() => unknown>;
37
60
  readonly input?: {
38
- readonly actions: Readonly<Record<string, NativeInputValueType>>;
61
+ /** May be a thunk, read LIVE on every use — an app whose action set loads
62
+ * asynchronously (an input map fetched at boot) answers with what it has
63
+ * NOW rather than freezing the empty pre-load set at install. */
64
+ readonly actions:
65
+ | Readonly<Record<string, NativeInputValueType>>
66
+ | (() => Readonly<Record<string, NativeInputValueType>>);
39
67
  readonly set: (action: string, value: NativeInputValue) => void;
40
68
  readonly clear: () => void;
41
69
  /** Optional native-scheduler operation. The adapter never synthesizes one. */
@@ -43,6 +71,12 @@ export interface NativeDebugModule {
43
71
  /** Optional native-scheduler operation. The adapter never adds a host phase. */
44
72
  readonly scheduleAtTick?: (tick: number, action: string, value: NativeInputValue) => void;
45
73
  };
74
+ /** The app's own EVENT STREAM, bridged into the session's tick-stamped
75
+ * event log at install — the entry-export replacement for the react
76
+ * emit hook. The app owns the emitter; the host only forwards. */
77
+ readonly events?: {
78
+ readonly subscribe: (listener: (event: string, detail?: unknown) => void) => () => void;
79
+ };
46
80
  }
47
81
 
48
82
  type NativeInputBinding = NonNullable<NativeDebugModule['input']>;
@@ -99,6 +133,15 @@ function parseActions(value: unknown): Record<string, NativeInputValueType> {
99
133
  function parseInput(value: unknown): NativeInputBinding {
100
134
  if (!isRecord(value)) throw new Error('debug.input must be an object.');
101
135
  rejectUnknownKeys(value, ['actions', 'set', 'clear', 'tap', 'scheduleAtTick'], 'debug.input');
136
+ if (typeof value['actions'] === 'function') {
137
+ // A live table: validated per read (requireAction/the actions source), not
138
+ // here — the whole point is that its content changes after install.
139
+ const set = value['set'];
140
+ const clear = value['clear'];
141
+ if (typeof set !== 'function') throw new Error('debug.input.set must be a function.');
142
+ if (typeof clear !== 'function') throw new Error('debug.input.clear must be a function.');
143
+ return value as unknown as NativeInputBinding;
144
+ }
102
145
  const set = value['set'];
103
146
  const clear = value['clear'];
104
147
  const tap = value['tap'];
@@ -134,14 +177,28 @@ export function nativeDebugBindingFromEntryModule(
134
177
  throw new Error(`Root "${rootId}" exports \`debug\`, but it is not an object.`);
135
178
  }
136
179
  const raw = entryModule['debug'];
137
- rejectUnknownKeys(raw, ['commands', 'state', 'input'], `Root "${rootId}" debug export`);
180
+ rejectUnknownKeys(raw, ['commands', 'state', 'input', 'events'], `Root "${rootId}" debug export`);
138
181
 
139
- const commands =
140
- raw['commands'] === undefined ? undefined : functionRecord(raw['commands'], 'debug.commands');
141
- const state =
142
- raw['state'] === undefined ? undefined : functionRecord(raw['state'], 'debug.state');
182
+ // A thunk table defers to install time (see {@link NativeTable}); a plain
183
+ // record validates here, where the error can still name the export.
184
+ const parseTable = (value: unknown, at: string) => {
185
+ if (value === undefined) return undefined;
186
+ if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
187
+ return functionRecord(value, at);
188
+ };
189
+ // Commands allow the `{ description?, run }` entry shape; full validation
190
+ // happens at install (`resolveCommandTable`), where thunks resolve too.
191
+ const parseCommands = (value: unknown, at: string) => {
192
+ if (value === undefined) return undefined;
193
+ if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
194
+ if (!isRecord(value)) throw new Error(`${at} must be an object of named commands.`);
195
+ return value as Readonly<Record<string, NativeCommandEntry>>;
196
+ };
197
+ const commands = parseCommands(raw['commands'], 'debug.commands');
198
+ const state = parseTable(raw['state'], 'debug.state');
143
199
  const input = raw['input'] === undefined ? undefined : parseInput(raw['input']);
144
- if (!commands && !state && !input) {
200
+ const events = raw['events'] === undefined ? undefined : parseEvents(raw['events']);
201
+ if (!commands && !state && !input && !events) {
145
202
  throw new Error(`Root "${rootId}" exports an empty \`debug\` object.`);
146
203
  }
147
204
  return {
@@ -150,10 +207,48 @@ export function nativeDebugBindingFromEntryModule(
150
207
  ...(commands ? { commands } : {}),
151
208
  ...(state ? { state } : {}),
152
209
  ...(input ? { input } : {}),
210
+ ...(events ? { events } : {}),
153
211
  },
154
212
  };
155
213
  }
156
214
 
215
+ function parseEvents(value: unknown): NonNullable<NativeDebugModule['events']> {
216
+ if (!isRecord(value) || typeof value['subscribe'] !== 'function') {
217
+ throw new Error('debug.events must be an object with a subscribe(listener) function.');
218
+ }
219
+ return value as unknown as NonNullable<NativeDebugModule['events']>;
220
+ }
221
+
222
+ /** Resolve a possibly-thunk COMMAND table, validating each entry's shape. */
223
+ function resolveCommandTable(
224
+ table: NativeTable<NativeCommandEntry> | undefined,
225
+ at: string,
226
+ ): Readonly<Record<string, NativeCommandEntry>> {
227
+ if (table === undefined) return {};
228
+ const raw = typeof table === 'function' ? table() : table;
229
+ if (!isRecord(raw)) throw new Error(`${at} must be an object of named commands.`);
230
+ for (const [name, entry] of Object.entries(raw)) {
231
+ if (!name) throw new Error(`${at} contains an empty name.`);
232
+ const ok =
233
+ typeof entry === 'function' ||
234
+ (isRecord(entry) && typeof (entry as { run?: unknown }).run === 'function');
235
+ if (!ok) throw new Error(`${at}.${name} must be a function or { description?, run }.`);
236
+ }
237
+ return raw as Readonly<Record<string, NativeCommandEntry>>;
238
+ }
239
+
240
+ /** Resolve a possibly-thunk table at install time, validating the result. */
241
+ function resolveTable<T>(
242
+ table: NativeTable<T> | undefined,
243
+ at: string,
244
+ ): Readonly<Record<string, T>> {
245
+ if (table === undefined) return {};
246
+ if (typeof table === 'function') {
247
+ return functionRecord(table(), at) as Readonly<Record<string, T>>;
248
+ }
249
+ return table;
250
+ }
251
+
157
252
  function valueMatches(type: NativeInputValueType, value: NativeInputValue): boolean {
158
253
  if (type === 'digital') return typeof value === 'boolean';
159
254
  if (type === 'scalar') return typeof value === 'number' && Number.isFinite(value);
@@ -176,14 +271,19 @@ function installInput(
176
271
  const trace: Array<{ tick: number; action: string; value: NativeInputValue }> = [];
177
272
  let recording = false;
178
273
 
274
+ // LIVE read on every use — see the `actions` field's own comment.
275
+ const liveActions = (): Readonly<Record<string, NativeInputValueType>> =>
276
+ typeof input.actions === 'function' ? input.actions() : input.actions;
277
+
179
278
  const requireAction = (action: string, value: NativeInputValue): NativeInputValueType => {
180
- const type = input.actions[action];
279
+ const actions = liveActions();
280
+ const type = actions[action];
181
281
  if (!type) {
182
282
  throw new DebugError(
183
283
  'INPUT_ACTION_NOT_FOUND',
184
284
  `Native input has no action named "${action}".`,
185
285
  {
186
- registered: Object.keys(input.actions),
286
+ registered: Object.keys(actions),
187
287
  },
188
288
  );
189
289
  }
@@ -271,7 +371,7 @@ function installInput(
271
371
  };
272
372
 
273
373
  registry.setInputActionsSource(binding.rootId, () =>
274
- Object.entries(input.actions).map(([name, valueType]) => ({ name, valueType })),
374
+ Object.entries(liveActions()).map(([name, valueType]) => ({ name, valueType })),
275
375
  );
276
376
  registry.setInputTraceSource(binding.rootId, () => ({
277
377
  version: 1,
@@ -296,12 +396,145 @@ export function installNativeDebugBindings(
296
396
  throw new Error(`Native debug binding names unmounted root "${binding.rootId}".`);
297
397
  }
298
398
  const root = registry.forRoot(binding.rootId);
299
- for (const [name, read] of Object.entries(binding.debug.state ?? {})) {
399
+ const state = resolveTable(binding.debug.state, `Root "${binding.rootId}" debug.state`);
400
+ for (const [name, read] of Object.entries(state)) {
300
401
  root.registerStateProvider(name, read, { tier: 'assisted' });
301
402
  }
302
- for (const [name, command] of Object.entries(binding.debug.commands ?? {})) {
303
- root.registerCommand(name, { locus: 'client' }, command);
403
+ const commands = resolveCommandTable(
404
+ binding.debug.commands,
405
+ `Root "${binding.rootId}" debug.commands`,
406
+ );
407
+ for (const [name, entry] of Object.entries(commands)) {
408
+ const run = typeof entry === 'function' ? entry : entry.run;
409
+ const description = typeof entry === 'function' ? undefined : entry.description;
410
+ root.registerCommand(
411
+ name,
412
+ { locus: 'client', ...(description === undefined ? {} : { description }) },
413
+ run,
414
+ );
304
415
  }
305
416
  if (binding.debug.input) installInput(game, registry, binding, binding.debug.input);
417
+ // The app's emitter → the session's tick-stamped event log. The
418
+ // subscription's disposer is deliberately dropped: it lives exactly as
419
+ // long as this Game's registry does.
420
+ binding.debug.events?.subscribe((event, detail) => root.emit(event, detail));
421
+ }
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // The native `systems` export — the first-party door onto `SystemAdapters`
426
+ // ---------------------------------------------------------------------------
427
+ //
428
+ // The sibling of `debug` on the SAME module surface: a root entry re-exports
429
+ // one `systems` object beside it —
430
+ //
431
+ // export { debug, systems } from './commands';
432
+ //
433
+ // — whose slots are the ONE declarable carrier both realms share,
434
+ // `VgaiGameSystemAdapters` (`ingest/game-contract.ts`; the native engine is
435
+ // the premade 100% implementation of that contract). Validation is the SAME
436
+ // projection the ingest realm uses (`ingest/contract-system-adapters.ts`), so
437
+ // there is one shape law, not two. This door is what retires
438
+ // `ctx.registerSystemAdapter` from component code (ARCHITECTURE-CORE §System
439
+ // adapters: "a project's `vgai.adapter.ts` binds app-owned systems through
440
+ // declared native exports … Components never call `registerSystemAdapter`").
441
+ //
442
+ // Native-realm difference from ingest: a malformed slot THROWS (this is our
443
+ // own code failing its own contract — fail fast), where the ingest projection
444
+ // files a verdict for the coverage report. A `{ present: false, evidence }`
445
+ // slot is accepted as the positive absence it is and binds nothing.
446
+
447
+ /** One root's statically declared, already-validated system-adapter slots. */
448
+ export interface NativeSystemsBinding {
449
+ readonly rootId: string;
450
+ readonly slots: Readonly<Partial<SystemAdapters>>;
451
+ /**
452
+ * The slots this root's `systems` table answered with `absent(reason)` — the
453
+ * positive absences, in the game's own words.
454
+ *
455
+ * They are carried rather than dropped because they are the ONLY thing that
456
+ * can tell a coverage reader "this game has no networking" apart from "nobody
457
+ * ever looked". Nothing is installed for them (that would be the stub adapter
458
+ * `absent()`'s own comment forbids); they exist to be READ — through
459
+ * `binding.observation.entrySystems`, which is the product door the coverage
460
+ * table renders from.
461
+ */
462
+ readonly absent: readonly ContractSystemEmptySlot[];
463
+ }
464
+
465
+ /**
466
+ * Read the single native `systems` export from an already-loaded root module.
467
+ * `surface` is the root's own mount surface when the caller knows it — it
468
+ * feeds the physics keying check (`PHYSICS_KEYING_BY_SURFACE`).
469
+ */
470
+ export function nativeSystemsBindingFromEntryModule(
471
+ rootId: string,
472
+ entryModule: unknown,
473
+ surface?: ContractSurface | undefined,
474
+ ): NativeSystemsBinding | null {
475
+ if (!isRecord(entryModule) || entryModule['systems'] === undefined) return null;
476
+ const raw = entryModule['systems'];
477
+ if (!isRecord(raw)) {
478
+ throw new Error(`Root "${rootId}" exports \`systems\`, but it is not an object.`);
479
+ }
480
+ rejectUnknownKeys(raw, CONTRACT_SYSTEM_SLOTS, `Root "${rootId}" systems export`);
481
+ const projection = projectContractSystemAdapters(
482
+ { systemAdapters: raw as VgaiGameSystemAdapters },
483
+ surface,
484
+ );
485
+ if (projection.malformed.length > 0) {
486
+ throw new Error(
487
+ `Root "${rootId}" systems export is malformed: ` +
488
+ projection.malformed.map((slot) => `${slot.slot} — ${slot.reason}`).join('; '),
489
+ );
490
+ }
491
+ if (Object.keys(projection.bound).length === 0 && projection.empty.length === 0) {
492
+ throw new Error(`Root "${rootId}" exports an empty \`systems\` object.`);
493
+ }
494
+ return { rootId, slots: projection.bound, absent: projection.empty };
495
+ }
496
+
497
+ /**
498
+ * Install every declared binding onto the mounted Game's game-scoped slot
499
+ * table (`Game.installDeclaredSystemAdapters`), where the ordinary
500
+ * `game.systemAdapters` merge picks them up ahead of any lingering component
501
+ * registration for the same root.
502
+ *
503
+ * A root's ABSENCES travel the same call and install NOTHING — they are
504
+ * recorded, never bound, because the whole point of `absent()` is that no
505
+ * adapter exists to bind (see its comment: a marker, never a stub). They are
506
+ * recorded HERE rather than left on the binding alone so the answer is
507
+ * game-scoped, matching the registry every editor panel already reads: a
508
+ * `SystemAdapters` slot is filled by whichever root builds it, so "does this
509
+ * GAME have physics" cannot be answered one root at a time.
510
+ *
511
+ * A root that declares only absences therefore still calls through — skipping
512
+ * it on an empty `slots` map is what would drop exactly the games whose whole
513
+ * declaration is "I have none of these".
514
+ */
515
+ export function installNativeSystemsBindings(
516
+ game: Game,
517
+ bindings: readonly NativeSystemsBinding[],
518
+ ): void {
519
+ if (bindings.length === 0) return;
520
+ const install = game.installDeclaredSystemAdapters;
521
+ if (!install) {
522
+ throw new Error(
523
+ 'Cannot install native systems bindings: this Game does not implement ' +
524
+ 'installDeclaredSystemAdapters.',
525
+ );
526
+ }
527
+ for (const binding of bindings) {
528
+ if (Object.keys(binding.slots).length === 0 && binding.absent.length === 0) continue;
529
+ install.call(
530
+ game,
531
+ binding.rootId,
532
+ binding.slots,
533
+ binding.absent.map((slot) => ({
534
+ rootId: binding.rootId,
535
+ slot: slot.slot,
536
+ reason: slot.evidence,
537
+ })),
538
+ );
306
539
  }
307
540
  }
@@ -29,6 +29,15 @@ import type { Transform, TransformOwner } from './transform';
29
29
  * `@react-three/rapier` pins for R3F worlds (0.19). Their `RigidBody` classes
30
30
  * are unrelated NOMINAL types, so a shared helper that named either one could
31
31
  * not serve both callers.
32
+ *
33
+ * FLIP CONDITION: `@react-three/rapier` pins its rapier EXACTLY (`0.19.2` as
34
+ * of its 2.2.0 — check `npm view @react-three/rapier dependencies`), so an
35
+ * override or engine bump to any OTHER rapier version forks the wasm instance
36
+ * that library initializes against and is refused. This structural split
37
+ * collapses only when the engine's own `@dimforge/rapier3d-compat` and
38
+ * `@react-three/rapier`'s exact pin agree on one version — at that point the
39
+ * two `RigidBody` types are one nominal type again and this interface can
40
+ * name it directly.
32
41
  */
33
42
  export interface RapierEditableBody {
34
43
  isFixed(): boolean;
@@ -53,28 +62,66 @@ export interface RapierEditableBody {
53
62
  * answered differently by each Rapier owner, and a caller that cannot answer
54
63
  * one honestly omits it.
55
64
  */
65
+ /**
66
+ * What asking this Rapier owner about a node id actually yields.
67
+ *
68
+ * THE TWO NEGATIVE ANSWERS ARE DIFFERENT FACTS, and collapsing them into one
69
+ * `undefined` is what let `ownerOf` answer `'editor'` — "the editor drives this
70
+ * node's transform" — for an id this world has never heard of. `'no-body'` is a
71
+ * real measurement about a real node; `'unresolved'` is this implementer saying
72
+ * it cannot answer at all.
73
+ */
74
+ export type RapierBodyLookup =
75
+ | { readonly kind: 'body'; readonly body: RapierEditableBody }
76
+ /** The node exists here and simply has no physics body. */
77
+ | { readonly kind: 'no-body' }
78
+ /** This world does not know this node id. */
79
+ | { readonly kind: 'unresolved' };
80
+
81
+ /** The refusal sentence for a protocol verb aimed at a node this world has
82
+ * never heard of — named once so every verb says the same thing. */
83
+ function unresolvedNodeRefusal(operation: string, nodeId: string): string {
84
+ return (
85
+ `[RapierPhysicsAdapter] ${operation}: this world does not know node id "${nodeId}", so ` +
86
+ 'there is no body to drive. Refusing rather than reporting a completed write — gate on ' +
87
+ "ownerOf(id) !== 'unresolved' before driving physics from a game-scoped adapter."
88
+ );
89
+ }
90
+
56
91
  export function createRapierBodyEditing(
57
- bodyFor: (nodeId: string) => RapierEditableBody | null | undefined,
92
+ lookup: (nodeId: string) => RapierBodyLookup,
58
93
  ): Pick<PhysicsAdapter, 'ownerOf' | 'freeze' | 'commit' | 'unfreeze'> {
59
94
  /** Saved body types while frozen, so unfreeze can restore them. */
60
95
  const frozen = new Map<RapierEditableBody, number>();
61
96
 
97
+ /** The body for a verb that must actually reach one, or `null` when this node
98
+ * genuinely has no physics. THROWS for an unknown id: the three verbs below
99
+ * return `void`, so a silent return is indistinguishable from a completed
100
+ * write, and `commit` in particular is a WRITE the caller believes landed. */
101
+ const bodyForWrite = (operation: string, nodeId: string): RapierEditableBody | null => {
102
+ const answer = lookup(nodeId);
103
+ if (answer.kind === 'unresolved') throw new Error(unresolvedNodeRefusal(operation, nodeId));
104
+ return answer.kind === 'body' ? answer.body : null;
105
+ };
106
+
62
107
  return {
63
108
  ownerOf(nodeId: string): TransformOwner {
64
- const body = bodyFor(nodeId);
65
- if (!body) return 'editor';
66
- // A fixed body never moves on its own the editor owns its transform.
67
- return body.isFixed() ? 'editor' : 'physics';
109
+ const answer = lookup(nodeId);
110
+ if (answer.kind === 'unresolved') return 'unresolved';
111
+ // A node with no body, and a FIXED body (which never moves on its own),
112
+ // are both transforms the editor drives.
113
+ if (answer.kind === 'no-body') return 'editor';
114
+ return answer.body.isFixed() ? 'editor' : 'physics';
68
115
  },
69
116
  freeze(nodeId: string): void {
70
- const body = bodyFor(nodeId);
117
+ const body = bodyForWrite('freeze', nodeId);
71
118
  if (!body || frozen.has(body)) return;
72
119
  frozen.set(body, body.bodyType());
73
120
  // Kinematic-position: the body stops simulating but tracks the pose we set.
74
121
  body.setBodyType(2 /* KinematicPositionBased */, true);
75
122
  },
76
123
  commit(nodeId: string, t: Transform): void {
77
- const body = bodyFor(nodeId);
124
+ const body = bodyForWrite('commit', nodeId);
78
125
  if (!body) return;
79
126
  body.setTranslation({ x: t.position[0], y: t.position[1], z: t.position[2] }, true);
80
127
  body.setRotation(
@@ -83,7 +130,7 @@ export function createRapierBodyEditing(
83
130
  );
84
131
  },
85
132
  unfreeze(nodeId: string): void {
86
- const body = bodyFor(nodeId);
133
+ const body = bodyForWrite('unfreeze', nodeId);
87
134
  if (!body) return;
88
135
  const prev = frozen.get(body);
89
136
  if (prev !== undefined) {
@@ -149,22 +196,31 @@ export function createRapierPhysicsAdapter(
149
196
  return contactBuf.subarray(0, contactCount * 3);
150
197
  };
151
198
 
152
- /** The one place this adapter crosses from node id into Three's vocabulary. */
153
- const bodyFor = (nodeId: string): RAPIER.RigidBody | undefined => {
154
- const o = resolve(nodeId);
155
- return o ? registry.get(o)?.body : undefined;
199
+ /** The one place this adapter crosses from node id into Three's vocabulary
200
+ * and the one place that can tell an UNKNOWN node from one with no body. */
201
+ const lookup = (nodeId: string): RapierBodyLookup => {
202
+ const object = resolve(nodeId);
203
+ if (!object) return { kind: 'unresolved' };
204
+ const body = registry.get(object)?.body;
205
+ return body ? { kind: 'body', body } : { kind: 'no-body' };
156
206
  };
157
207
 
158
- return {
159
- ...createRapierBodyEditing(bodyFor),
160
- debugDraw: () => physics?.debugMesh ?? null,
161
- setDebugDrawEnabled(enabled: boolean): void {
162
- if (!physics) return;
208
+ const adapter: PhysicsAdapter = { ...createRapierBodyEditing(lookup) };
209
+ // The debug-draw and contact capabilities exist only where a physics CONTEXT
210
+ // does. They used to be attached unconditionally and then no-op internally:
211
+ // `setDebugDrawEnabled(true)` returned as if it had enabled something, and
212
+ // `contactPoints()` returned an empty view — "we looked and there are no
213
+ // contacts" — for a world with no simulation at all. Presence mirrors the
214
+ // capability, so `physics-debug.ts`'s existing `?.()` calls now see absence.
215
+ if (physics) {
216
+ adapter.debugDraw = () => physics.debugMesh;
217
+ adapter.setDebugDrawEnabled = (enabled: boolean): void => {
163
218
  // Same two fields the in-game KeyP toggle flips — the adapter's render
164
219
  // system reads `debugEnabled` and feeds `debugRender()` into the mesh.
165
220
  physics.debugEnabled = enabled;
166
221
  physics.debugMesh.visible = enabled;
167
- },
168
- contactPoints: collectContactPoints,
169
- };
222
+ };
223
+ adapter.contactPoints = collectContactPoints;
224
+ }
225
+ return adapter;
170
226
  }
@@ -4,7 +4,7 @@
4
4
  * It lives in the ADAPTER SEAM, not in a surface entry, because `WorldRendererConfig` IS part of
5
5
  * the root contract: `MountedThreeRoot.rendererConfig` reports it (`./root-adapter.ts`) and
6
6
  * `./root-seam-contract.ts` already lists it as a contract field. The seam defines the shape; the
7
- * surface entries and the editor CONFORM to it — `world3d-react/r3f-adapter.tsx` is the declarer,
7
+ * surface entries and the editor CONFORM to it — `world3d-react/r3f-root-factory.tsx` is the declarer,
8
8
  * `runtime/create-runtime.ts` the host, `editor/src/components/ViewportPanel.tsx` the editor's
9
9
  * applier. Do NOT move it back under `world3d-react/`: nothing here touches react, and a core
10
10
  * adapter file reaching into a react entry is exactly what `react-core-import-ban.test.ts` forbids
@@ -17,7 +17,7 @@
17
17
  * engine's pipeline — an imported Godot 3 GLES2 game does gamma-space lighting with no tonemapper
18
18
  * at all, so ACES quietly desaturates and darkens every colour its author picked.
19
19
  *
20
- * So a world may DECLARE the pipeline it was authored for, and `createR3FAdapter` applies it to the
20
+ * So a mount may DECLARE the pipeline its world was authored for, and the mounting adapter applies it to the
21
21
  * host's renderer for the life of the mount, restoring what it found on dispose. Three properties
22
22
  * of that shape are load-bearing:
23
23
  *
@@ -90,7 +90,7 @@ export interface WorldRendererConfig {
90
90
  * Apply `config` to `renderer`, returning the restore function that puts back what was there.
91
91
  *
92
92
  * `three` is passed in rather than imported for values so the enum constants come from the HOST's
93
- * three instance — the same identity rule `r3f-adapter.tsx` follows for the scene and camera.
93
+ * three instance — the same identity rule `r3f-root-factory.tsx` follows for the scene and camera.
94
94
  */
95
95
  export function applyWorldRendererConfig(
96
96
  three: typeof THREE,
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * RootAdapter — the host ⇄ runtime contract. A ROOT is *anything that mounts*.
3
3
  *
4
- * This is the interface the host depends on. `GameSetupFn` is private to one
5
- * implementer (`SetupThreeRootAdapter`); first-party content and unmodified
6
- * external games are peer root adapters. A game is the manifest's complete
7
- * roots array, while each adapter owns exactly one root.
4
+ * This is the interface the host depends on. First-party content and
5
+ * unmodified external games are peer root adapters. A game is the
6
+ * manifest's complete roots array, while each adapter owns exactly one
7
+ * root.
8
8
  */
9
9
 
10
10
  import type { Container } from 'pixi.js';
@@ -136,8 +136,8 @@ export interface MountedReactRoot extends MountedRootBase {
136
136
  /** Every kind of live, mounted world. */
137
137
  export type MountedRoot = MountedThreeRoot | MountedPixiRoot | MountedReactRoot;
138
138
 
139
- /** Map a {@link AdapterSurface} to its mounted-world shape (mirrors `NodeOf`/
140
- * `BodyOf`/`ColliderOf` in `ecs/behavior.ts`) — lets generic code over
139
+ /** Map a {@link AdapterSurface} to its mounted-world shape lets generic
140
+ * code over
141
141
  * `K extends AdapterSurface` name the right surface without a manual union. */
142
142
  export type MountedRootFor<K extends AdapterSurface> = K extends 'three'
143
143
  ? MountedThreeRoot
@@ -174,7 +174,7 @@ export interface RootAdapter<K extends AdapterSurface = 'three'> {
174
174
  }
175
175
 
176
176
  /**
177
- * One surface tag paired with an adapter typed FOR that surface (WO-34).
177
+ * One surface tag paired with an adapter typed FOR that surface.
178
178
  *
179
179
  * A resolver that turns a manifest root into a mountable
180
180
  * adapter handles every surface in one function, so its return type has to