@vgai/engine 0.5.21 → 0.5.22

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 (87) hide show
  1. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  2. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  3. package/dist/adapter/manifest-interpreter.js +50 -0
  4. package/dist/adapter/native-debug-module.d.ts +45 -3
  5. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  6. package/dist/adapter/native-debug-module.js +128 -10
  7. package/dist/adapter/renderer-config.d.ts +1 -1
  8. package/dist/adapter/renderer-config.js +1 -1
  9. package/dist/canvas-react/index.d.ts +5 -12
  10. package/dist/canvas-react/index.d.ts.map +1 -1
  11. package/dist/canvas-react/index.js +5 -12
  12. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  13. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  14. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  15. package/dist/core/countdown-timer.d.ts +118 -0
  16. package/dist/core/countdown-timer.d.ts.map +1 -0
  17. package/dist/core/countdown-timer.js +137 -0
  18. package/dist/core/deferred-commands.d.ts +127 -0
  19. package/dist/core/deferred-commands.d.ts.map +1 -0
  20. package/dist/core/deferred-commands.js +132 -0
  21. package/dist/core/sim-clock.d.ts +14 -2
  22. package/dist/core/sim-clock.d.ts.map +1 -1
  23. package/dist/core/sim-clock.js +14 -2
  24. package/dist/dev/instruments.d.ts +38 -11
  25. package/dist/dev/instruments.d.ts.map +1 -1
  26. package/dist/dev/instruments.js +81 -11
  27. package/dist/input/input-manager.d.ts +15 -0
  28. package/dist/input/input-manager.d.ts.map +1 -1
  29. package/dist/input/input-manager.js +15 -0
  30. package/dist/render/directional-shadow-fit.d.ts +88 -0
  31. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  32. package/dist/render/directional-shadow-fit.js +133 -0
  33. package/dist/render/environment-capture.d.ts +68 -0
  34. package/dist/render/environment-capture.d.ts.map +1 -0
  35. package/dist/render/environment-capture.js +90 -0
  36. package/dist/render/ibl-override-material.d.ts +97 -0
  37. package/dist/render/ibl-override-material.d.ts.map +1 -0
  38. package/dist/render/ibl-override-material.js +114 -0
  39. package/dist/runtime/game-input-seams.d.ts +51 -0
  40. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  41. package/dist/runtime/game-input-seams.js +86 -0
  42. package/dist/runtime/game.d.ts +15 -0
  43. package/dist/runtime/game.d.ts.map +1 -1
  44. package/dist/runtime/game.js +40 -8
  45. package/dist/runtime/mount-game.d.ts.map +1 -1
  46. package/dist/runtime/mount-game.js +18 -2
  47. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  48. package/dist/runtime/mount-manifest.js +4 -2
  49. package/dist/runtime/types.d.ts +1 -1
  50. package/dist/world3d-react/index.d.ts +15 -25
  51. package/dist/world3d-react/index.d.ts.map +1 -1
  52. package/dist/world3d-react/index.js +15 -25
  53. package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
  54. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  55. package/dist/world3d-react/r3f-root-factory.js +396 -50
  56. package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
  57. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  58. package/dist/world3d-react/rapier-physics-bridge.js +82 -49
  59. package/package.json +1 -1
  60. package/schemas/engine-capabilities.json +3 -3
  61. package/src/adapter/manifest-interpreter.ts +64 -0
  62. package/src/adapter/native-debug-module.ts +212 -15
  63. package/src/adapter/renderer-config.ts +1 -1
  64. package/src/canvas-react/index.ts +5 -21
  65. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  66. package/src/core/countdown-timer.ts +188 -0
  67. package/src/core/deferred-commands.ts +174 -0
  68. package/src/core/sim-clock.ts +14 -2
  69. package/src/dev/instruments.ts +108 -11
  70. package/src/input/input-manager.ts +15 -0
  71. package/src/render/directional-shadow-fit.ts +156 -0
  72. package/src/render/environment-capture.ts +102 -0
  73. package/src/render/ibl-override-material.ts +170 -0
  74. package/src/runtime/game-input-seams.ts +108 -0
  75. package/src/runtime/game.ts +65 -7
  76. package/src/runtime/mount-game.ts +23 -1
  77. package/src/runtime/mount-manifest.ts +4 -2
  78. package/src/runtime/types.ts +1 -1
  79. package/src/world3d-react/index.ts +15 -31
  80. package/src/world3d-react/r3f-root-factory.tsx +444 -53
  81. package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
  82. package/src/canvas-react/engine-bridge.ts +0 -59
  83. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  84. package/src/canvas-react/world-context.ts +0 -253
  85. package/src/world3d-react/engine-bridge.ts +0 -86
  86. package/src/world3d-react/r3f-adapter.tsx +0 -717
  87. package/src/world3d-react/world-context.ts +0 -358
