@vgai/engine 0.5.15 → 0.5.17

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 (220) hide show
  1. package/dist/adapter/adapter-module.d.ts +229 -33
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +222 -50
  4. package/dist/adapter/authoring-seam-contract.d.ts +530 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -0
  6. package/dist/adapter/authoring-seam-contract.js +162 -0
  7. package/dist/adapter/authoring.d.ts +237 -50
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/authoring.js +2 -1
  10. package/dist/adapter/entrypoint-selection-source.d.ts +35 -0
  11. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -0
  12. package/dist/adapter/entrypoint-selection-source.js +151 -0
  13. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +7 -3
  14. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  15. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +145 -27
  16. package/dist/adapter/first-party-systems.d.ts +13 -3
  17. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  18. package/dist/adapter/first-party-systems.js +10 -3
  19. package/dist/adapter/index.d.ts +7 -2
  20. package/dist/adapter/index.d.ts.map +1 -1
  21. package/dist/adapter/index.js +4 -0
  22. package/dist/adapter/ingest/contract-system-adapters.d.ts +1 -1
  23. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  24. package/dist/adapter/ingest/contract-system-adapters.js +13 -25
  25. package/dist/adapter/ingest/game-contract-seams.d.ts +214 -0
  26. package/dist/adapter/ingest/game-contract-seams.d.ts.map +1 -0
  27. package/dist/adapter/ingest/game-contract-seams.js +62 -0
  28. package/dist/adapter/ingest/game-contract.d.ts +5 -3
  29. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  30. package/dist/adapter/ingest/game-contract.js +4 -1
  31. package/dist/adapter/ingest/merge-debug-adapters.d.ts +52 -0
  32. package/dist/adapter/ingest/merge-debug-adapters.d.ts.map +1 -0
  33. package/dist/adapter/ingest/merge-debug-adapters.js +141 -0
  34. package/dist/adapter/ingest/observation-debug-adapter.d.ts +56 -0
  35. package/dist/adapter/ingest/observation-debug-adapter.d.ts.map +1 -0
  36. package/dist/adapter/ingest/observation-debug-adapter.js +131 -0
  37. package/dist/adapter/ingest/scene-capture.d.ts +38 -13
  38. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  39. package/dist/adapter/ingest/scene-capture.js +101 -24
  40. package/dist/adapter/ingest/visible-capture-window.d.ts +22 -5
  41. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  42. package/dist/adapter/ingest/visible-capture-window.js +46 -7
  43. package/dist/adapter/native-debug-module.d.ts +43 -0
  44. package/dist/adapter/native-debug-module.d.ts.map +1 -0
  45. package/dist/adapter/native-debug-module.js +230 -0
  46. package/dist/adapter/object3d-authoring-subject.d.ts +32 -0
  47. package/dist/adapter/object3d-authoring-subject.d.ts.map +1 -0
  48. package/dist/adapter/object3d-authoring-subject.js +25 -0
  49. package/dist/adapter/root-adapter.d.ts +25 -56
  50. package/dist/adapter/root-adapter.d.ts.map +1 -1
  51. package/dist/adapter/root-adapter.js +4 -20
  52. package/dist/adapter/root-seam-contract.d.ts +129 -0
  53. package/dist/adapter/root-seam-contract.d.ts.map +1 -0
  54. package/dist/adapter/root-seam-contract.js +37 -0
  55. package/dist/adapter/seam-evidence.d.ts +118 -0
  56. package/dist/adapter/seam-evidence.d.ts.map +1 -0
  57. package/dist/adapter/seam-evidence.js +231 -0
  58. package/dist/adapter/setup-three-root-adapter.d.ts +2 -3
  59. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  60. package/dist/adapter/setup-three-root-adapter.js +11 -12
  61. package/dist/adapter/system-adapter.d.ts +6 -1
  62. package/dist/adapter/system-adapter.d.ts.map +1 -1
  63. package/dist/adapter/system-seam-contract.d.ts +354 -0
  64. package/dist/adapter/system-seam-contract.d.ts.map +1 -0
  65. package/dist/adapter/system-seam-contract.js +95 -0
  66. package/dist/animation/xstate-animation-binding.d.ts +6 -0
  67. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-binding.js +142 -4
  69. package/dist/asset-formats/collider.d.ts +1 -1
  70. package/dist/asset-formats/mesh.d.ts +2 -2
  71. package/dist/asset-formats/particles.d.ts +1 -1
  72. package/dist/canvas-react/engine-bridge.d.ts +6 -6
  73. package/dist/canvas-react/engine-bridge.d.ts.map +1 -1
  74. package/dist/canvas-react/engine-bridge.js +2 -2
  75. package/dist/canvas-react/index.d.ts +2 -2
  76. package/dist/canvas-react/index.d.ts.map +1 -1
  77. package/dist/canvas-react/index.js +1 -1
  78. package/dist/canvas-react/pixi-react-adapter.d.ts +15 -9
  79. package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
  80. package/dist/canvas-react/pixi-react-adapter.js +129 -64
  81. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  82. package/dist/canvas-react/pixi-react-root-factory.js +4 -0
  83. package/dist/canvas-react/world-context.d.ts +22 -12
  84. package/dist/canvas-react/world-context.d.ts.map +1 -1
  85. package/dist/canvas-react/world-context.js +12 -51
  86. package/dist/data/curve.d.ts +1 -1
  87. package/dist/data/curve.js +2 -2
  88. package/dist/dev/instruments.d.ts +4 -4
  89. package/dist/dev/instruments.js +4 -4
  90. package/dist/dev/register-render-vitals.d.ts +2 -2
  91. package/dist/dev/register-render-vitals.js +2 -2
  92. package/dist/dev/static-batch-advisor.d.ts +1 -1
  93. package/dist/dev/static-batch-advisor.js +2 -2
  94. package/dist/ecs/user-data.d.ts +5 -0
  95. package/dist/ecs/user-data.d.ts.map +1 -1
  96. package/dist/ecs/user-data.js +3 -0
  97. package/dist/input/input-manager.d.ts +64 -0
  98. package/dist/input/input-manager.d.ts.map +1 -1
  99. package/dist/input/input-manager.js +149 -0
  100. package/dist/manifest/load.d.ts +0 -3
  101. package/dist/manifest/load.d.ts.map +1 -1
  102. package/dist/manifest/load.js +0 -4
  103. package/dist/manifest/schema.d.ts +9 -11
  104. package/dist/manifest/schema.d.ts.map +1 -1
  105. package/dist/manifest/schema.js +2 -23
  106. package/dist/pixi/authoring.d.ts +33 -0
  107. package/dist/pixi/authoring.d.ts.map +1 -1
  108. package/dist/pixi/authoring.js +301 -2
  109. package/dist/pixi/ingest.d.ts +2 -1
  110. package/dist/pixi/ingest.d.ts.map +1 -1
  111. package/dist/pixi/ingest.js +4 -2
  112. package/dist/pixi/scene-capture.d.ts +9 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +9 -6
  115. package/dist/react/world-state.d.ts +12 -0
  116. package/dist/react/world-state.d.ts.map +1 -1
  117. package/dist/react/world-state.js +14 -0
  118. package/dist/render/particles-factory.d.ts +13 -6
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +6 -3
  121. package/dist/render/soft-particle-depth.d.ts +3 -3
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  123. package/dist/render/soft-particle-depth.js +8 -13
  124. package/dist/runtime/create-runtime.d.ts +25 -62
  125. package/dist/runtime/create-runtime.d.ts.map +1 -1
  126. package/dist/runtime/create-runtime.js +58 -105
  127. package/dist/runtime/dev-build.d.ts +37 -0
  128. package/dist/runtime/dev-build.d.ts.map +1 -0
  129. package/dist/runtime/dev-build.js +49 -0
  130. package/dist/runtime/game.d.ts +38 -72
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +40 -55
  133. package/dist/runtime/mount-game.d.ts.map +1 -1
  134. package/dist/runtime/mount-game.js +12 -1
  135. package/dist/runtime/mount-manifest.d.ts +0 -13
  136. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  137. package/dist/runtime/mount-manifest.js +5 -25
  138. package/dist/runtime/types.d.ts +18 -0
  139. package/dist/runtime/types.d.ts.map +1 -1
  140. package/dist/setup/setup-renderer.d.ts +3 -4
  141. package/dist/setup/setup-renderer.d.ts.map +1 -1
  142. package/dist/setup/setup-renderer.js +3 -4
  143. package/dist/world3d-react/engine-bridge.d.ts +1 -1
  144. package/dist/world3d-react/engine-bridge.d.ts.map +1 -1
  145. package/dist/world3d-react/engine-bridge.js +29 -4
  146. package/dist/world3d-react/index.d.ts +1 -1
  147. package/dist/world3d-react/index.js +1 -1
  148. package/dist/world3d-react/r3f-adapter.d.ts +7 -0
  149. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  150. package/dist/world3d-react/r3f-adapter.js +132 -28
  151. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  152. package/dist/world3d-react/r3f-root-factory.js +14 -0
  153. package/dist/world3d-react/rapier-physics-bridge.d.ts +4 -4
  154. package/dist/world3d-react/rapier-physics-bridge.js +3 -3
  155. package/dist/world3d-react/world-context.d.ts +52 -3
  156. package/dist/world3d-react/world-context.d.ts.map +1 -1
  157. package/dist/world3d-react/world-context.js +94 -64
  158. package/dist-config/config.js.map +1 -1
  159. package/package.json +1 -1
  160. package/schemas/engine-capabilities.json +8 -8
  161. package/schemas/vgai-project.schema.json +1 -6
  162. package/src/adapter/adapter-module.ts +405 -72
  163. package/src/adapter/authoring-seam-contract.ts +211 -0
  164. package/src/adapter/authoring.ts +242 -50
  165. package/src/adapter/entrypoint-selection-source.ts +159 -0
  166. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +162 -36
  167. package/src/adapter/first-party-systems.ts +17 -3
  168. package/src/adapter/index.ts +51 -1
  169. package/src/adapter/ingest/contract-system-adapters.ts +26 -30
  170. package/src/adapter/ingest/game-contract-seams.ts +85 -0
  171. package/src/adapter/ingest/game-contract.ts +9 -4
  172. package/src/adapter/ingest/merge-debug-adapters.ts +193 -0
  173. package/src/adapter/ingest/observation-debug-adapter.ts +158 -0
  174. package/src/adapter/ingest/scene-capture.ts +126 -23
  175. package/src/adapter/ingest/visible-capture-window.ts +63 -9
  176. package/src/adapter/native-debug-module.ts +307 -0
  177. package/src/adapter/object3d-authoring-subject.ts +50 -0
  178. package/src/adapter/root-adapter.ts +25 -56
  179. package/src/adapter/root-seam-contract.ts +57 -0
  180. package/src/adapter/seam-evidence.ts +336 -0
  181. package/src/adapter/setup-three-root-adapter.ts +13 -15
  182. package/src/adapter/system-adapter.ts +6 -1
  183. package/src/adapter/system-seam-contract.ts +121 -0
  184. package/src/animation/xstate-animation-binding.ts +151 -4
  185. package/src/canvas-react/engine-bridge.ts +6 -6
  186. package/src/canvas-react/index.ts +2 -1
  187. package/src/canvas-react/pixi-react-adapter.tsx +143 -77
  188. package/src/canvas-react/pixi-react-root-factory.tsx +4 -0
  189. package/src/canvas-react/world-context.ts +26 -101
  190. package/src/data/curve.ts +2 -2
  191. package/src/dev/instruments.ts +4 -4
  192. package/src/dev/register-render-vitals.ts +2 -2
  193. package/src/dev/static-batch-advisor.ts +2 -2
  194. package/src/ecs/user-data.ts +5 -0
  195. package/src/input/input-manager.ts +145 -0
  196. package/src/manifest/load.ts +0 -7
  197. package/src/manifest/schema.ts +2 -25
  198. package/src/pixi/authoring.ts +329 -2
  199. package/src/pixi/ingest.ts +9 -2
  200. package/src/pixi/scene-capture.ts +20 -5
  201. package/src/react/world-state.tsx +15 -0
  202. package/src/render/particles-factory.ts +16 -6
  203. package/src/render/soft-particle-depth.ts +8 -13
  204. package/src/runtime/create-runtime.ts +79 -158
  205. package/src/runtime/dev-build.ts +49 -0
  206. package/src/runtime/game.ts +72 -113
  207. package/src/runtime/mount-game.ts +19 -1
  208. package/src/runtime/mount-manifest.ts +5 -37
  209. package/src/runtime/types.ts +22 -0
  210. package/src/setup/setup-renderer.ts +3 -4
  211. package/src/world3d-react/engine-bridge.ts +29 -4
  212. package/src/world3d-react/index.ts +1 -1
  213. package/src/world3d-react/r3f-adapter.tsx +140 -31
  214. package/src/world3d-react/r3f-root-factory.tsx +14 -0
  215. package/src/world3d-react/rapier-physics-bridge.tsx +4 -4
  216. package/src/world3d-react/world-context.ts +111 -87
  217. package/dist/runtime/dev-layers.d.ts +0 -34
  218. package/dist/runtime/dev-layers.d.ts.map +0 -1
  219. package/dist/runtime/dev-layers.js +0 -46
  220. package/src/runtime/dev-layers.ts +0 -46
