@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
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Replace Three's image-based-lighting functions on a `MeshStandardMaterial`,
3
+ * per fragment, without forking three.
4
+ *
5
+ * A local-probe system cannot express itself as `material.envMap`: one envMap
6
+ * per object picks ONE environment for the whole draw, and the decisive work —
7
+ * which probe volume contains this fragment, how it fades at the boundary,
8
+ * what happens where volumes overlap — is per-fragment and can differ across a
9
+ * single `InstancedMesh`. Every such system therefore does the same three
10
+ * things to three's own program, and this module is that shared mechanism:
11
+ *
12
+ * 1. take `ShaderChunk.envmap_physical_pars_fragment` and rename its two
13
+ * entry points, so the caller's replacement can still call them as its
14
+ * fallback ({@link baseIblFunctions});
15
+ * 2. publish the fragment's world position as a varying, spliced into
16
+ * `<worldpos_vertex>` under `USE_ENVMAP` ({@link IBL_WORLD_POSITION});
17
+ * 3. swap the fragment chunk for the caller's own GLSL and extend the
18
+ * program cache key so the variant is not shared with an unpatched
19
+ * material ({@link overrideMaterialIbl}).
20
+ *
21
+ * What the caller keeps is exactly what makes its system ITS system: the
22
+ * lighting model in the GLSL body, the uniforms behind it, and how probes are
23
+ * discovered, scheduled and captured. This module decides none of that — it
24
+ * hands back three's own shader object and gets out of the way.
25
+ *
26
+ * Consumers today: the `reflections` capability's native probe system, and
27
+ * `godot-compat`'s Godot 3 `ReflectionProbe`, whose per-fragment models are
28
+ * deliberately different renderers over this one splice.
29
+ */
30
+ import { type Mesh, type MeshStandardMaterial, ShaderChunk } from 'three';
31
+
32
+ /** The shader object three hands to `onBeforeCompile`. */
33
+ export type IblOverrideShader = Parameters<MeshStandardMaterial['onBeforeCompile']>[0];
34
+
35
+ /**
36
+ * The `varying vec3` the vertex splice publishes and the fragment body reads.
37
+ *
38
+ * It is ONE name across every consumer on purpose: the splice that declares it
39
+ * lives here, so a per-consumer name would be a string two files have to agree
40
+ * on with nothing checking that they do.
41
+ */
42
+ export const IBL_WORLD_POSITION = 'vgaiIblWorldPosition';
43
+
44
+ /**
45
+ * Three's own IBL chunk with `getIBLIrradiance`/`getIBLRadiance` renamed to
46
+ * `getBaseIBLIrradiance`/`getBaseIBLRadiance`.
47
+ *
48
+ * A replacement chunk defines the original names itself; calling the renamed
49
+ * pair is how it falls back to the scene environment where no probe applies.
50
+ */
51
+ export function baseIblFunctions(): string {
52
+ return ShaderChunk.envmap_physical_pars_fragment
53
+ .replaceAll('getIBLIrradiance', 'getBaseIBLIrradiance')
54
+ .replaceAll('getIBLRadiance', 'getBaseIBLRadiance');
55
+ }
56
+
57
+ /**
58
+ * Every material a mesh draws with that {@link overrideMaterialIbl} accepts.
59
+ *
60
+ * `isMeshStandardMaterial` is true for `MeshPhysicalMaterial` too, which is the
61
+ * intent: both compile the physical IBL chunk this module replaces.
62
+ */
63
+ export function standardMaterialsOf(mesh: Mesh): readonly MeshStandardMaterial[] {
64
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
65
+ return materials.filter((material): material is MeshStandardMaterial =>
66
+ Boolean((material as MeshStandardMaterial).isMeshStandardMaterial),
67
+ );
68
+ }
69
+
70
+ export interface IblOverrideOptions {
71
+ /**
72
+ * The GLSL that replaces `#include <envmap_physical_pars_fragment>`. Read at
73
+ * COMPILE time, not at install time, so a caller whose probe count is still
74
+ * settling does not have to reinstall to change it.
75
+ */
76
+ readonly fragment: () => string;
77
+ /**
78
+ * Appended to the material's own `customProgramCacheKey`, after a `|`. Must
79
+ * distinguish every shape of {@link IblOverrideOptions.fragment} the caller
80
+ * can produce — a probe COUNT belongs in here, because it changes the
81
+ * declared uniform array sizes.
82
+ */
83
+ readonly cacheKey: () => string;
84
+ /**
85
+ * Runs at the end of `onBeforeCompile`, with the shader three is compiling.
86
+ * Seed uniforms here: this is the only moment the caller is handed the
87
+ * object whose `uniforms` map the program will read.
88
+ */
89
+ readonly onCompile?: (shader: IblOverrideShader) => void;
90
+ }
91
+
92
+ export interface IblOverride {
93
+ readonly material: MeshStandardMaterial;
94
+ /**
95
+ * The shader three compiled this material with, or null before the first
96
+ * compile and after {@link IblOverride.restore}. Uniform writes go through
97
+ * `shader.uniforms`.
98
+ */
99
+ readonly shader: IblOverrideShader | null;
100
+ /** Put the material's own `onBeforeCompile`/`customProgramCacheKey` back. */
101
+ restore(): void;
102
+ }
103
+
104
+ /**
105
+ * Install the override. The returned handle is the only way to reach the
106
+ * compiled shader or to undo the patch; the material is otherwise untouched
107
+ * (in particular this never assigns `envMap`, which stays the caller's).
108
+ *
109
+ * The caller's own `onBeforeCompile` runs FIRST and its result is what gets
110
+ * spliced, so an override composes over a material that already had one.
111
+ */
112
+ export function overrideMaterialIbl(
113
+ material: MeshStandardMaterial,
114
+ options: IblOverrideOptions,
115
+ ): IblOverride {
116
+ const previousCompile = material.onBeforeCompile;
117
+ const previousCacheKey = material.customProgramCacheKey;
118
+ let compiled: IblOverrideShader | null = null;
119
+
120
+ material.onBeforeCompile = (shader, renderer) => {
121
+ previousCompile.call(material, shader, renderer);
122
+ requireChunk(shader.vertexShader, '#include <worldpos_vertex>');
123
+ requireChunk(shader.fragmentShader, '#include <envmap_physical_pars_fragment>');
124
+ shader.vertexShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.vertexShader.replace(
125
+ '#include <worldpos_vertex>',
126
+ `#include <worldpos_vertex>
127
+ #if defined(USE_ENVMAP)
128
+ ${IBL_WORLD_POSITION} = worldPosition.xyz;
129
+ #endif`,
130
+ )}`;
131
+ shader.fragmentShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.fragmentShader.replace(
132
+ '#include <envmap_physical_pars_fragment>',
133
+ options.fragment(),
134
+ )}`;
135
+ compiled = shader;
136
+ options.onCompile?.(shader);
137
+ };
138
+ material.customProgramCacheKey = () => `${previousCacheKey.call(material)}|${options.cacheKey()}`;
139
+ material.needsUpdate = true;
140
+
141
+ return {
142
+ material,
143
+ get shader() {
144
+ return compiled;
145
+ },
146
+ restore() {
147
+ material.onBeforeCompile = previousCompile;
148
+ material.customProgramCacheKey = previousCacheKey;
149
+ material.needsUpdate = true;
150
+ compiled = null;
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * A missing chunk is a NAMED error rather than a silent no-op.
157
+ *
158
+ * `String.replace` with no match returns the string unchanged, which would
159
+ * leave the varying declared and never assigned — a shader that links and
160
+ * renders the wrong thing. The only way to reach this today is a preceding
161
+ * `onBeforeCompile` in the same chain having already replaced the chunk (two
162
+ * IBL overrides stacked on one material), and that is worth a message naming
163
+ * the chunk instead of a black frame.
164
+ */
165
+ function requireChunk(source: string, chunk: string): void {
166
+ if (source.includes(chunk)) return;
167
+ throw new Error(
168
+ `overrideMaterialIbl needs three's ${chunk} shader chunk, and this material's program no longer contains it.`,
169
+ );
170
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The GAME-SCOPED input wiring one world mount owns — HOST work performed from
3
+ * outside the world's own tree, shared verbatim by the three and canvas lanes
4
+ * (a project has ONE input map and one bot-input doctrine, whatever surface
5
+ * reads them).
6
+ *
7
+ * Two things happen here:
8
+ *
9
+ * 1. the project's input map is loaded through `Game.loadInputMap` (game-owned,
10
+ * load-once), so the actions the project declared exist;
11
+ * 2. this root's seams are registered on the game-scoped debug registry
12
+ * (`setVirtualInputTarget`/`setInputActionsSource`/`setInputTraceSource`),
13
+ * which is what makes `game.input.hold/tap/...` (the bot/`vgai eval` input
14
+ * doctrine) and the built-in `input.actions`/`input.trace` providers
15
+ * resolve to this world instead of throwing `DEBUG_INPUT_UNAVAILABLE`.
16
+ *
17
+ * A world's React tree receives no vgai context — this wiring is precisely the
18
+ * part that never needed one: every closure below reads GAME-scoped state the
19
+ * host owns (`game.input`, `game.loop.fixedDt`, the game's seed).
20
+ *
21
+ * `optionalInputMap` is the CONVENTIONAL-PATH probe used when the project
22
+ * never named a map: an absent file is the ordinary state of a brand-new
23
+ * project, so it resolves quietly instead of printing an error on every boot.
24
+ * A map that exists and fails to parse is as loud as ever.
25
+ *
26
+ * LIFETIME, honestly: `registry.strip(id)` runs on the mount's dispose, but it
27
+ * walks providers/commands only — the three seams set here survive it, keyed
28
+ * by this world's id, until a remount of the same id overwrites them. That
29
+ * residue is inert rather than hidden: every closure reads game-scoped state,
30
+ * so a stale entry actuates exactly what a live one would. What it can still
31
+ * do is name a dead world in `DEBUG_INPUT_WORLD_NOT_FOUND`'s registered list.
32
+ * Do not write "the strip clears the input seams" anywhere without changing
33
+ * `strip` to actually do it.
34
+ */
35
+
36
+ import { getSeededRandom } from '../core/seeded-random';
37
+ import type { DebugRegistry } from './debug-registry';
38
+ import type { GameInternal } from './game';
39
+
40
+ /** The conventional input-map location every scaffolded project ships
41
+ * (`public/inputmaps/default.inputmap.json`). */
42
+ export const DEFAULT_INPUT_MAP_PATH = '/inputmaps/default.inputmap.json';
43
+
44
+ /** The slice of a host context this wiring reads — satisfied structurally by
45
+ * both `ThreeHostContext` and `CanvasHostContext`. */
46
+ export interface GameInputSeamHost {
47
+ readonly game?: GameInternal | null | undefined;
48
+ readonly headless?: boolean | undefined;
49
+ }
50
+
51
+ export function wireGameInputSeams(
52
+ host: GameInputSeamHost,
53
+ registry: DebugRegistry,
54
+ options: {
55
+ readonly id: string;
56
+ readonly inputMapPath?: string | null | undefined;
57
+ readonly optionalInputMap?: boolean | undefined;
58
+ },
59
+ ): Promise<void> {
60
+ const { id, inputMapPath = DEFAULT_INPUT_MAP_PATH, optionalInputMap = false } = options;
61
+ if (!host.game) return Promise.resolve();
62
+ const game = host.game;
63
+ const input = game.input;
64
+ registry.setInputActionsSource(id, () =>
65
+ input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) })),
66
+ );
67
+ registry.setInputTraceSource(id, () => {
68
+ const raw = input.getInputTrace();
69
+ return {
70
+ version: raw.version,
71
+ seed: getSeededRandom(game)?.seed ?? null,
72
+ fixedDt: game.loop.fixedDt,
73
+ ticks: raw.ticks,
74
+ };
75
+ });
76
+ registry.setVirtualInputTarget(id, {
77
+ setVirtualAction: (action, value) => input.setVirtualAction(action, value),
78
+ tapVirtualAction: (action) => input.tapVirtualAction(action),
79
+ clearVirtualActions: () => input.clearVirtualActions(),
80
+ scheduleActionAtTick: (tick, action, value) => input.scheduleActionAtTick(tick, action, value),
81
+ startInputRecording: () => input.startInputRecording(),
82
+ stopInputRecording: () => input.stopInputRecording(),
83
+ isInputRecording: () => input.isInputRecording(),
84
+ injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
85
+ injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
86
+ injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
87
+ injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
88
+ });
89
+ if (host.headless || inputMapPath === null) return Promise.resolve();
90
+ // Load-once through the game-owned path (competing paths across roots throw
91
+ // THERE, loudly). A FAILED load (missing/bad file) must not fail the mount:
92
+ // a world with no declared actions is legal. It degrades loudly instead —
93
+ // naming exactly what breaks.
94
+ // The non-optional call passes ONE argument, exactly as it always has — a
95
+ // trailing `undefined` is a different call to any observer of it.
96
+ const load = optionalInputMap
97
+ ? game.loadInputMap(inputMapPath, { optional: true })
98
+ : game.loadInputMap(inputMapPath);
99
+ return load.catch((err: unknown) => {
100
+ // biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to failing the mount (see comment above)
101
+ console.error(
102
+ `world "${id}": failed to load input map "${inputMapPath}" — declared input ` +
103
+ 'actions and `game.input.*` (bot/virtual input) will not work until a valid map ' +
104
+ `loads. Ship one at the conventional path (${DEFAULT_INPUT_MAP_PATH}). ` +
105
+ `Cause: ${err instanceof Error ? err.message : String(err)}`,
106
+ );
107
+ });
108
+ }
@@ -538,6 +538,21 @@ export interface Game {
538
538
  readonly systemAdapters: SystemAdapters;
539
539
  /** Subscribe when a mounted root registers or replaces a system adapter. */
540
540
  subscribeSystemAdapters?(listener: () => void): () => void;
541
+ /**
542
+ * Install a root's STATICALLY DECLARED system-adapter slots — the native
543
+ * module surface's `export const systems` (adapter/native-debug-module.ts),
544
+ * already validated by `contract-system-adapters.ts`. The host calls this
545
+ * once per declaring root at mount; project components never do
546
+ * (ARCHITECTURE-CORE §System adapters: "Components never call
547
+ * `registerSystemAdapter`" — this door is the replacement, and the slots it
548
+ * installs take precedence over a component registration for the same root
549
+ * in {@link systemAdapters}'s merge, with the standard collision warning).
550
+ * Throws on an unmounted root id or a second declaration for the same root.
551
+ * Optional for the same reason {@link subscribeSystemAdapters} is: partial
552
+ * `Game` doubles in tests; `installNativeSystemsBindings` refuses loudly
553
+ * when the hosting Game lacks it.
554
+ */
555
+ installDeclaredSystemAdapters?(rootId: string, slots: Readonly<Partial<SystemAdapters>>): void;
541
556
  /** The one game-owned `InputManager`, shared by every first-party root. */
542
557
  readonly input: InputManager;
543
558
  /**
@@ -881,7 +896,13 @@ export function createGame(opts: {
881
896
  // above's "once per game instance" idiom.
882
897
  const warnedSystemAdapterKeys = new Set<string>();
883
898
  const systemAdapterListeners = new Set<() => void>();
884
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
899
+ /** Per-root slots installed by `installDeclaredSystemAdapters` the native
900
+ * module surface's static `systems` declaration. Merged BEFORE the same
901
+ * root's live `mounted.systems` below, so during migration a lingering
902
+ * component registration for a declared slot is shadowed (with the
903
+ * standard warning) rather than silently winning by running later. */
904
+ const declaredSystemAdapters = new Map<string, Readonly<Partial<SystemAdapters>>>();
905
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-source × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
885
906
  function computeSystemAdapters(): SystemAdapters {
886
907
  // Debug is game-scoped: React hooks, probes, and the built-in time
887
908
  // provider all register with the one registry created above, regardless
@@ -891,9 +912,11 @@ export function createGame(opts: {
891
912
  // intentional shared registration it is.
892
913
  const result: SystemAdapters = { debug: debugRegistry.adapter };
893
914
  const ownerRootId = new Map<string, string>([['debug', '(game)']]);
894
- for (const world of roots) {
895
- const adapters = world.mounted.systems;
896
- if (!adapters) continue;
915
+ const merge = (
916
+ owner: string,
917
+ adapters: Readonly<Partial<SystemAdapters>>,
918
+ quietKeys?: ReadonlySet<string>,
919
+ ): void => {
897
920
  for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
898
921
  if (adapters[key] === undefined) continue;
899
922
  const existingOwner = ownerRootId.get(key);
@@ -903,12 +926,19 @@ export function createGame(opts: {
903
926
  // object under `systems.debug` — that is by design, not a
904
927
  // collision, so it must never warn.
905
928
  if (result[key] === adapters[key]) continue;
929
+ // A world's own DECLARED slot overriding that same world's
930
+ // substrate-seeded default (the three lane pre-seeds physics/audio/
931
+ // renderDebug into `mounted.systems`) is the door working as
932
+ // intended, not a collision — same silence `ctx.
933
+ // registerSystemAdapter` gives the same override. Cross-root
934
+ // collisions still warn.
935
+ if (quietKeys?.has(key)) continue;
906
936
  if (!warnedSystemAdapterKeys.has(key)) {
907
937
  warnedSystemAdapterKeys.add(key);
908
938
  // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
909
939
  console.warn(
910
- `[game] systemAdapters: "${key}" is registered by both world "${existingOwner}" and ` +
911
- `world "${world.id}" — the FIRST registration ("${existingOwner}") wins; the later ` +
940
+ `[game] systemAdapters: "${key}" is registered by both ${existingOwner} and ` +
941
+ `${owner} — the FIRST registration (${existingOwner}) wins; the later ` +
912
942
  'one is shadowed (game-scoped system adapters).',
913
943
  );
914
944
  }
@@ -916,7 +946,19 @@ export function createGame(opts: {
916
946
  }
917
947
  // biome-ignore lint/suspicious/noExplicitAny: SystemAdapters is a plain optional-field record; the per-key copy is correct by construction (same key on both sides), just not expressible without a cast
918
948
  (result as any)[key] = adapters[key];
919
- ownerRootId.set(key, world.id);
949
+ ownerRootId.set(key, owner);
950
+ }
951
+ };
952
+ for (const world of roots) {
953
+ const declared = declaredSystemAdapters.get(world.id);
954
+ if (declared) merge(`world "${world.id}" (declared systems export)`, declared);
955
+ const adapters = world.mounted.systems;
956
+ if (adapters) {
957
+ merge(
958
+ `world "${world.id}"`,
959
+ adapters,
960
+ declared ? new Set(Object.keys(declared)) : undefined,
961
+ );
920
962
  }
921
963
  }
922
964
  return result;
@@ -1292,6 +1334,22 @@ export function createGame(opts: {
1292
1334
  notifySystemAdaptersChanged() {
1293
1335
  for (const listener of systemAdapterListeners) listener();
1294
1336
  },
1337
+ installDeclaredSystemAdapters(rootId: string, slots: Readonly<Partial<SystemAdapters>>) {
1338
+ if (!roots.some((w) => w.id === rootId)) {
1339
+ throw new Error(
1340
+ `installDeclaredSystemAdapters: no mounted root is named "${rootId}" ` +
1341
+ `(mounted: ${roots.map((w) => `"${w.id}"`).join(', ') || 'none'}).`,
1342
+ );
1343
+ }
1344
+ if (declaredSystemAdapters.has(rootId)) {
1345
+ throw new Error(
1346
+ `installDeclaredSystemAdapters: root "${rootId}" already installed its declared ` +
1347
+ 'systems — a declaration is static and installs exactly once per mount.',
1348
+ );
1349
+ }
1350
+ declaredSystemAdapters.set(rootId, slots);
1351
+ gameInternal.notifySystemAdaptersChanged();
1352
+ },
1295
1353
  input,
1296
1354
  state: stateBridge,
1297
1355
  play: {
@@ -17,12 +17,17 @@
17
17
  // even the 'three' convenience export is a plain function the CALLER
18
18
  // registers, not something this module wires up automatically.
19
19
 
20
+ import { declaredRoots } from '../adapter/manifest-interpreter';
20
21
  import {
21
22
  installNativeDebugBindings,
23
+ installNativeSystemsBindings,
22
24
  type NativeDebugBinding,
25
+ type NativeSystemsBinding,
23
26
  nativeDebugBindingFromEntryModule,
27
+ nativeSystemsBindingFromEntryModule,
24
28
  } from '../adapter/native-debug-module';
25
29
  import type { RootAdapter } from '../adapter/root-adapter';
30
+ import { publishDevInstruments } from '../dev/instruments';
26
31
  import type { ResolvedAdapterRoot } from '../manifest/load';
27
32
  import type { GameSession } from './create-runtime';
28
33
  import { type MountEntry, mountManifestRoots, resolveManifest } from './mount-manifest';
@@ -223,6 +228,15 @@ function nativeDebugForEntry(
223
228
  return binding ? [binding] : [];
224
229
  }
225
230
 
231
+ function nativeSystemsForEntry(
232
+ world: ResolvedAdapterRoot,
233
+ entryModule: unknown,
234
+ ): NativeSystemsBinding[] {
235
+ if (entryModule === undefined || world.adapter.type !== 'builtin') return [];
236
+ const binding = nativeSystemsBindingFromEntryModule(world.id, entryModule, world.surface);
237
+ return binding ? [binding] : [];
238
+ }
239
+
226
240
  /**
227
241
  * Mount every world declared by a `vgai.project.json` manifest onto `host
228
242
  * .container`, resolving each world's kind through the `registerAdapter`
@@ -255,8 +269,9 @@ export async function mountGameFromManifest(
255
269
  const manifest = resolveManifest(manifestInput);
256
270
  const entries: Record<string, MountEntry> = { ...opts.entries };
257
271
  const nativeDebug: NativeDebugBinding[] = [];
272
+ const nativeSystems: NativeSystemsBinding[] = [];
258
273
 
259
- for (const world of manifest.roots) {
274
+ for (const world of declaredRoots(manifest)) {
260
275
  if (entries[world.id] !== undefined) continue; // explicit entry always wins
261
276
 
262
277
  const factory = registry.get(world.adapter.identity);
@@ -266,6 +281,7 @@ export async function mountGameFromManifest(
266
281
  ? await host.loadEntryModule(world.entry)
267
282
  : undefined;
268
283
  nativeDebug.push(...nativeDebugForEntry(world, entryModule));
284
+ nativeSystems.push(...nativeSystemsForEntry(world, entryModule));
269
285
  entries[world.id] = await factory(world, { host, entryModule });
270
286
  continue;
271
287
  }
@@ -290,5 +306,11 @@ export async function mountGameFromManifest(
290
306
  headless: host.headless,
291
307
  });
292
308
  installNativeDebugBindings(session.game, nativeDebug);
309
+ installNativeSystemsBindings(session.game, nativeSystems);
310
+ // The universal instruments are host furniture on EVERY mount path — the
311
+ // editor installs them in its own bindings pass; the standalone composer
312
+ // installs them here. The disposer is deliberately dropped: the set's one
313
+ // resource lives on this Game and dies with it.
314
+ publishDevInstruments(session.game);
293
315
  return session;
294
316
  }
@@ -24,6 +24,7 @@
24
24
  // `fromSetup` here adds no NEW bundle weight — only pixi/react are avoided.
25
25
 
26
26
  import { assertNever } from '../adapter/adapter-surface';
27
+ import { declaredRoots } from '../adapter/manifest-interpreter';
27
28
  import type { RootAdapter } from '../adapter/root-adapter';
28
29
  // Value-imported from the implementer's OWN path, not the type-only
29
30
  // `../adapter` barrel (P-6).
@@ -382,12 +383,13 @@ function buildRootMountSpec(
382
383
  */
383
384
  export async function mountManifestRoots(opts: MountManifestOptions): Promise<GameSession> {
384
385
  const manifest = resolveManifest(opts.manifest);
385
- if (manifest.roots.length === 0) {
386
+ const mountPlan = declaredRoots(manifest);
387
+ if (mountPlan.length === 0) {
386
388
  throw new Error('mountManifestRoots: manifest declares no roots — nothing to mount.');
387
389
  }
388
390
  const entries = opts.entries ?? {};
389
391
 
390
- const roots: RootMountSpec[] = manifest.roots.map((world) =>
392
+ const roots: RootMountSpec[] = mountPlan.map((world) =>
391
393
  buildRootMountSpec(world, entries[world.id]),
392
394
  );
393
395
 
@@ -299,7 +299,7 @@ export interface EditorPreview {
299
299
  *
300
300
  * Its one former reader — `browser-play-entry.ts`'s whole-bundle `setup()`,
301
301
  * which dispatched over the generated `SETUP_BY_EXAMPLE_ID` table — is
302
- * DELETED. Hosted dispatch is per-root (`ADAPTER_BY_EXAMPLE_ROOT` /
302
+ * DELETED. Hosted dispatch is per-root (`THREE_BY_EXAMPLE_ROOT` /
303
303
  * `REACT_BY_EXAMPLE_ROOT`, resolved in `adapter-resolver.ts`) and never
304
304
  * consults this field; `exampleDispatchFor` still sets it as the marker for
305
305
  * "play the project's own shipped game" rather than a `?scene=` override
@@ -2,11 +2,8 @@
2
2
  * world3d-react — the react-three-fiber bridge for vgai's three surface
3
3
  * (`@engine/world3d-react`).
4
4
  *
5
- * An OPT-IN module, upstreamed
6
- * from the live-proven `examples/r3f-first-party` project copies (R4;
7
- * decision D24 — R3F TSX is the blessed three.js authoring path). Mirrors how
8
- * `pixi/` is the PixiJS surface's opt-in home: a peer surface module the engine
9
- * CORE never imports (enforced by
5
+ * An OPT-IN module, mirroring how `pixi/` is the PixiJS surface's opt-in home:
6
+ * a peer surface module the engine CORE never imports (enforced by
10
7
  * `packages/engine/test/react-core-import-ban.test.ts` — this directory is an
11
8
  * allowed react-importing entry alongside `react/`, and core files may not
12
9
  * import it).
@@ -14,27 +11,23 @@
14
11
  * Peer contract: an importing PROJECT already depends on `react`,
15
12
  * `@react-three/fiber`, and `three` (fiber's three must be deduped to the
16
13
  * host's single instance — `resolve.dedupe: ['three', 'react', 'react-dom']`
17
- * in the project's Vite config; see `createR3FAdapter`'s header). The engine
18
- * package deliberately declares no hard dependency on fiber: only projects
19
- * that already mount through fiber ever import this module.
14
+ * in the project's Vite config). The engine package deliberately declares no
15
+ * hard dependency on fiber: only projects that already mount through fiber
16
+ * ever import this module.
20
17
  *
21
18
  * Surface:
22
- * - `createR3FAdapter({ id, content })` — mount an R3F tree as a first-party
23
- * `kind: "three"` world under the host's gated loop.
24
- * - `useGameContext()` this root's `R3FGameContext` (`input`, `debug`,
25
- * `random`, `game`, `roots`, …) from inside any component in the tree. This
26
- * is how an R3F world reaches engine services: behavior is components +
27
- * hooks (D26b), not a class under a string key. The subsystems an R3F world
28
- * does not build are absent from that type — see its doc comment for what
29
- * to use instead.
30
- * - `EngineBridge` / `EngineBridgeValue` — the context contract, exported
31
- * for advanced adopters building their own bridge.
19
+ * - `r3fRootFactory` / `resolveR3FEntryAdapter`what a three entry module
20
+ * MEANS: a default-exported component (`export default function World()`),
21
+ * mounted as a first-party `kind: "three"` world under the host's gated
22
+ * loop. The world's vgai surface is its entry module's static exports
23
+ * (`export { debug, systems } from './commands'`), connected by the host
24
+ * at mount.
32
25
  *
33
26
  * Deliberately NOT re-exported here: `<RapierPhysicsBridge>`
34
- * (`@engine/world3d-react/rapier-physics-bridge`), which registers an R3F
35
- * world's `@react-three/rapier` physics as the game's
36
- * `SystemAdapters.physics` — so the colliders instrument and the
37
- * editor's transform coordination can see it. It imports
27
+ * (`@engine/world3d-react/rapier-physics-bridge`), which publishes an R3F
28
+ * world's `@react-three/rapier` physics to the module slot the entry's
29
+ * `systems.physics` declaration forwards — so the colliders instrument and
30
+ * the editor's transform coordination can see it. It imports
38
31
  * `@react-three/rapier`, and only a world that already depends on that library
39
32
  * should pull it into its graph; a deep import keeps this barrel's peer
40
33
  * contract to `react`/`@react-three/fiber`/`three` alone.
@@ -46,13 +39,4 @@ export {
46
39
  type WorldRendererConfig,
47
40
  type WorldToneMapping,
48
41
  } from '../adapter/renderer-config';
49
- export { EngineBridge, type EngineBridgeValue, useGameContext } from './engine-bridge';
50
- export { type CreateR3FAdapterOptions, createR3FAdapter } from './r3f-adapter';
51
42
  export { r3fRootFactory, resolveR3FEntryAdapter } from './r3f-root-factory';
52
- export {
53
- createR3FRootContext,
54
- DEFAULT_INPUT_MAP_PATH,
55
- type R3FGameContext,
56
- type R3FRootContextOptions,
57
- type R3FRootRuntime,
58
- } from './world-context';