@@ -40,18 +40,20 @@
40
40
  * is how a `SystemAdapters` member says "not supported".
41
41
  *
42
42
  * ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
43
- * OWNER: this component owns exactly ONE `SystemAdapters.physics` registration,
44
- * on the R3F root it is mounted in. It allocates nothing else no timers, no
45
- * subscriptions, no per-frame work (see the phase note below), and no Rapier
46
- * objects: the bodies it edits belong to `<Physics>`.
47
- * SHARERS: every reader of `game.systemAdapters.physics` the
48
- * colliders instrument, the editor's transform coordination. They read; they
49
- * never dispose.
50
- * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key it
51
- * set (`registerSystemAdapter('physics', undefined)`, which
52
- * `Game.computeSystemAdapters` skips), so a play stop/start, a hot restart, or
53
- * a world that unmounts `<Physics>` leaves no stale adapter behind. Two
54
- * bridges in one root would fight over that one key; mount exactly one.
43
+ * OWNER: this component owns one physics ADAPTER, published onto the ONE
44
+ * module-scoped slot `rapierPhysicsSystem()` forwards to (GAME-GLOBALthe
45
+ * entry's static `systems.physics` declaration cannot be per-root). It allocates nothing
46
+ * else no timers, no subscriptions, no per-frame work (see the phase note
47
+ * below), and no Rapier objects: the bodies it edits belong to `<Physics>`.
48
+ * SHARERS: every reader of `game.systemAdapters.physics` the colliders
49
+ * instrument, the editor's transform coordination. They read; they never
50
+ * dispose.
51
+ * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key
52
+ * and the slot it set (identity-guarded), so a play stop/start, a hot
53
+ * restart, or a world that unmounts `<Physics>` leaves no stale adapter
54
+ * behind. Because the slot is game-global, mount exactly ONE bridge per
55
+ * GAME: with two, the later mount wins the slot and the later unmount clears
56
+ * it while the earlier bridge is still live.
55
57
  *
56
58
  * PHASE ORDERING: none to respect — this component registers pull-only
57
59
  * callbacks and runs no per-frame work. Every method it exposes is invoked
@@ -62,7 +64,6 @@ import { useRapier } from '@react-three/rapier';
62
64
  import { useEffect } from 'react';
63
65
  import * as THREE from 'three';
64
66
  import { createRapierBodyEditing, } from '../adapter/rapier-physics-adapter';
65
- import { useGameContext } from './engine-bridge';
66
67
  function lateEntityObject(states, nodeId) {
67
68
  let found;
68
69
  for (const state of states) {
@@ -92,46 +93,28 @@ function owningBody(object, states) {
92
93
  * `useRapier()`). Renders nothing.
93
94
  */
94
95
  export function RapierPhysicsBridge({ onDebugChange }) {
95
- const ctx = useGameContext();
96
+ // This bridge publishes the module slot below, which the entry's
97
+ // `systems.physics` declaration (`rapierPhysicsSystem()`) forwards to —
98
+ // that declaration is the ONE path the host reads this world's physics
99
+ // through.
96
100
  const { colliderStates, rapier, rigidBodyStates, world } = useRapier();
97
- const registerSystemAdapter = ctx.registerSystemAdapter;
98
101
  useEffect(() => {
99
- if (!registerSystemAdapter)
100
- return;
101
102
  /**
102
- * node id → the Rapier body driving it, in two hops the library and the
103
- * engine each already own:
103
+ * node id → the Rapier body driving it, entirely through the library's
104
+ * OWN body↔object map (`rigidBodyStates`, carried on the `RapierContext`
105
+ * `useRapier()` returns): resolve `userData.entityId` — this stack's
106
+ * identity convention, stamped by the editor's R3F authoring adapter —
107
+ * inside the body-owned subtrees, then walk ANCESTORS to the owning body
108
+ * (`<RigidBody>` owns a subtree, so the selected mesh is usually a
109
+ * descendant of the object the state names).
104
110
  *
105
- * 1. id `Object3D`, through the live scene index `userData.entityId`
106
- * is this stack's identity convention (the editor's R3F authoring
107
- * adapter stamps it; `../adapter/setup-three-root-adapter.ts` resolves
108
- * the same way for the imperative root).
109
- * 2. `Object3D` → body, through `rigidBodyStates` — the library's OWN
110
- * body↔object map, carried on the `RapierContext` `useRapier()`
111
- * returns. Walking ANCESTORS matters: `<RigidBody>` owns a subtree, so
112
- * the selected mesh is usually a descendant of the object the state
113
- * names.
114
- *
115
- * Both maps are live, so this is computed per call rather than cached —
111
+ * The map is live, so this is computed per call rather than cached
116
112
  * calls arrive at gesture rate (a gizmo drag start/end, a dev command),
117
- * never per frame. `ctx.sceneIndex` is read AT CALL TIME, never captured:
118
- * the R3F root backfills it once fiber resolves the scene, which can land
119
- * after this component's first effect (`world-context.ts`'s
120
- * `setSceneCamera`), so a destructured copy would be permanently absent.
113
+ * never per frame, and only objects under a `<RigidBody>` can have a body
114
+ * at all, so the body subtrees are the complete search space.
121
115
  */
122
116
  const bodyFor = (nodeId) => {
123
- const indexed = ctx.sceneIndex?.byEntityId(nodeId);
124
- const object = indexed ?? lateEntityObject(rigidBodyStates.values(), nodeId);
125
- if (!indexed && object) {
126
- // The editor assigns its stable authoring entity ids AFTER Fiber's
127
- // first commit, while the root's SceneIndex is constructed during
128
- // that commit. Resolve the one late id inside the library-owned body
129
- // subtrees and immediately reindex it; subsequent gestures take the
130
- // normal indexed path. This is the SceneIndex's documented escape
131
- // hatch for a userData id written after construction, not a parallel
132
- // scene mirror.
133
- ctx.sceneIndex?.reindex(object);
134
- }
117
+ const object = lateEntityObject(rigidBodyStates.values(), nodeId);
135
118
  if (!object)
136
119
  return undefined;
137
120
  return owningBody(object, rigidBodyStates.values());
@@ -303,8 +286,58 @@ export function RapierPhysicsBridge({ onDebugChange }) {
303
286
  joints,
304
287
  previewJointAnchor,
305
288
  };
306
- registerSystemAdapter('physics', adapter);
307
- return () => registerSystemAdapter('physics', undefined);
308
- }, [colliderStates, ctx, onDebugChange, rapier, registerSystemAdapter, rigidBodyStates, world]);
289
+ liveAdapter = adapter;
290
+ return () => {
291
+ if (liveAdapter === adapter)
292
+ liveAdapter = null;
293
+ };
294
+ }, [colliderStates, onDebugChange, rapier, rigidBodyStates, world]);
309
295
  return null;
310
296
  }
297
+ // ---------------------------------------------------------------------------
298
+ // The entry-declaration half
299
+ // ---------------------------------------------------------------------------
300
+ /** The mounted bridge's adapter, while one is live. Module-scoped on purpose:
301
+ * the entry's `systems` declaration is static, the rapier world is not, and
302
+ * this slot is the seam between the two. */
303
+ let liveAdapter = null;
304
+ function requireLiveAdapter() {
305
+ if (!liveAdapter) {
306
+ throw new Error('rapierPhysicsSystem: no <RapierPhysicsBridge> is mounted inside a live <Physics> right ' +
307
+ 'now — the world is not playing, or the scene never mounted the bridge.');
308
+ }
309
+ return liveAdapter;
310
+ }
311
+ /**
312
+ * The `systems.physics` slot for a default-exported world — declare it from
313
+ * the entry (`export const systems = { physics: rapierPhysicsSystem() }`) and
314
+ * keep `<RapierPhysicsBridge />` mounted inside `<Physics>`. Every call
315
+ * forwards to the LIVE bridge adapter, so the static declaration answers with
316
+ * the running world's physics; while none is mounted, every call refuses by
317
+ * name rather than fabricating.
318
+ */
319
+ export function rapierPhysicsSystem() {
320
+ // Every forward REQUIRES the live adapter and its member — a silent no-op
321
+ // or fabricated empty answer is the shape the anti-shim rule forbids. The
322
+ // bridge's adapter always carries all five optional members, so the member
323
+ // checks only ever fire against a foreign replacement.
324
+ const requireMember = (member) => {
325
+ const adapter = requireLiveAdapter();
326
+ const value = adapter[member];
327
+ if (typeof value !== 'function') {
328
+ throw new Error(`rapierPhysicsSystem: the live physics adapter has no ${String(member)}().`);
329
+ }
330
+ return value.bind(adapter);
331
+ };
332
+ return {
333
+ ownerOf: (nodeId) => requireLiveAdapter().ownerOf(nodeId),
334
+ freeze: (nodeId) => requireLiveAdapter().freeze(nodeId),
335
+ commit: (nodeId, t) => requireLiveAdapter().commit(nodeId, t),
336
+ unfreeze: (nodeId) => requireLiveAdapter().unfreeze(nodeId),
337
+ setDebugDrawEnabled: (enabled) => requireMember('setDebugDrawEnabled')(enabled),
338
+ colliders: (nodeId) => requireMember('colliders')(nodeId),
339
+ previewCollider: (colliderId, shape) => requireMember('previewCollider')(colliderId, shape),
340
+ joints: (nodeId) => requireMember('joints')(nodeId),
341
+ previewJointAnchor: (jointId, endpoint, anchor) => requireMember('previewJointAnchor')(jointId, endpoint, anchor),
342
+ };
343
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.21",
5
+ "version": "0.5.22",
6
6
  "description": "Readable TypeScript game engine and universal host for Three.js, PixiJS, and React games.",
7
7
  "keywords": [
8
8
  "game-engine",
@@ -35,7 +35,7 @@
35
35
  "id": "input-value-model",
36
36
  "area": "input",
37
37
  "status": "supported-with-caveats",
38
- "claim": "F1 (spec §12) added a typed action-value model (digital/scalar/vector2/pointerDelta/pointerPosition, default digital) with real deadzone/combine rules and a valueType-mismatch throw. F2 ('Complete Device Backends') un-rejects mouse_move/gamepad_axis_pair, giving both real readers (mouse_move -> pointerDelta/vector2 off the real mouse-delta accumulator; gamepad_axis_pair -> vector2 off a coupled xAxis/yAxis stick, deadzone-rescaled like gamepad_axis), and adds touch_button/touch_stick (digital/vector2) via setTouchButton/setTouchStick. Real window focus (blur/focus) and pointer-lock state are tracked (requestPointerLock / exitPointerLock) and flush held REAL-device input on loss (#144: virtual/scheduled machine input gates on enabled only and survives blur); gamepad device identity (id/mapping) is exposed via getGamepadInfo/ActionValueSource.gamepadId. F4 adds getLastActiveDevice() + an optional getPrompt device param. Device-poll getters (isDeviceKeyPressed / isDeviceMouseButtonPressed / getDeviceMouseDelta / getDeviceMousePosition / hasDeviceGamepad / isDeviceGamepadButtonPressed / getDeviceGamepadAxis) read the same keysDown/mouseButtons/gamepads stores without inventing implicit actions, and return inert at READ time when inputActive is false. Caveat: pointerPosition still has no real-device backend (test_pointer_position injection only); scalar isn't fed by mouse_move (no axis-select field).",
38
+ "claim": "F1 (spec §12) added a typed action-value model (digital/scalar/vector2/pointerDelta/pointerPosition, default digital) with real deadzone/combine rules and a valueType-mismatch throw. F2 ('Complete Device Backends') un-rejects mouse_move/gamepad_axis_pair, giving both real readers (mouse_move -> pointerDelta/vector2 off the real mouse-delta accumulator; gamepad_axis_pair -> vector2 off a coupled xAxis/yAxis stick, deadzone-rescaled like gamepad_axis), and adds touch_button/touch_stick (digital/vector2) via setTouchButton/setTouchStick. Real window focus (blur/focus) and pointer-lock state are tracked (requestPointerLock / exitPointerLock) and flush held REAL-device input on loss (#144: virtual/scheduled machine input gates on enabled only and survives blur); gamepad device identity (id/mapping) is exposed via getGamepadInfo/ActionValueSource.gamepadId. F4 adds getLastActiveDevice() + an optional getPrompt device param. Device-poll getters (isDeviceKeyPressed / isDeviceMouseButtonPressed / getDeviceMouseDelta / getDeviceMousePosition / hasDeviceGamepad / isDeviceGamepadButtonPressed / getDeviceGamepadAxis) read the same keysDown/mouseButtons/gamepads stores without inventing implicit actions, and return inert at READ time when inputActive is false. getDeviceGamepadAxis is deliberately RAW — no deadzone: the engine's own stick deadzone is on the action path (gamepad_axis / gamepad_axis_pair, where a game declares and can override it), and a device-door caller that wants a stick applies its own engine's processor (unity-compat reproduces Unity's StickDeadzone at 0.125/0.925). Caveat: pointerPosition still has no real-device backend (test_pointer_position injection only); scalar isn't fed by mouse_move (no axis-select field).",
39
39
  "evidence": [
40
40
  "packages/engine/src/input/input-types.ts:2-40 (InputBinding union incl. mouse_move/gamepad_axis_pair/touch_button/touch_stick/test_*)",
41
41
  "packages/engine/src/input/input-manager.ts (digital/gating/typed getters/device-poll/inject*/setTouch*/getGamepadInfo/F4 getLastActiveDevice+getPrompt/requestPointerLock/exitPointerLock)"
@@ -43,7 +43,7 @@
43
43
  "notes": "gamepad disconnect/reconnect needs no special-cased code: poll() re-reads navigator.getGamepads() and every read skips a null slot, so a disconnected gamepad's actions go neutral with a correct isJustReleased edge for free — see input-device-backends-f2.test.ts.",
44
44
  "evidenceHashes": {
45
45
  "packages/engine/src/input/input-types.ts": "62d9005609a72b55a8214c869d1b27825b1a17b9389f3f3f5d0b04725331b382",
46
- "packages/engine/src/input/input-manager.ts": "7d34683759d7ab7fd20ca6f5b5e646eb76a31b00c77977816f4ef863ccb06e62"
46
+ "packages/engine/src/input/input-manager.ts": "928a1cde1c7245cfd8afc47794ddf28db8d1368bf7f88e602bb645f79f92ba61"
47
47
  }
48
48
  },
49
49
  {
@@ -88,7 +88,7 @@
88
88
  "notes": "Scope is native-DOM React ONLY (D-N1): R3F/@pixi/react games are canvas games and ride the three.js/pixi seams instead. Generality limit: ONE proven game (react-rpg — menu-to-dungeon e2e under React 19; both game-2 candidates rejected on evidence, R-N7). Game deps must be host-installed root deps (D-N6). Open R-N6: vendored react games' raw window listeners bypass the play-mode input gate.",
89
89
  "evidenceHashes": {
90
90
  "vendor/games/verify-unaltered.mjs": "257a362030a10f4d72a3280815069fbd83f436e1619391635b6f8df2ddd6c339",
91
- "packages/editor/src/adapter-resolver.ts": "574fb1301f1314af1b268f6fc71e8dcde291620f2f01b5d8d3ce251095d7c775"
91
+ "packages/editor/src/adapter-resolver.ts": "63e4204afceb4395f395d5a4df48a5148de75da597a4407ee74ccf233c5d2fa6"
92
92
  }
93
93
  },
94
94
  {
@@ -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,13 @@ 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
+ projectContractSystemAdapters,
27
+ } from './ingest/contract-system-adapters';
28
+ import type { VgaiGameSystemAdapters } from './ingest/game-contract';
29
+ import type { SystemAdapters } from './system-adapter';
23
30
 
24
31
  export const NATIVE_INPUT_VALUE_TYPES = [
25
32
  'digital',
@@ -31,11 +38,31 @@ export const NATIVE_INPUT_VALUE_TYPES = [
31
38
  export type NativeInputValueType = (typeof NATIVE_INPUT_VALUE_TYPES)[number];
32
39
  export type NativeInputValue = boolean | number | { readonly x: number; readonly y: number };
33
40
 
41
+ /** A table may be a THUNK, evaluated once at install (after every project
42
+ * module has run), so a game whose declarations accrete during module
43
+ * evaluation — the dev-tools registry's `stat()`/`cheat()` lines beside each
44
+ * mechanic — hands a complete snapshot without ordering its own imports
45
+ * around the debugger. */
46
+ type NativeTable<T> = Readonly<Record<string, T>> | (() => Readonly<Record<string, T>>);
47
+
48
+ /** A command is a plain function, or `{ description, run }` when it wants the
49
+ * session's `game.commands()` listing to say what it does. Argument
50
+ * validation stays inside the function — the game's words at the game's
51
+ * door. */
52
+ export type NativeCommandEntry =
53
+ | ((...args: unknown[]) => unknown)
54
+ | { readonly description?: string; readonly run: (...args: unknown[]) => unknown };
55
+
34
56
  export interface NativeDebugModule {
35
- readonly commands?: Readonly<Record<string, (...args: unknown[]) => unknown>>;
36
- readonly state?: Readonly<Record<string, () => unknown>>;
57
+ readonly commands?: NativeTable<NativeCommandEntry>;
58
+ readonly state?: NativeTable<() => unknown>;
37
59
  readonly input?: {
38
- readonly actions: Readonly<Record<string, NativeInputValueType>>;
60
+ /** May be a thunk, read LIVE on every use — an app whose action set loads
61
+ * asynchronously (an input map fetched at boot) answers with what it has
62
+ * NOW rather than freezing the empty pre-load set at install. */
63
+ readonly actions:
64
+ | Readonly<Record<string, NativeInputValueType>>
65
+ | (() => Readonly<Record<string, NativeInputValueType>>);
39
66
  readonly set: (action: string, value: NativeInputValue) => void;
40
67
  readonly clear: () => void;
41
68
  /** Optional native-scheduler operation. The adapter never synthesizes one. */
@@ -43,6 +70,12 @@ export interface NativeDebugModule {
43
70
  /** Optional native-scheduler operation. The adapter never adds a host phase. */
44
71
  readonly scheduleAtTick?: (tick: number, action: string, value: NativeInputValue) => void;
45
72
  };
73
+ /** The app's own EVENT STREAM, bridged into the session's tick-stamped
74
+ * event log at install — the entry-export replacement for the react
75
+ * emit hook. The app owns the emitter; the host only forwards. */
76
+ readonly events?: {
77
+ readonly subscribe: (listener: (event: string, detail?: unknown) => void) => () => void;
78
+ };
46
79
  }
47
80
 
48
81
  type NativeInputBinding = NonNullable<NativeDebugModule['input']>;
@@ -99,6 +132,15 @@ function parseActions(value: unknown): Record<string, NativeInputValueType> {
99
132
  function parseInput(value: unknown): NativeInputBinding {
100
133
  if (!isRecord(value)) throw new Error('debug.input must be an object.');
101
134
  rejectUnknownKeys(value, ['actions', 'set', 'clear', 'tap', 'scheduleAtTick'], 'debug.input');
135
+ if (typeof value['actions'] === 'function') {
136
+ // A live table: validated per read (requireAction/the actions source), not
137
+ // here — the whole point is that its content changes after install.
138
+ const set = value['set'];
139
+ const clear = value['clear'];
140
+ if (typeof set !== 'function') throw new Error('debug.input.set must be a function.');
141
+ if (typeof clear !== 'function') throw new Error('debug.input.clear must be a function.');
142
+ return value as unknown as NativeInputBinding;
143
+ }
102
144
  const set = value['set'];
103
145
  const clear = value['clear'];
104
146
  const tap = value['tap'];
@@ -134,14 +176,28 @@ export function nativeDebugBindingFromEntryModule(
134
176
  throw new Error(`Root "${rootId}" exports \`debug\`, but it is not an object.`);
135
177
  }
136
178
  const raw = entryModule['debug'];
137
- rejectUnknownKeys(raw, ['commands', 'state', 'input'], `Root "${rootId}" debug export`);
179
+ rejectUnknownKeys(raw, ['commands', 'state', 'input', 'events'], `Root "${rootId}" debug export`);
138
180
 
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');
181
+ // A thunk table defers to install time (see {@link NativeTable}); a plain
182
+ // record validates here, where the error can still name the export.
183
+ const parseTable = (value: unknown, at: string) => {
184
+ if (value === undefined) return undefined;
185
+ if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
186
+ return functionRecord(value, at);
187
+ };
188
+ // Commands allow the `{ description?, run }` entry shape; full validation
189
+ // happens at install (`resolveCommandTable`), where thunks resolve too.
190
+ const parseCommands = (value: unknown, at: string) => {
191
+ if (value === undefined) return undefined;
192
+ if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
193
+ if (!isRecord(value)) throw new Error(`${at} must be an object of named commands.`);
194
+ return value as Readonly<Record<string, NativeCommandEntry>>;
195
+ };
196
+ const commands = parseCommands(raw['commands'], 'debug.commands');
197
+ const state = parseTable(raw['state'], 'debug.state');
143
198
  const input = raw['input'] === undefined ? undefined : parseInput(raw['input']);
144
- if (!commands && !state && !input) {
199
+ const events = raw['events'] === undefined ? undefined : parseEvents(raw['events']);
200
+ if (!commands && !state && !input && !events) {
145
201
  throw new Error(`Root "${rootId}" exports an empty \`debug\` object.`);
146
202
  }
147
203
  return {
@@ -150,10 +206,48 @@ export function nativeDebugBindingFromEntryModule(
150
206
  ...(commands ? { commands } : {}),
151
207
  ...(state ? { state } : {}),
152
208
  ...(input ? { input } : {}),
209
+ ...(events ? { events } : {}),
153
210
  },
154
211
  };
155
212
  }
156
213
 
214
+ function parseEvents(value: unknown): NonNullable<NativeDebugModule['events']> {
215
+ if (!isRecord(value) || typeof value['subscribe'] !== 'function') {
216
+ throw new Error('debug.events must be an object with a subscribe(listener) function.');
217
+ }
218
+ return value as unknown as NonNullable<NativeDebugModule['events']>;
219
+ }
220
+
221
+ /** Resolve a possibly-thunk COMMAND table, validating each entry's shape. */
222
+ function resolveCommandTable(
223
+ table: NativeTable<NativeCommandEntry> | undefined,
224
+ at: string,
225
+ ): Readonly<Record<string, NativeCommandEntry>> {
226
+ if (table === undefined) return {};
227
+ const raw = typeof table === 'function' ? table() : table;
228
+ if (!isRecord(raw)) throw new Error(`${at} must be an object of named commands.`);
229
+ for (const [name, entry] of Object.entries(raw)) {
230
+ if (!name) throw new Error(`${at} contains an empty name.`);
231
+ const ok =
232
+ typeof entry === 'function' ||
233
+ (isRecord(entry) && typeof (entry as { run?: unknown }).run === 'function');
234
+ if (!ok) throw new Error(`${at}.${name} must be a function or { description?, run }.`);
235
+ }
236
+ return raw as Readonly<Record<string, NativeCommandEntry>>;
237
+ }
238
+
239
+ /** Resolve a possibly-thunk table at install time, validating the result. */
240
+ function resolveTable<T>(
241
+ table: NativeTable<T> | undefined,
242
+ at: string,
243
+ ): Readonly<Record<string, T>> {
244
+ if (table === undefined) return {};
245
+ if (typeof table === 'function') {
246
+ return functionRecord(table(), at) as Readonly<Record<string, T>>;
247
+ }
248
+ return table;
249
+ }
250
+
157
251
  function valueMatches(type: NativeInputValueType, value: NativeInputValue): boolean {
158
252
  if (type === 'digital') return typeof value === 'boolean';
159
253
  if (type === 'scalar') return typeof value === 'number' && Number.isFinite(value);
@@ -176,14 +270,19 @@ function installInput(
176
270
  const trace: Array<{ tick: number; action: string; value: NativeInputValue }> = [];
177
271
  let recording = false;
178
272
 
273
+ // LIVE read on every use — see the `actions` field's own comment.
274
+ const liveActions = (): Readonly<Record<string, NativeInputValueType>> =>
275
+ typeof input.actions === 'function' ? input.actions() : input.actions;
276
+
179
277
  const requireAction = (action: string, value: NativeInputValue): NativeInputValueType => {
180
- const type = input.actions[action];
278
+ const actions = liveActions();
279
+ const type = actions[action];
181
280
  if (!type) {
182
281
  throw new DebugError(
183
282
  'INPUT_ACTION_NOT_FOUND',
184
283
  `Native input has no action named "${action}".`,
185
284
  {
186
- registered: Object.keys(input.actions),
285
+ registered: Object.keys(actions),
187
286
  },
188
287
  );
189
288
  }
@@ -271,7 +370,7 @@ function installInput(
271
370
  };
272
371
 
273
372
  registry.setInputActionsSource(binding.rootId, () =>
274
- Object.entries(input.actions).map(([name, valueType]) => ({ name, valueType })),
373
+ Object.entries(liveActions()).map(([name, valueType]) => ({ name, valueType })),
275
374
  );
276
375
  registry.setInputTraceSource(binding.rootId, () => ({
277
376
  version: 1,
@@ -296,12 +395,110 @@ export function installNativeDebugBindings(
296
395
  throw new Error(`Native debug binding names unmounted root "${binding.rootId}".`);
297
396
  }
298
397
  const root = registry.forRoot(binding.rootId);
299
- for (const [name, read] of Object.entries(binding.debug.state ?? {})) {
398
+ const state = resolveTable(binding.debug.state, `Root "${binding.rootId}" debug.state`);
399
+ for (const [name, read] of Object.entries(state)) {
300
400
  root.registerStateProvider(name, read, { tier: 'assisted' });
301
401
  }
302
- for (const [name, command] of Object.entries(binding.debug.commands ?? {})) {
303
- root.registerCommand(name, { locus: 'client' }, command);
402
+ const commands = resolveCommandTable(
403
+ binding.debug.commands,
404
+ `Root "${binding.rootId}" debug.commands`,
405
+ );
406
+ for (const [name, entry] of Object.entries(commands)) {
407
+ const run = typeof entry === 'function' ? entry : entry.run;
408
+ const description = typeof entry === 'function' ? undefined : entry.description;
409
+ root.registerCommand(
410
+ name,
411
+ { locus: 'client', ...(description === undefined ? {} : { description }) },
412
+ run,
413
+ );
304
414
  }
305
415
  if (binding.debug.input) installInput(game, registry, binding, binding.debug.input);
416
+ // The app's emitter → the session's tick-stamped event log. The
417
+ // subscription's disposer is deliberately dropped: it lives exactly as
418
+ // long as this Game's registry does.
419
+ binding.debug.events?.subscribe((event, detail) => root.emit(event, detail));
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // The native `systems` export — the first-party door onto `SystemAdapters`
425
+ // ---------------------------------------------------------------------------
426
+ //
427
+ // The sibling of `debug` on the SAME module surface: a root entry re-exports
428
+ // one `systems` object beside it —
429
+ //
430
+ // export { debug, systems } from './commands';
431
+ //
432
+ // — whose slots are the ONE declarable carrier both realms share,
433
+ // `VgaiGameSystemAdapters` (`ingest/game-contract.ts`; the native engine is
434
+ // the premade 100% implementation of that contract). Validation is the SAME
435
+ // projection the ingest realm uses (`ingest/contract-system-adapters.ts`), so
436
+ // there is one shape law, not two. This door is what retires
437
+ // `ctx.registerSystemAdapter` from component code (ARCHITECTURE-CORE §System
438
+ // adapters: "a project's `vgai.adapter.ts` binds app-owned systems through
439
+ // declared native exports … Components never call `registerSystemAdapter`").
440
+ //
441
+ // Native-realm difference from ingest: a malformed slot THROWS (this is our
442
+ // own code failing its own contract — fail fast), where the ingest projection
443
+ // files a verdict for the coverage report. A `{ present: false, evidence }`
444
+ // slot is accepted as the positive absence it is and binds nothing.
445
+
446
+ /** One root's statically declared, already-validated system-adapter slots. */
447
+ export interface NativeSystemsBinding {
448
+ readonly rootId: string;
449
+ readonly slots: Readonly<Partial<SystemAdapters>>;
450
+ }
451
+
452
+ /**
453
+ * Read the single native `systems` export from an already-loaded root module.
454
+ * `surface` is the root's own mount surface when the caller knows it — it
455
+ * feeds the physics keying check (`PHYSICS_KEYING_BY_SURFACE`).
456
+ */
457
+ export function nativeSystemsBindingFromEntryModule(
458
+ rootId: string,
459
+ entryModule: unknown,
460
+ surface?: ContractSurface | undefined,
461
+ ): NativeSystemsBinding | null {
462
+ if (!isRecord(entryModule) || entryModule['systems'] === undefined) return null;
463
+ const raw = entryModule['systems'];
464
+ if (!isRecord(raw)) {
465
+ throw new Error(`Root "${rootId}" exports \`systems\`, but it is not an object.`);
466
+ }
467
+ rejectUnknownKeys(raw, CONTRACT_SYSTEM_SLOTS, `Root "${rootId}" systems export`);
468
+ const projection = projectContractSystemAdapters(
469
+ { systemAdapters: raw as VgaiGameSystemAdapters },
470
+ surface,
471
+ );
472
+ if (projection.malformed.length > 0) {
473
+ throw new Error(
474
+ `Root "${rootId}" systems export is malformed: ` +
475
+ projection.malformed.map((slot) => `${slot.slot} — ${slot.reason}`).join('; '),
476
+ );
477
+ }
478
+ if (Object.keys(projection.bound).length === 0 && projection.empty.length === 0) {
479
+ throw new Error(`Root "${rootId}" exports an empty \`systems\` object.`);
480
+ }
481
+ return { rootId, slots: projection.bound };
482
+ }
483
+
484
+ /** Install every declared binding onto the mounted Game's game-scoped slot
485
+ * table (`Game.installDeclaredSystemAdapters`), where the ordinary
486
+ * `game.systemAdapters` merge picks them up ahead of any lingering component
487
+ * registration for the same root. */
488
+ export function installNativeSystemsBindings(
489
+ game: Game,
490
+ bindings: readonly NativeSystemsBinding[],
491
+ ): void {
492
+ if (bindings.length === 0) return;
493
+ const install = game.installDeclaredSystemAdapters;
494
+ if (!install) {
495
+ throw new Error(
496
+ 'Cannot install native systems bindings: this Game does not implement ' +
497
+ 'installDeclaredSystemAdapters.',
498
+ );
499
+ }
500
+ for (const binding of bindings) {
501
+ if (Object.keys(binding.slots).length === 0) continue;
502
+ install.call(game, binding.rootId, binding.slots);
306
503
  }
307
504
  }
@@ -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
  *
@@ -15,19 +15,15 @@
15
15
  * or the Pixi reconciler.
16
16
  *
17
17
  * Surface:
18
- * - `createPixiReactAdapter({ id, content })` — mount a `@pixi/react` tree as
19
- * a first-party `surface: "canvas"` root under the host's gated loop.
20
18
  * - `pixiReactRootFactory` / `resolveCanvasEntryAdapter` — what a canvas entry
21
- * module MEANS: a default-exported component (or an explicit `adapter`).
19
+ * module MEANS: a default-exported component, mounted as a first-party
20
+ * `surface: "canvas"` root under the host's gated loop. The world's vgai
21
+ * surface is its entry module's static exports
22
+ * (`export { debug, systems } from './commands'`), connected by the host at
23
+ * mount.
22
24
  * - `PixiPrimitive` / `adoptNow` — the canvas surface's `<primitive object={…}>`:
23
25
  * render a container the game already owns, and place a spawn in the
24
26
  * display tree ahead of the commit that renders it.
25
- * - `useGameContext()` — this root's `CanvasGameContext` (`input`, `debug`,
26
- * `random`, `clock`, `game`, `roots`) from inside any component in the tree.
27
- * Behavior in a canvas world is components + hooks, never a class under a
28
- * string key.
29
- * - `EngineBridge` / `EngineBridgeValue` — the context contract, exported for
30
- * adopters building their own bridge.
31
27
  *
32
28
  * The Pixi runtime itself is NOT re-exported here. A component reaches the live
33
29
  * `Application` (and therefore `stage`, `renderer`, `ticker`) with
@@ -35,17 +31,5 @@
35
31
  * assets with Pixi's own `Assets` — the library's API, not a second one.
36
32
  */
37
33
 
38
- export { EngineBridge, type EngineBridgeValue, useGameContext } from './engine-bridge';
39
34
  export { adoptNow, PixiPrimitive, type PixiPrimitiveProps } from './pixi-primitive';
40
- export {
41
- type CreatePixiReactAdapterOptions,
42
- createPixiReactAdapter,
43
- } from './pixi-react-adapter';
44
35
  export { pixiReactRootFactory, resolveCanvasEntryAdapter } from './pixi-react-root-factory';
45
- export {
46
- type CanvasGameContext,
47
- type CanvasRootContextOptions,
48
- type CanvasRootRuntime,
49
- createCanvasRootContext,
50
- DEFAULT_INPUT_MAP_PATH,
51
- } from './world-context';