@@ -33,7 +33,7 @@
33
33
  * Deliberately NOT re-exported here: `<RapierPhysicsBridge>`
34
34
  * (`@engine/world3d-react/rapier-physics-bridge`), which registers an R3F
35
35
  * world's `@react-three/rapier` physics as the game's
36
- * `SystemAdapters.physics` — so the dev menu's colliders instrument and the
36
+ * `SystemAdapters.physics` — so the colliders instrument and the
37
37
  * editor's transform coordination can see it. It imports
38
38
  * `@react-three/rapier`, and only a world that already depends on that library
39
39
  * should pull it into its graph; a deep import keeps this barrel's peer
@@ -23,12 +23,14 @@ import {
23
23
  advance,
24
24
  createRoot,
25
25
  extend,
26
+ flushSync,
26
27
  events as pointerEvents,
27
28
  type RootState,
28
29
  useFrame,
29
30
  } from '@react-three/fiber';
30
31
  import { createElement, Fragment, type ReactNode, useEffect, useMemo } from 'react';
31
32
  import type { MountedThreeRoot, RootAdapter, ThreeHostContext } from '../adapter';
33
+ import type { SystemAdapters } from '../adapter/system-adapter';
32
34
  import { type RenderVitalsRegistration, registerRenderVitals } from '../dev/register-render-vitals';
33
35
  import {
34
36
  createRenderDebugAdapter,
@@ -40,10 +42,11 @@ import { RENDER_SUBMIT_PHASE } from '../dev/render-vitals';
40
42
  import { createWebGLFrameCapture } from '../dev/webgl-frame-capture';
41
43
  import { WorldProvider } from '../react/world-state';
42
44
  import { createSoftParticleDepthPass } from '../render/soft-particle-depth';
43
- import { devLayersEnabled } from '../runtime/dev-layers';
45
+ import { getDebugRegistry } from '../runtime/debug-registry';
46
+ import { devBuildEnabled } from '../runtime/dev-build';
44
47
  import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
45
48
  import { applyWorldRendererConfig, type WorldRendererConfig } from './renderer-config';
46
- import { createR3FRootContext } from './world-context';
49
+ import { createR3FRootContext, DEFAULT_INPUT_MAP_PATH, wireGameInputSeams } from './world-context';
47
50
 
48
51
  /** The slice of `WebGLRenderer.info` the vitals reporter reads. Declared
49
52
  * structurally rather than imported from `three`, per this module's own
@@ -74,6 +77,11 @@ interface RendererCounters {
74
77
  * sort is stable) — the tie-break among priority-0 callbacks is insertion
75
78
  * order.
76
79
  *
80
+ * It is mounted only when THIS host carries a real WebGL context. The editor's
81
+ * design-time host deliberately reconciles the scene without one because the
82
+ * editor viewport owns the real renderer and camera; that viewport runs the
83
+ * same generic depth pass immediately before it presents the adopted scene.
84
+ *
77
85
  * Costs nothing while no soft-particle system is armed — see
78
86
  * `../render/soft-particle-depth.ts` for what "armed" means and what the pass
79
87
  * spends when it is.
@@ -99,6 +107,13 @@ export interface CreateR3FAdapterOptions {
99
107
  * actions). A missing/invalid map degrades loudly without failing the
100
108
  * mount — see `world-context.ts`. */
101
109
  readonly inputMapPath?: string | null | undefined;
110
+ /**
111
+ * Transitional compatibility for explicit pre-native adapters. The
112
+ * default-export root factory always passes `false`: ordinary R3F trees get
113
+ * no vgai context/provider or private scheduler. Remove this switch with
114
+ * the last explicit legacy adapter migration.
115
+ */
116
+ readonly engineRuntime?: boolean | undefined;
102
117
  /** The colour pipeline this world was AUTHORED for, applied to the host's renderer for the life
103
118
  * of the mount and restored on dispose. Omit it (every world here does) to keep the host's own
104
119
  * defaults; declare it when the world's colours were picked against a different engine's
@@ -113,7 +128,7 @@ export interface CreateR3FAdapterOptions {
113
128
  * second `requestAnimationFrame` loop (design §1.C).
114
129
  */
115
130
  export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter {
116
- const { id, content, inputMapPath, renderer: rendererConfig } = options;
131
+ const { id, content, inputMapPath, engineRuntime = true, renderer: rendererConfig } = options;
117
132
 
118
133
  return {
119
134
  id,
@@ -171,15 +186,37 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
171
186
  // for one is a compile error rather than anything that can happen at
172
187
  // runtime. `scene`/`camera` are backfilled right after fiber's first
173
188
  // commit resolves them, before any tick can run.
174
- const runtime = createR3FRootContext(host, { id, inputMapPath });
175
- const { systems } = runtime;
189
+ const runtime = engineRuntime ? createR3FRootContext(host, { id, inputMapPath }) : null;
176
190
  // `ctx` rides the bridge so ordinary components can reach engine
177
191
  // services through `useGameContext()`.
178
- const bridge: EngineBridgeValue = { ctx: runtime.ctx };
192
+ const bridge: EngineBridgeValue | null = runtime ? { ctx: runtime.ctx } : null;
193
+ const gameDebugRegistry = host.game ? getDebugRegistry(host.game) : null;
194
+ // THE HOST STILL WIRES THIS GAME'S INPUT WHEN THE WORLD TAKES NO CONTEXT.
195
+ //
196
+ // A default-exported world mounts with `engineRuntime: false` — no vgai
197
+ // runtime context enters its React tree, by design. That says nothing
198
+ // about the GAME-scoped input the host owns from the outside: the
199
+ // project's declared map, and this root's `game.input.*` seams on the
200
+ // debug registry. Skipping those made a fresh scaffold's bot/autoplay/
201
+ // `vgai eval` input doors throw `DEBUG_INPUT_UNAVAILABLE` and left the
202
+ // declared actions unloaded — in the ONE world shape `vgai create`
203
+ // produces. Same host-vs-context split as `WorldProvider` below.
204
+ //
205
+ // `inputMapPath` unset (what the default-export factory passes) means
206
+ // "the conventional path, if this project ships one" — quiet when it
207
+ // does not, since a brand-new project declares no actions yet.
208
+ const hostInputReady =
209
+ runtime || !gameDebugRegistry
210
+ ? null
211
+ : wireGameInputSeams(host, gameDebugRegistry, {
212
+ id,
213
+ inputMapPath: inputMapPath ?? DEFAULT_INPUT_MAP_PATH,
214
+ optionalInputMap: inputMapPath === undefined,
215
+ });
179
216
  // Actions must exist before any component's init()/first frame reads
180
217
  // them — wait for the (never-rejecting) input-map load before the first
181
218
  // commit.
182
- await runtime.inputMapReady;
219
+ await (runtime?.inputMapReady ?? hostInputReady);
183
220
 
184
221
  // The world's own colour pipeline. Applied AFTER `root.configure()` below — see the comment
185
222
  // at its call site for why "before" is the version that silently does nothing.
@@ -199,6 +236,19 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
199
236
  const statePromise = new Promise<RootState>((resolve) => {
200
237
  resolveState = resolve;
201
238
  });
239
+ let resolveEffectsReady!: () => void;
240
+ const effectsReady = new Promise<void>((resolve) => {
241
+ resolveEffectsReady = resolve;
242
+ });
243
+ // Rendered after the game's content. React runs passive effects in tree
244
+ // order, so when this fires every useDebugProvider/useDebugCommand in
245
+ // the initial tree has registered. A Play acknowledgement is a claim
246
+ // that the mounted game's command surface exists, not merely that Fiber
247
+ // allocated a scene.
248
+ function MountEffectsReady(): null {
249
+ useEffect(resolveEffectsReady, []);
250
+ return null;
251
+ }
202
252
 
203
253
  // `root.configure()` is ASYNC in fiber v9 (`Promise<ReconcilerRoot>`)
204
254
  // — the design doc's §1.C sketch shows it called synchronously; the
@@ -298,21 +348,44 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
298
348
  // until this landed. `host.game` is genuinely absent in bare/headless
299
349
  // hosts, so the provider is conditional and those hooks stay the inert
300
350
  // no-ops they already document themselves to be.
301
- const game = runtime.ctx.game;
351
+ //
352
+ // READ FROM `host.game`, NEVER FROM `runtime` — the two are not the same
353
+ // question, and conflating them silently dark-ended the seam for every
354
+ // scaffolded game. The IDIOMATIC three world (`export default function
355
+ // World()`) mounts through `resolveR3FEntryAdapter` with
356
+ // `engineRuntime: false` (`r3f-root-factory.tsx`), so it has no
357
+ // `runtime` and no `bridge` — by design, because the vgai runtime
358
+ // CONTEXT (`useGameContext`) must not enter an ecosystem-native tree.
359
+ // `WorldProvider` is not that context: it carries the Game handle the
360
+ // host already holds, and it is the only way a game's own
361
+ // `useDebugProvider('stat', …)` can reach the ONE game-scoped registry
362
+ // the editor/`vgai eval` read. Keyed off `runtime`, every
363
+ // default-exported world's registrations no-opped in silence and
364
+ // `vgai eval 'await game.providers()'` listed only the built-ins.
365
+ // The canvas lane never had the gap (`canvas-react/
366
+ // pixi-react-root-factory.tsx` keeps its entry runtime on).
367
+ const game = host.game;
302
368
  // `SoftParticleDepth` trails the world's own tree deliberately — see its
303
- // docblock for why the subscription must land last.
369
+ // docblock for why the subscription must land last. A no-context design
370
+ // mount omits this fiber callback because the adopting editor viewport
371
+ // runs the pass with its own renderer and camera instead.
304
372
  const world = createElement(
305
373
  Fragment,
306
374
  null,
307
375
  content,
308
- createElement(SoftParticleDepth, { key: 'vgai-soft-particle-depth' }),
376
+ runtime && typeof host.renderer.getContext === 'function'
377
+ ? createElement(SoftParticleDepth, { key: 'vgai-soft-particle-depth' })
378
+ : null,
379
+ createElement(MountEffectsReady, { key: 'vgai-mount-effects-ready' }),
309
380
  );
381
+ // The two providers are INDEPENDENT: `EngineBridge` rides `bridge` (the
382
+ // vgai runtime context, absent for a native default-exported world),
383
+ // `WorldProvider` rides `host.game` (the React seam, present whenever a
384
+ // Game hosts this world). Nesting the second inside the first is what
385
+ // made the seam a hostage of the first.
386
+ const provided = game ? createElement(WorldProvider, { game }, world) : world;
310
387
  root.render(
311
- createElement(
312
- EngineBridge.Provider,
313
- { value: bridge },
314
- game ? createElement(WorldProvider, { game }, world) : world,
315
- ),
388
+ bridge ? createElement(EngineBridge.Provider, { value: bridge }, provided) : provided,
316
389
  );
317
390
  // A reconcile-time crash (e.g. a missing `extend` catalogue entry)
318
391
  // surfaces as an uncaught window error and `onCreated` never fires —
@@ -323,7 +396,8 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
323
396
  const onError = (event: ErrorEvent) => {
324
397
  cleanup();
325
398
  root.unmount();
326
- runtime.dispose();
399
+ runtime?.dispose();
400
+ if (!runtime) gameDebugRegistry?.strip(id);
327
401
  restoreRendererConfig();
328
402
  reject(
329
403
  new Error(
@@ -335,7 +409,8 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
335
409
  const timer = setTimeout(() => {
336
410
  cleanup();
337
411
  root.unmount();
338
- runtime.dispose();
412
+ runtime?.dispose();
413
+ if (!runtime) gameDebugRegistry?.strip(id);
339
414
  restoreRendererConfig();
340
415
  reject(
341
416
  new Error(
@@ -349,7 +424,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
349
424
  window.removeEventListener('error', onError);
350
425
  };
351
426
  window.addEventListener('error', onError);
352
- void statePromise.then((s) => {
427
+ void Promise.all([statePromise, effectsReady]).then(([s]) => {
353
428
  cleanup();
354
429
  resolve(s);
355
430
  });
@@ -369,7 +444,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
369
444
  // Backfill the component runtime's ctx with fiber's real scene/camera
370
445
  // (W3 — see the HONESTY NOTE above). This happens before the first
371
446
  // `update(dt)` tick, so no component ever observes them missing.
372
- runtime.setSceneCamera(state.scene, state.camera);
447
+ runtime?.setSceneCamera(state.scene, state.camera);
373
448
 
374
449
  // THE STORE IS THE TRUTH, NOT THE `onCreated` SNAPSHOT.
375
450
  //
@@ -406,11 +481,11 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
406
481
  // world is the only shape a scaffolded project's three root has, so a
407
482
  // door wired only into the classic path is a door no scaffolded project
408
483
  // ever gets (issue #1518: `game.state('render.vitals')` answered "no
409
- // state provider registered" in the field while every other dev layer of
410
- // the same world worked).
484
+ // state provider registered" in the field while every other debug
485
+ // reading of the same world worked).
411
486
  //
412
487
  // The gate is the same three conditions, minus the one that cannot
413
- // arise: `devLayersEnabled()` (`../runtime/dev-layers.ts`, the ONE owner
488
+ // arise: `devBuildEnabled()` (`../runtime/dev-build.ts`, the ONE owner
414
489
  // of "is this a dev context" — a ship build registers nothing and pays
415
490
  // nothing), and a `Game` shell (the readings are folded out of that
416
491
  // game's profiler frames). "Not headless" needs no term here: a headless
@@ -428,14 +503,17 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
428
503
  // (the fold) and `runtime.dispose()`'s scoped `strip(id)` (the
429
504
  // registrations) — the same split the classic adapter documents.
430
505
  let renderVitals: RenderVitalsRegistration | null = null;
431
- if (host.game && devLayersEnabled()) {
506
+ if (host.game && devBuildEnabled()) {
432
507
  // Same auto-enable as the classic adapter: the readings are folded out
433
508
  // of profiler frames, and the profiler is a flag the editor already
434
509
  // sets on play — under the dev gate this is the same cost arriving a
435
510
  // little earlier, not a new one.
436
511
  host.game.profiler.enabled = true;
512
+ if (!gameDebugRegistry) {
513
+ throw new Error('r3f-adapter: mounted Game has no debug registry.');
514
+ }
437
515
  renderVitals = registerRenderVitals({
438
- registry: runtime.debugRegistry,
516
+ registry: gameDebugRegistry,
439
517
  worldId: id,
440
518
  profiler: host.game.profiler,
441
519
  scene: live().scene,
@@ -461,7 +539,12 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
461
539
  memory: () => collectRenderMemory(live().scene, host.renderer.info),
462
540
  })
463
541
  : null;
464
- if (renderDebugWiring) runtime.systemAdapters.renderDebug = renderDebugWiring.adapter;
542
+ // The session debug registry is host instrumentation, not component
543
+ // context. Publishing the existing adapter here lets coverage and
544
+ // editor panels see the same door without wrapping the R3F tree.
545
+ const systemAdapters: SystemAdapters =
546
+ runtime?.systemAdapters ?? (gameDebugRegistry ? { debug: gameDebugRegistry.adapter } : {});
547
+ if (renderDebugWiring) systemAdapters.renderDebug = renderDebugWiring.adapter;
465
548
 
466
549
  // The engine drives every `useFrame` through the mounted world's
467
550
  // `update(dt)` hook, never off a raw host-loop callback. That is the
@@ -490,7 +573,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
490
573
  // resumed frame as one giant `useFrame` delta — the exact "time passed
491
574
  // while frozen" illusion acceptance gate 2 forbids.
492
575
  let elapsed = 0;
493
- /** The camera `ctx` was last backfilled with — see `update()`. */
576
+ /** The legacy component ctx camera last backfilled by `update()`. */
494
577
  let backfilledCamera = state.camera;
495
578
 
496
579
  return {
@@ -524,7 +607,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
524
607
  // WebGL2 context exists, `renderDebug` are engine-seeded. Game code
525
608
  // adds its own capabilities via `ctx.registerSystemAdapter` exactly
526
609
  // as in a classic world.
527
- systems: runtime.systemAdapters,
610
+ systems: systemAdapters,
528
611
  update(dt: number): void {
529
612
  elapsed += dt;
530
613
  const current = live();
@@ -533,14 +616,14 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
533
616
  // component reading one while the frame uses the other is the
534
617
  // silent kind of wrong. Identity-gated, so this is one comparison
535
618
  // per frame in the overwhelmingly common no-swap case.
536
- if (current.camera !== backfilledCamera) {
619
+ if (runtime && current.camera !== backfilledCamera) {
537
620
  backfilledCamera = current.camera;
538
621
  runtime.setSceneCamera(current.scene, current.camera);
539
622
  }
540
623
  // Engine phases FIRST (registered systems mutate transforms),
541
624
  // then fiber's advance (useFrame callbacks + the actual render see
542
625
  // the fresh state). Both are host-gated: neither runs while frozen.
543
- systems.run(dt);
626
+ runtime?.systems.run(dt);
544
627
  renderDebugWiring?.beforeRender();
545
628
  try {
546
629
  const profiler = host.game?.profiler;
@@ -591,7 +674,13 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
591
674
  // Reject/restore a pending capture while its renderables still
592
675
  // exist, then unmount component effects and strip runtime state.
593
676
  renderDebugWiring?.dispose();
594
- root.unmount();
677
+ // The editor audits and disposes the mounted game's window/document realm immediately
678
+ // after `session.stop()`. Fiber's ordinary `unmount()` submits a concurrent reconciler
679
+ // update, so component effect cleanups could still be pending at that boundary and the
680
+ // audit would truthfully report/reclaim listeners the world was about to remove itself.
681
+ // Force the unmount COMMIT (not fiber's deliberately delayed renderer/context disposal)
682
+ // to finish here: a stopped root owns no live component lifecycle beyond this method.
683
+ flushSync(() => root.unmount());
595
684
  // The vitals fold owns two `profiler.subscribe` registrations, and
596
685
  // `runtime.dispose()`'s `strip(id)` removes the REGISTRATIONS only —
597
686
  // this is the one path that ends the subscriptions, exactly as the
@@ -599,7 +688,27 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
599
688
  // subscription can never outlive the door it feeds.
600
689
  renderVitals?.dispose();
601
690
  renderVitals = null;
602
- runtime.dispose();
691
+ runtime?.dispose();
692
+ // The no-context path has no `runtime.dispose()` to carry its scoped
693
+ // strip, so it ends its own registrations here — the same
694
+ // `strip(id)`, at the same moment. That is what ends this world's
695
+ // PROVIDERS AND COMMANDS (the render-vitals door above is the one
696
+ // this path actually registers under `id`); sibling roots are
697
+ // untouched.
698
+ //
699
+ // It does NOT end the input seams `wireGameInputSeams` set, and
700
+ // nothing else does either: `strip` walks providers/commands only
701
+ // (`runtime/debug-registry.ts`), so `virtualInputTargets`/
702
+ // `inputActionsSources`/`inputTraceSources` keep this world's id
703
+ // until a remount of the same id overwrites it. The runtime path has
704
+ // always been identical, and the residue is inert rather than
705
+ // hidden: every one of those closures reads GAME-scoped state
706
+ // (`game.input`, `game.loop.fixedDt`, the game's seed), so a stale
707
+ // entry actuates exactly what a live one would. What it can still do
708
+ // is name a dead world in `DEBUG_INPUT_WORLD_NOT_FOUND`'s registered
709
+ // list. Do not write "the strip clears the input seams" here again
710
+ // without changing `strip` to actually do it.
711
+ if (!runtime) gameDebugRegistry?.strip(id);
603
712
  restoreRendererConfig();
604
713
  },
605
714
  };
@@ -69,6 +69,20 @@ export function resolveR3FEntryAdapter(entryModule: unknown, rootId: string): Ro
69
69
  return createR3FAdapter({
70
70
  id: rootId,
71
71
  content: createElement(mod.default),
72
+ // A default-exported R3F component is an ordinary R3F app. The host
73
+ // controls Fiber's native `frameloop: 'never'` scheduler; the vgai
74
+ // runtime CONTEXT (`useGameContext`, `ctx.systems`) never enters its
75
+ // React tree.
76
+ //
77
+ // This flag governs that context and nothing else. It does NOT govern
78
+ // `WorldProvider`: `useDebugProvider`/`useDebugCommand`/`useWorldState`
79
+ // are the documented door a game declares its own state/commands
80
+ // through, and they must work in the idiomatic shape — which is the
81
+ // ONLY shape `vgai create` scaffolds. `r3f-adapter.tsx` therefore keys
82
+ // that provider off `host.game`, not off this flag; see the comment
83
+ // beside `const game = host.game` there for the measured symptom
84
+ // (`vgai eval 'await game.providers()'` listing only built-ins).
85
+ engineRuntime: false,
72
86
  });
73
87
  }
74
88
  return null;
@@ -3,7 +3,7 @@
3
3
  * `@react-three/rapier` physics VISIBLE to the engine's editor/dev seams.
4
4
  *
5
5
  * WHY IT EXISTS. `SystemAdapters.physics` is what the editor's transform
6
- * coordination (`freeze → apply → unfreeze`) and the dev menu's universal
6
+ * coordination (`freeze → apply → unfreeze`) and the universal
7
7
  * "Colliders drawn" instrument (`@engine/dev/instruments`) read. The
8
8
  * first-party imperative three root registers one from
9
9
  * `../adapter/setup-three-root-adapter.ts`; an R3F world builds no first-party
@@ -44,7 +44,7 @@
44
44
  * on the R3F root it is mounted in. It allocates nothing else — no timers, no
45
45
  * subscriptions, no per-frame work (see the phase note below), and no Rapier
46
46
  * objects: the bodies it edits belong to `<Physics>`.
47
- * SHARERS: every reader of `game.systemAdapters.physics` — the dev menu's
47
+ * SHARERS: every reader of `game.systemAdapters.physics` — the
48
48
  * colliders instrument, the editor's transform coordination. They read; they
49
49
  * never dispose.
50
50
  * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key it
@@ -55,7 +55,7 @@
55
55
  *
56
56
  * PHASE ORDERING: none to respect — this component registers pull-only
57
57
  * callbacks and runs no per-frame work. Every method it exposes is invoked
58
- * BETWEEN frames, by an editor gesture or a dev-menu command, never from a
58
+ * BETWEEN frames, by an editor gesture or an instrument command, never from a
59
59
  * phase.
60
60
  */
61
61
 
@@ -78,7 +78,7 @@ import { useGameContext } from './engine-bridge';
78
78
  export interface RapierPhysicsBridgeProps {
79
79
  /**
80
80
  * Set the `debug` prop of the SAME `<Physics>` this bridge is mounted in.
81
- * The dev menu's "Colliders drawn" toggle calls it; the world's own state is
81
+ * The "Colliders drawn" instrument calls it; the world's own state is
82
82
  * what `<Physics debug={…}>` reads, so the library's debug rendering is the
83
83
  * only thing that ever draws a collider.
84
84
  */
@@ -13,12 +13,15 @@
13
13
  * `ctx.debug = registry.forRoot(id)`, so a component's
14
14
  * effect can `ctx.debug.registerStateProvider(...)` and be visible
15
15
  * to `vgai eval`/the editor's debug panels — previously a silent no-op.
16
- * 2. **`ctx.input` is the game-owned `InputManager`** (`host.game.input`
16
+ * 2. **`ctx.input` is the game-owned `InputManager` when a Game hosts the
17
+ * root** (`host.game.input` —
17
18
  * the same instance the classic adapter uses when hosted), and this
18
19
  * module registers the SAME per-world debug-registry seams the classic
19
20
  * adapter registers (`setVirtualInputTarget`/`setInputActionsSource`/
20
21
  * `setInputTraceSource`), which is what makes `game.input.*` (the bot
21
22
  * input doctrine) work instead of throwing `DEBUG_INPUT_UNAVAILABLE`.
23
+ * A bare/foreign mount receives `null`, visibly in the type, rather than
24
+ * a getter that throws later inside a frame callback.
22
25
  * The project's input map is loaded through `Game.loadInputMap` (the
23
26
  * game-owned, load-once path every root shares) with the SAME default
24
27
  * path the classic adapter uses. A missing/unparseable map degrades
@@ -49,7 +52,7 @@ import {
49
52
  getDebugRegistry,
50
53
  } from '../runtime/debug-registry';
51
54
  import { disposeDebrisSubtree } from '../runtime/game';
52
- import type { GameContext } from '../runtime/types';
55
+ import type { ComponentContextBase, GameContext } from '../runtime/types';
53
56
 
54
57
  /** The classic adapter's default input-map path — the conventional location
55
58
  * every scaffolded project ships (`public/inputmaps/default.inputmap.json`). */
@@ -90,22 +93,11 @@ export const DEFAULT_INPUT_MAP_PATH = '/inputmaps/default.inputmap.json';
90
93
  * exporting `setup(ctx): Promise<GameCleanup>`, which `SetupThreeRootAdapter`
91
94
  * mounts with the complete {@link GameContext}.
92
95
  */
93
- export type R3FGameContext = Pick<
94
- GameContext,
95
- | 'scene'
96
- | 'camera'
97
- | 'sceneIndex'
98
- | 'input'
99
- | 'assets'
100
- | 'systems'
101
- | 'clock'
102
- | 'debug'
103
- | 'random'
104
- | 'game'
105
- | 'roots'
106
- | 'playtest'
107
- | 'registerSystemAdapter'
108
- >;
96
+ export type R3FGameContext = ComponentContextBase &
97
+ Pick<GameContext, 'scene' | 'sceneIndex' | 'assets' | 'clock'> & {
98
+ /** Fiber may install either a perspective or orthographic camera. */
99
+ camera: THREE.Camera;
100
+ };
109
101
 
110
102
  export interface R3FRootContextOptions {
111
103
  /** World id — provenance for every debug-registry registration (matches the
@@ -155,6 +147,103 @@ export interface R3FRootRuntime {
155
147
  dispose(): void;
156
148
  }
157
149
 
150
+ /**
151
+ * The GAME-SCOPED input wiring one three root owns — and the half that has
152
+ * nothing to do with the vgai runtime context.
153
+ *
154
+ * Two things happen here, both of them HOST work performed from outside the
155
+ * world's own tree:
156
+ *
157
+ * 1. the project's input map is loaded through `Game.loadInputMap` (game-owned,
158
+ * load-once), so the actions the project declared exist;
159
+ * 2. this root's seams are registered on the game-scoped debug registry
160
+ * (`setVirtualInputTarget`/`setInputActionsSource`/`setInputTraceSource`),
161
+ * which is what makes `game.input.hold/tap/...` (the bot/`vgai eval` input
162
+ * doctrine) and the built-in `input.actions`/`input.trace` providers
163
+ * resolve to this world instead of throwing `DEBUG_INPUT_UNAVAILABLE`.
164
+ *
165
+ * It is called on BOTH three-root paths, and that is the point: a world that
166
+ * mounts as a default-exported component receives no vgai runtime context
167
+ * (`engineRuntime: false` — see `r3f-root-factory.tsx`), but "no context in the
168
+ * tree" was never meant to say "the host does not wire this game's input". It
169
+ * was read that way, and the cost was measured on a fresh scaffold: the
170
+ * declared map never loaded and every `game.input.*` door threw, so the QA
171
+ * bot, autoplay routes and `vgai eval` could not drive the ONE world shape
172
+ * `vgai create` produces. Same split, same reasoning, as `WorldProvider`
173
+ * riding `host.game` rather than the runtime (see `r3f-adapter.tsx`).
174
+ *
175
+ * `optionalInputMap` is the CONVENTIONAL-PATH probe used when the project
176
+ * never named a map: an absent file is the ordinary state of a brand-new
177
+ * project, so it resolves quietly instead of printing an error on every boot.
178
+ * A map that exists and fails to parse is as loud as ever.
179
+ *
180
+ * LIFETIME, honestly: `registry.strip(id)` runs on the mount's dispose (both
181
+ * paths), but it walks providers/commands only — the three seams set here
182
+ * survive it, keyed by this world's id, until a remount of the same id
183
+ * overwrites them. That residue is inert rather than hidden: every closure
184
+ * below reads GAME-scoped state (`game.input`, `game.loop.fixedDt`, the game's
185
+ * seed), so a stale entry actuates exactly what a live one would. See the
186
+ * dispose comment in `r3f-adapter.tsx` for the full accounting.
187
+ */
188
+ export function wireGameInputSeams(
189
+ host: ThreeHostContext,
190
+ registry: DebugRegistry,
191
+ options: {
192
+ readonly id: string;
193
+ readonly inputMapPath?: string | null | undefined;
194
+ readonly optionalInputMap?: boolean | undefined;
195
+ },
196
+ ): Promise<void> {
197
+ const { id, inputMapPath = DEFAULT_INPUT_MAP_PATH, optionalInputMap = false } = options;
198
+ if (!host.game) return Promise.resolve();
199
+ const game = host.game;
200
+ const input = game.input;
201
+ registry.setInputActionsSource(id, () =>
202
+ input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) })),
203
+ );
204
+ registry.setInputTraceSource(id, () => {
205
+ const raw = input.getInputTrace();
206
+ return {
207
+ version: raw.version,
208
+ seed: getSeededRandom(game)?.seed ?? null,
209
+ fixedDt: game.loop.fixedDt,
210
+ ticks: raw.ticks,
211
+ };
212
+ });
213
+ registry.setVirtualInputTarget(id, {
214
+ setVirtualAction: (action, value) => input.setVirtualAction(action, value),
215
+ tapVirtualAction: (action) => input.tapVirtualAction(action),
216
+ clearVirtualActions: () => input.clearVirtualActions(),
217
+ scheduleActionAtTick: (tick, action, value) => input.scheduleActionAtTick(tick, action, value),
218
+ startInputRecording: () => input.startInputRecording(),
219
+ stopInputRecording: () => input.stopInputRecording(),
220
+ isInputRecording: () => input.isInputRecording(),
221
+ injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
222
+ injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
223
+ injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
224
+ injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
225
+ });
226
+ if (host.headless || inputMapPath === null) return Promise.resolve();
227
+ // Load-once through the game-owned path (competing paths across roots throw
228
+ // THERE, loudly). A FAILED load (missing/bad file) must not fail the mount:
229
+ // a three world with no declared actions is legal. It degrades loudly
230
+ // instead — naming exactly what breaks.
231
+ // The non-optional call passes ONE argument, exactly as it always has — a
232
+ // trailing `undefined` is a different call to any observer of it.
233
+ const load = optionalInputMap
234
+ ? game.loadInputMap(inputMapPath, { optional: true })
235
+ : game.loadInputMap(inputMapPath);
236
+ return load.catch((err: unknown) => {
237
+ // biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to failing the mount (see comment above)
238
+ console.error(
239
+ `three world "${id}": failed to load input map "${inputMapPath}" — declared input ` +
240
+ 'actions and `game.input.*` (bot/virtual input) will not work until a valid map ' +
241
+ 'loads. Ship one at the conventional path or pass `inputMapPath` to ' +
242
+ `createR3FAdapter (null to opt out). Cause: ${err instanceof Error ? err.message : String(err)}`,
243
+ );
244
+ });
245
+ }
246
+
158
247
  /**
159
248
  * Build the engine runtime for one R3F world from a `ThreeHostContext` — the same
160
249
  * wiring, at the same layer, as the classic `SetupThreeRootAdapter.mount()`
@@ -185,6 +274,7 @@ export function createR3FRootContext(
185
274
  const ctxRaw: Record<string, unknown> = {
186
275
  systems,
187
276
  assets: host.assets,
277
+ input: host.game?.input ?? null,
188
278
  registerSystemAdapter: (kind: keyof SystemAdapters, adapter: SystemAdapters[typeof kind]) => {
189
279
  // biome-ignore lint/suspicious/noExplicitAny: same per-key record write the classic adapter performs; correct by construction
190
280
  (systemAdapters as any)[kind] = adapter;
@@ -223,75 +313,9 @@ export function createR3FRootContext(
223
313
  gameClock ?? createSimClock({ dispose: (obj) => disposeDebrisSubtree(obj, []) });
224
314
  ctx.clock = clock;
225
315
 
226
- // --- ctx.input + the debug-registry input seams (gap 3) — the game-owned
227
- // InputManager, polled by the game-scoped runner each frame; this world
228
- // registers the SAME per-world seams the classic adapter registers so
229
- // `game.input.*` (virtual input) and the built-in `input.actions`/
230
- // `input.trace` providers resolve to it. ---
231
- let inputMapReady: Promise<void> = Promise.resolve();
232
- if (host.game) {
233
- const game = host.game;
234
- const input = game.input;
235
- ctxRaw['input'] = input;
236
- debugRegistry.setInputActionsSource(id, () =>
237
- input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) })),
238
- );
239
- debugRegistry.setInputTraceSource(id, () => {
240
- const raw = input.getInputTrace();
241
- return {
242
- version: raw.version,
243
- seed: getSeededRandom(game)?.seed ?? null,
244
- fixedDt: game.loop.fixedDt,
245
- ticks: raw.ticks,
246
- };
247
- });
248
- debugRegistry.setVirtualInputTarget(id, {
249
- setVirtualAction: (action, value) => input.setVirtualAction(action, value),
250
- tapVirtualAction: (action) => input.tapVirtualAction(action),
251
- clearVirtualActions: () => input.clearVirtualActions(),
252
- scheduleActionAtTick: (tick, action, value) =>
253
- input.scheduleActionAtTick(tick, action, value),
254
- startInputRecording: () => input.startInputRecording(),
255
- stopInputRecording: () => input.stopInputRecording(),
256
- isInputRecording: () => input.isInputRecording(),
257
- injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
258
- injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
259
- injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
260
- injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
261
- });
262
- if (!host.headless && inputMapPath !== null) {
263
- // Load-once through the game-owned path (classic parity — competing
264
- // paths across roots throw THERE, loudly). A FAILED load (missing/bad
265
- // file) must not fail this mount: an R3F tree with no declared actions
266
- // is legal. It degrades loudly instead — naming exactly what breaks.
267
- inputMapReady = game.loadInputMap(inputMapPath).catch((err: unknown) => {
268
- // biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to failing the mount (see comment above)
269
- console.error(
270
- `R3F world "${id}": failed to load input map "${inputMapPath}" — declared input ` +
271
- 'actions and `game.input.*` (bot/virtual input) will not work until a valid map ' +
272
- 'loads. Ship one at the conventional path or pass `inputMapPath` to ' +
273
- `createR3FAdapter (null to opt out). Cause: ${err instanceof Error ? err.message : String(err)}`,
274
- );
275
- });
276
- }
277
- } else {
278
- // `input` IS a field of every R3F world's ctx — it is game-owned, and this
279
- // particular mount has no Game to own it (a bare harness or a foreign
280
- // host). A throwing getter names that at the read, instead of handing back
281
- // `undefined` that breaks deep inside a frame callback. Non-enumerable so
282
- // spreads/serialization of the ctx never trip it.
283
- Object.defineProperty(ctxRaw, 'input', {
284
- configurable: true,
285
- enumerable: false,
286
- get(): never {
287
- throw new Error(
288
- `R3F world "${id}": ctx.input is game-owned and this mount has no Game host ` +
289
- '(host.game is absent — bare harness/foreign host); mount through the vgai host ' +
290
- 'to get the shared InputManager.',
291
- );
292
- },
293
- });
294
- }
316
+ // --- ctx.input + the debug-registry input seams (gap 3) — see
317
+ // `wireGameInputSeams` below; `ctx.input` is that same game-owned manager. ---
318
+ const inputMapReady = wireGameInputSeams(host, debugRegistry, { id, inputMapPath });
295
319
 
296
320
  return {
297
321
  ctx,