@vgai/engine 0.5.13 → 0.5.14

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 (192) hide show
  1. package/dist/adapter/adapter-module.d.ts +447 -0
  2. package/dist/adapter/adapter-module.d.ts.map +1 -0
  3. package/dist/adapter/adapter-module.js +308 -0
  4. package/dist/adapter/authoring.d.ts +357 -10
  5. package/dist/adapter/authoring.d.ts.map +1 -1
  6. package/dist/adapter/authoring.js +87 -1
  7. package/dist/adapter/constraint.d.ts +9 -3
  8. package/dist/adapter/constraint.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +28 -0
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -0
  11. package/dist/adapter/finders/finder-result.js +10 -0
  12. package/dist/adapter/finders/index.d.ts +38 -0
  13. package/dist/adapter/finders/index.d.ts.map +1 -0
  14. package/dist/adapter/finders/index.js +41 -0
  15. package/dist/adapter/finders/prefabs-from-stories.d.ts +64 -0
  16. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -0
  17. package/dist/adapter/finders/prefabs-from-stories.js +92 -0
  18. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +67 -0
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -0
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +226 -0
  21. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  22. package/dist/adapter/first-party-systems.js +29 -0
  23. package/dist/adapter/index.d.ts +7 -3
  24. package/dist/adapter/index.d.ts.map +1 -1
  25. package/dist/adapter/index.js +5 -2
  26. package/dist/adapter/ingest/contract-system-adapters.d.ts +24 -1
  27. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  28. package/dist/adapter/ingest/contract-system-adapters.js +100 -34
  29. package/dist/adapter/ingest/game-contract.d.ts +188 -2
  30. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  31. package/dist/adapter/ingest/game-contract.js +107 -0
  32. package/dist/adapter/ingest/mount-readiness.d.ts +84 -0
  33. package/dist/adapter/ingest/mount-readiness.d.ts.map +1 -0
  34. package/dist/adapter/ingest/mount-readiness.js +81 -0
  35. package/dist/adapter/ingest/scene-capture.d.ts +91 -2
  36. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  37. package/dist/adapter/ingest/scene-capture.js +110 -18
  38. package/dist/adapter/ingest/structural-ids.d.ts +11 -5
  39. package/dist/adapter/ingest/structural-ids.d.ts.map +1 -1
  40. package/dist/adapter/ingest/structural-ids.js +18 -9
  41. package/dist/adapter/ingest/upstream-pin.d.ts +4 -2
  42. package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -1
  43. package/dist/adapter/ingest/upstream-pin.js +4 -2
  44. package/dist/adapter/ingest/visible-capture-window.d.ts +116 -0
  45. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -0
  46. package/dist/adapter/ingest/visible-capture-window.js +141 -0
  47. package/dist/adapter/loop-gate-report.d.ts +3 -4
  48. package/dist/adapter/loop-gate-report.d.ts.map +1 -1
  49. package/dist/adapter/loop-gate-report.js +3 -4
  50. package/dist/adapter/root-adapter.d.ts +22 -2
  51. package/dist/adapter/root-adapter.d.ts.map +1 -1
  52. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  53. package/dist/adapter/setup-three-root-adapter.js +15 -0
  54. package/dist/adapter/system-adapter.d.ts +161 -2
  55. package/dist/adapter/system-adapter.d.ts.map +1 -1
  56. package/dist/adapter/system-adapter.js +18 -1
  57. package/dist/animation/camera-ownership.d.ts +4 -2
  58. package/dist/animation/camera-ownership.d.ts.map +1 -1
  59. package/dist/animation/camera-ownership.js +58 -1
  60. package/dist/animation/runtime-inspection.d.ts +70 -0
  61. package/dist/animation/runtime-inspection.d.ts.map +1 -0
  62. package/dist/animation/runtime-inspection.js +25 -0
  63. package/dist/animation/xstate-animation-binding.d.ts +9 -1
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +48 -1
  66. package/dist/asset-formats/material.d.ts +1 -1
  67. package/dist/asset-formats/mesh.d.ts +1 -1
  68. package/dist/asset-formats/particles.d.ts +13 -0
  69. package/dist/asset-formats/particles.d.ts.map +1 -1
  70. package/dist/asset-formats/particles.js +28 -0
  71. package/dist/behavior/xstate-inspection.d.ts +24 -0
  72. package/dist/behavior/xstate-inspection.d.ts.map +1 -0
  73. package/dist/behavior/xstate-inspection.js +25 -0
  74. package/dist/core/game-loop.d.ts +7 -5
  75. package/dist/core/game-loop.d.ts.map +1 -1
  76. package/dist/core/game-loop.js +39 -18
  77. package/dist/core/types.d.ts +11 -12
  78. package/dist/core/types.d.ts.map +1 -1
  79. package/dist/data/data-asset.js +1 -1
  80. package/dist/dev/instruments.d.ts.map +1 -1
  81. package/dist/dev/instruments.js +5 -1
  82. package/dist/dev/render-debug-adapter.d.ts +19 -2
  83. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  84. package/dist/dev/render-debug-adapter.js +10 -0
  85. package/dist/ecs/user-data.d.ts +10 -2
  86. package/dist/ecs/user-data.d.ts.map +1 -1
  87. package/dist/ecs/user-data.js +6 -2
  88. package/dist/input/binding-identity.d.ts +10 -0
  89. package/dist/input/binding-identity.d.ts.map +1 -0
  90. package/dist/input/binding-identity.js +36 -0
  91. package/dist/input/input-manager.d.ts +18 -185
  92. package/dist/input/input-manager.d.ts.map +1 -1
  93. package/dist/input/input-manager.js +78 -68
  94. package/dist/manifest/load.d.ts +14 -0
  95. package/dist/manifest/load.d.ts.map +1 -1
  96. package/dist/manifest/load.js +6 -1
  97. package/dist/manifest/schema.d.ts +18 -0
  98. package/dist/manifest/schema.d.ts.map +1 -1
  99. package/dist/manifest/schema.js +64 -7
  100. package/dist/pixi/authoring.d.ts +27 -0
  101. package/dist/pixi/authoring.d.ts.map +1 -1
  102. package/dist/pixi/authoring.js +10 -3
  103. package/dist/pixi/index.d.ts +1 -1
  104. package/dist/pixi/index.d.ts.map +1 -1
  105. package/dist/pixi/index.js +1 -1
  106. package/dist/pixi/ingest.d.ts +8 -0
  107. package/dist/pixi/ingest.d.ts.map +1 -1
  108. package/dist/pixi/ingest.js +10 -1
  109. package/dist/pixi/render-pass-bracket.d.ts +56 -0
  110. package/dist/pixi/render-pass-bracket.d.ts.map +1 -0
  111. package/dist/pixi/render-pass-bracket.js +98 -0
  112. package/dist/pixi/scene-capture.d.ts +32 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +21 -9
  115. package/dist/pixi/system-adapters.d.ts +29 -0
  116. package/dist/pixi/system-adapters.d.ts.map +1 -1
  117. package/dist/pixi/system-adapters.js +27 -0
  118. package/dist/render/particles-factory.d.ts +16 -2
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +88 -5
  121. package/dist/render/soft-particle-depth.d.ts +149 -0
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -0
  123. package/dist/render/soft-particle-depth.js +305 -0
  124. package/dist/render/voxel-surface.d.ts +117 -0
  125. package/dist/render/voxel-surface.d.ts.map +1 -0
  126. package/dist/render/voxel-surface.js +263 -0
  127. package/dist/runtime/create-runtime.js +7 -0
  128. package/dist/runtime/debug-bridge.d.ts +15 -15
  129. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  130. package/dist/runtime/debug-bridge.js +27 -26
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +1 -2
  133. package/dist/setup/setup-particles.d.ts.map +1 -1
  134. package/dist/setup/setup-particles.js +9 -0
  135. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  136. package/dist/world3d-react/r3f-adapter.js +42 -3
  137. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  138. package/dist/world3d-react/rapier-physics-bridge.js +201 -11
  139. package/dist-config/config.js.map +1 -1
  140. package/package.json +4 -2
  141. package/schemas/engine-capabilities.json +12 -12
  142. package/schemas/vgai-project.schema.json +41 -2
  143. package/src/adapter/adapter-module.ts +607 -0
  144. package/src/adapter/authoring.ts +440 -10
  145. package/src/adapter/constraint.ts +10 -3
  146. package/src/adapter/finders/finder-result.ts +29 -0
  147. package/src/adapter/finders/index.ts +62 -0
  148. package/src/adapter/finders/prefabs-from-stories.ts +142 -0
  149. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +304 -0
  150. package/src/adapter/first-party-systems.ts +30 -0
  151. package/src/adapter/index.ts +32 -1
  152. package/src/adapter/ingest/contract-system-adapters.ts +116 -32
  153. package/src/adapter/ingest/game-contract.ts +265 -2
  154. package/src/adapter/ingest/mount-readiness.ts +140 -0
  155. package/src/adapter/ingest/scene-capture.ts +199 -22
  156. package/src/adapter/ingest/structural-ids.ts +21 -9
  157. package/src/adapter/ingest/upstream-pin.ts +4 -2
  158. package/src/adapter/ingest/visible-capture-window.ts +213 -0
  159. package/src/adapter/loop-gate-report.ts +3 -4
  160. package/src/adapter/root-adapter.ts +22 -2
  161. package/src/adapter/setup-three-root-adapter.ts +16 -0
  162. package/src/adapter/system-adapter.ts +188 -2
  163. package/src/animation/camera-ownership.ts +68 -3
  164. package/src/animation/runtime-inspection.ts +104 -0
  165. package/src/animation/xstate-animation-binding.ts +53 -1
  166. package/src/asset-formats/particles.ts +31 -0
  167. package/src/behavior/xstate-inspection.ts +51 -0
  168. package/src/core/game-loop.ts +39 -17
  169. package/src/core/types.ts +11 -12
  170. package/src/data/data-asset.ts +1 -1
  171. package/src/dev/instruments.ts +5 -1
  172. package/src/dev/render-debug-adapter.ts +22 -2
  173. package/src/ecs/user-data.ts +10 -2
  174. package/src/input/binding-identity.ts +37 -0
  175. package/src/input/input-manager.ts +93 -68
  176. package/src/manifest/load.ts +23 -2
  177. package/src/manifest/schema.ts +76 -7
  178. package/src/pixi/authoring.ts +37 -5
  179. package/src/pixi/index.ts +2 -0
  180. package/src/pixi/ingest.ts +15 -2
  181. package/src/pixi/render-pass-bracket.ts +117 -0
  182. package/src/pixi/scene-capture.ts +63 -13
  183. package/src/pixi/system-adapters.ts +41 -0
  184. package/src/render/particles-factory.ts +89 -6
  185. package/src/render/soft-particle-depth.ts +336 -0
  186. package/src/render/voxel-surface.ts +358 -0
  187. package/src/runtime/create-runtime.ts +7 -0
  188. package/src/runtime/debug-bridge.ts +32 -31
  189. package/src/runtime/game.ts +1 -2
  190. package/src/setup/setup-particles.ts +9 -0
  191. package/src/world3d-react/r3f-adapter.tsx +48 -2
  192. package/src/world3d-react/rapier-physics-bridge.tsx +249 -11
@@ -0,0 +1,51 @@
1
+ import type * as THREE from 'three';
2
+ import type { AnyStateMachine } from 'xstate';
3
+ import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
4
+
5
+ /** Minimal structural actor surface needed by the live behavior debugger. */
6
+ export interface InspectableXStateActor {
7
+ readonly getSnapshot: () => {
8
+ readonly value: unknown;
9
+ readonly context: unknown;
10
+ readonly machine?: AnyStateMachine;
11
+ };
12
+ readonly subscribe: (next: (snapshot: { readonly value: unknown }) => void) => {
13
+ unsubscribe: () => void;
14
+ };
15
+ readonly logic?: AnyStateMachine;
16
+ }
17
+
18
+ export interface XStateBehaviorInspection {
19
+ readonly actor: InspectableXStateActor;
20
+ readonly machine: AnyStateMachine;
21
+ }
22
+
23
+ let version = 0;
24
+ const listeners = new Set<() => void>();
25
+
26
+ function notify(): void {
27
+ version++;
28
+ for (const listener of listeners) listener();
29
+ }
30
+
31
+ export function subscribeXStateBehaviorInspections(listener: () => void): () => void {
32
+ listeners.add(listener);
33
+ return () => listeners.delete(listener);
34
+ }
35
+
36
+ export function xstateBehaviorInspectionsVersion(): number {
37
+ return version;
38
+ }
39
+
40
+ export function attachXStateBehaviorInspection(
41
+ owner: THREE.Object3D,
42
+ inspection: XStateBehaviorInspection,
43
+ ): () => void {
44
+ setUserData(owner, '_xstateBehavior', inspection);
45
+ notify();
46
+ return () => {
47
+ if (getUserData(owner, '_xstateBehavior') !== inspection) return;
48
+ deleteUserData(owner, '_xstateBehavior');
49
+ notify();
50
+ };
51
+ }
@@ -1,6 +1,12 @@
1
1
  import { clampTimeScale, paceFrame, TIME_SCALE_RANGE } from './frame-pacing';
2
2
  import type { GameLoopConfig, GameLoopLiveness } from './types';
3
3
 
4
+ /** A visible rAF chain that has produced no callback for this long is not
5
+ * honestly "running" merely because its callback remains armed. One second
6
+ * is far beyond an ordinary dropped frame and short enough for status to
7
+ * report browser starvation while it is still actionable. */
8
+ const LOOP_STARVATION_MS = 1_000;
9
+
4
10
  /**
5
11
  * Fixed-timestep game loop with accumulator pattern — SIM at a fixed rate,
6
12
  * PRESENTATION at the display's rate (WO-P13, `docs/WORK.md` §2 P13, which
@@ -56,19 +62,24 @@ export function createGameLoop(config: GameLoopConfig) {
56
62
  // Idle throttle (T2.1): a hidden tab stops the loop outright; becoming
57
63
  // visible again restarts it with the accumulator clock resynced to "now" —
58
64
  // deliberately no catch-up burst for the wall-clock time spent hidden.
59
- let hiddenPaused = false;
65
+ let visibilityPaused = false;
60
66
  let visibilityListenerAttached = false;
61
67
 
62
- function handleVisibilityChange() {
68
+ /** Reconcile the gate against the CURRENT document bit, not merely the
69
+ * last event. Browsers can miss/coalesce visibilitychange around window
70
+ * occlusion and reloads; reading liveness is itself a recovery opportunity,
71
+ * so a stale event can never leave this flag permanently wedged. */
72
+ function reconcileVisibilityGate() {
73
+ if (externalDrive || !visibilityListenerAttached) return;
63
74
  if (typeof document === 'undefined') return;
64
75
  if (document.hidden) {
65
76
  if (running) {
66
77
  running = false;
67
78
  cancelAnimationFrame(rafId);
68
- hiddenPaused = true;
79
+ visibilityPaused = true;
69
80
  }
70
- } else if (hiddenPaused) {
71
- hiddenPaused = false;
81
+ } else if (visibilityPaused) {
82
+ visibilityPaused = false;
72
83
  running = true;
73
84
  lastTime = performance.now();
74
85
  accumulator = 0;
@@ -76,6 +87,10 @@ export function createGameLoop(config: GameLoopConfig) {
76
87
  }
77
88
  }
78
89
 
90
+ function handleVisibilityChange() {
91
+ reconcileVisibilityGate();
92
+ }
93
+
79
94
  function frame(currentTime: number) {
80
95
  if (!running) return;
81
96
  rafId = requestAnimationFrame(frame);
@@ -107,13 +122,13 @@ export function createGameLoop(config: GameLoopConfig) {
107
122
  start() {
108
123
  if (running) return;
109
124
  running = true;
110
- // A direct start() call can land while the loop is auto-hidden-paused
111
- // (`hiddenPaused` true, `running` false, no rAF pending). Without this
125
+ // A direct start() call can land while the loop is auto-loop-starved
126
+ // (`visibilityPaused` true, `running` false, no rAF pending). Without this
112
127
  // reset, a later visibilitychange-to-visible would see stale
113
- // `hiddenPaused === true` and spawn a SECOND rAF chain on top of the
128
+ // `visibilityPaused === true` and spawn a SECOND rAF chain on top of the
114
129
  // one this call is about to start (doubled update()/render() overhead
115
130
  // until the next stop() — no sim-speed effect, but wasted work).
116
- hiddenPaused = false;
131
+ visibilityPaused = false;
117
132
  lastTime = performance.now();
118
133
  accumulator = 0;
119
134
 
@@ -137,7 +152,7 @@ export function createGameLoop(config: GameLoopConfig) {
137
152
  }
138
153
  if (typeof document !== 'undefined' && document.hidden) {
139
154
  running = false;
140
- hiddenPaused = true;
155
+ visibilityPaused = true;
141
156
  return;
142
157
  }
143
158
 
@@ -146,7 +161,7 @@ export function createGameLoop(config: GameLoopConfig) {
146
161
 
147
162
  stop() {
148
163
  running = false;
149
- hiddenPaused = false;
164
+ visibilityPaused = false;
150
165
  cancelAnimationFrame(rafId);
151
166
 
152
167
  if (visibilityListenerAttached && typeof document !== 'undefined') {
@@ -156,22 +171,29 @@ export function createGameLoop(config: GameLoopConfig) {
156
171
  },
157
172
 
158
173
  get isRunning() {
174
+ reconcileVisibilityGate();
159
175
  return running;
160
176
  },
161
177
 
162
178
  /**
163
179
  * Truthful liveness (issue #175 — see `GameLoopLiveness`'s doc comment
164
180
  * in `core/types.ts` for the full contract). Unlike `isRunning` above,
165
- * this DISTINGUISHES a hidden-tab pause from an actually-stopped loop:
166
- * `hiddenPaused` is only ever set by `handleVisibilityChange` (T2.1's
167
- * idle throttle) and cleared by `start()`/`stop()`/visibility-restore,
168
- * so it can never be true at the same time `running` is true. An
181
+ * this distinguishes a starved rAF loop from an actually-stopped loop.
182
+ * `visibilityPaused` records the deliberate `document.hidden` gate, and
183
+ * every read first reconciles that flag against the CURRENT document bit
184
+ * so a missed visibilitychange cannot wedge it. A visible, armed chain
185
+ * also reports starvation when no real frame callback has arrived within
186
+ * {@link LOOP_STARVATION_MS}; "armed" alone is not evidence of progress. An
169
187
  * `externalDrive` loop never installs the visibility listener at all
170
- * (see `start()` above), so `hiddenPaused` stays permanently `false` for
188
+ * (see `start()` above), so `visibilityPaused` stays permanently `false` for
171
189
  * it — it only ever reports `'running'` or `'stopped'`.
172
190
  */
173
191
  get liveness(): GameLoopLiveness {
174
- if (hiddenPaused) return 'hidden-paused';
192
+ reconcileVisibilityGate();
193
+ if (visibilityPaused) return 'loop-starved';
194
+ if (running && !externalDrive && performance.now() - lastTime > LOOP_STARVATION_MS) {
195
+ return 'loop-starved';
196
+ }
175
197
  return running ? 'running' : 'stopped';
176
198
  },
177
199
 
package/src/core/types.ts CHANGED
@@ -110,18 +110,17 @@ export interface GameLoopConfig {
110
110
  * debug-registry.ts`) can name the union without importing `game-loop.ts`
111
111
  * as a value.
112
112
  *
113
- * - `'running'` — the rAF chain is armed and `config.update` is being
114
- * driven by real frames (or, for an `externalDrive` loop, `start()` has
115
- * been called — see that mode's own liveness note below).
116
- * - `'hidden-paused'` — the T2.1 idle throttle stopped the loop because
117
- * `document.hidden` is true; this is deliberate and reversible (resumes
118
- * automatically on visibility), but ZERO ticks are advancing right now.
113
+ * - `'running'` — a real rAF callback arrived recently (or, for an
114
+ * `externalDrive` loop, `start()` has been called — see that mode's own
115
+ * liveness note below).
116
+ * - `'loop-starved'` — the host loop has made no recent rAF progress: either
117
+ * the T2.1 visibility gate deliberately parked it, or an armed visible-page
118
+ * callback has not arrived within the starvation threshold. This reports
119
+ * the LOOP measurement, never a conclusion about where the browser tab is.
119
120
  * - `'stopped'` — `stop()` was called, or `start()` was never called.
120
121
  *
121
- * `isRunning` collapses `'hidden-paused'` and `'stopped'` into the same
122
- * `false` exactly the ambiguity that let a hidden-paused game report as
123
- * healthy ("playing") through every consumer that only read `isRunning`/
124
- * store-level play state. Read `liveness` wherever "is this game actually
125
- * advancing" matters.
122
+ * `isRunning` says whether the rAF chain is armed; it can therefore remain
123
+ * `true` while a visible browser starves callbacks. Read `liveness` wherever
124
+ * "is this host loop actually advancing" matters.
126
125
  */
127
- export type GameLoopLiveness = 'running' | 'hidden-paused' | 'stopped';
126
+ export type GameLoopLiveness = 'running' | 'loop-starved' | 'stopped';
@@ -94,7 +94,7 @@ export function defineData<S extends z.ZodType>(
94
94
  parsed = parseDataJson(schema, next, sourcePath);
95
95
  } catch (err) {
96
96
  // Keep the last good value alive — see the JSDoc on DataHandle.hotSwap.
97
- // biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game (mirrors achieved-tier.ts's direct console use)
97
+ // biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game
98
98
  console.error(`[data] hot edit rejected, keeping previous values.\n${String(err)}`);
99
99
  return;
100
100
  }
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import { z } from 'zod';
53
+ import { nodeKeyedPhysics } from '../adapter/system-adapter';
53
54
  import { clampTimeScale, TIME_SCALE_RANGE } from '../core/frame-pacing';
54
55
  import type { Game } from '../runtime/game';
55
56
 
@@ -175,7 +176,10 @@ export function createDevInstruments(game: Game): DevInstrumentSet {
175
176
  // below, which is also the only thing that calls the adapter.
176
177
  let collidersDrawn = false;
177
178
  const colliderDraw = () => {
178
- const adapter = game.systemAdapters.physics;
179
+ // Node-id keyed only — `setDebugDrawEnabled` draws THREE wireframes into
180
+ // the world, which a canvas mount's display-keyed carrier has no analogue
181
+ // for and does not declare.
182
+ const adapter = nodeKeyedPhysics(game.systemAdapters.physics);
179
183
  return adapter?.setDebugDrawEnabled ? adapter : null;
180
184
  };
181
185
 
@@ -18,6 +18,12 @@
18
18
  * `annotation: null` (counted as unattributed) — never a fabricated guess. The
19
19
  * wrappers are all restored when the capture resolves, rejects, times out, or
20
20
  * the adapter is disposed.
21
+ *
22
+ * That attribution is a THREE capability, not a property of frame capture: a
23
+ * surface whose renderer has no per-draw annotation seam omits `scene` entirely
24
+ * (the Pixi ingest lane does — `editor/src/ingest/ingest-render-debug.ts`), and
25
+ * every draw is then recorded unattributed. The capture itself is GL-level and
26
+ * unchanged either way; only the names beside the draws go away.
21
27
  */
22
28
 
23
29
  import type * as THREE from 'three';
@@ -44,8 +50,19 @@ export function frameCaptureContextFor(
44
50
  export interface RenderDebugAdapterDeps {
45
51
  /** The armed-on-demand frame capture over the mount's real context. */
46
52
  capture: WebGLFrameCapture;
47
- /** The live scene root traversed to attribute draws. */
48
- scene: THREE.Object3D;
53
+ /**
54
+ * The live three scene root traversed to attribute draws to the objects that
55
+ * issue them.
56
+ *
57
+ * OMITTED on a surface whose renderer publishes no per-draw annotation seam —
58
+ * the Pixi lane, where display objects have no `onBeforeRender` and the
59
+ * batcher merges many display objects into one draw, so there is no object to
60
+ * name for a given draw at all. Then every draw is recorded with
61
+ * `annotation: null` and lands in `totals.unattributed`: the honest "this
62
+ * seam cannot say who drew this", never a guess derived from the scene's
63
+ * shape.
64
+ */
65
+ scene?: THREE.Object3D;
49
66
  /** Memory snapshot supplier (renderer.info-backed). When omitted, the
50
67
  * adapter's `memorySnapshot` is absent (honest capability degradation). */
51
68
  memory?: () => RenderMemorySnapshot;
@@ -127,6 +144,9 @@ export function createRenderDebugAdapter(deps: RenderDebugAdapterDeps): RenderDe
127
144
 
128
145
  function wrapOnBeforeRender(): (() => void)[] {
129
146
  const restores: (() => void)[] = [];
147
+ // No scene ⇒ no annotation seam on this surface (see `scene` above): wrap
148
+ // nothing, restore nothing, and let every draw record `annotation: null`.
149
+ if (!scene) return restores;
130
150
  scene.traverse((object) => {
131
151
  if (!isRenderable(object)) return;
132
152
  const original = object.onBeforeRender;
@@ -46,8 +46,10 @@
46
46
  * mixer; see xstate-animation-binding.ts).
47
47
  * - `_availableClips` — string[] of clip names discovered on the GLTF
48
48
  * (inspector dropdown; same names as `_animClips`' keys).
49
- * - `_xstateAnimation` — live native XState/Three binding exposed for
50
- * play-mode inspection; removed when binding disposes.
49
+ * - `_animationRuntime` format-neutral live native mixer/action inspection.
50
+ * - `_xstateBehavior` — optional live XState behavior actor inspection.
51
+ * - `_xstateAnimation` — compatibility handle for the optional XState→Three
52
+ * bridge; animation UI never discovers through it.
51
53
  *
52
54
  * Disposal contract:
53
55
  * - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
@@ -118,7 +120,9 @@ import type { ParticleSystem } from 'three.quarks';
118
120
  import type { ConstraintMark } from '../adapter/constraint';
119
121
  import type { RapierEditableBody } from '../adapter/rapier-physics-adapter';
120
122
  import type { ReflectionProbeMark } from '../adapter/reflection-probe';
123
+ import type { AnimationRuntimeInspection } from '../animation/runtime-inspection';
121
124
  import type { XStateAnimationBinding } from '../animation/xstate-animation-binding';
125
+ import type { XStateBehaviorInspection } from '../behavior/xstate-inspection';
122
126
 
123
127
  /** The kinds of editor helper objects tagged via `editorHelperType`. */
124
128
  export type EditorHelperType =
@@ -165,6 +169,8 @@ export interface UserDataSchema {
165
169
  _animMixer: THREE.AnimationMixer;
166
170
  _animClips: Map<string, THREE.AnimationClip>;
167
171
  _availableClips: string[];
172
+ _animationRuntime: AnimationRuntimeInspection;
173
+ _xstateBehavior: XStateBehaviorInspection;
168
174
  _xstateAnimation: XStateAnimationBinding;
169
175
  __sharedGeometry: boolean;
170
176
  __shadeOrig: THREE.Material | THREE.Material[];
@@ -222,6 +228,8 @@ export const UserDataKeys = {
222
228
  _animMixer: '_animMixer',
223
229
  _animClips: '_animClips',
224
230
  _availableClips: '_availableClips',
231
+ _animationRuntime: '_animationRuntime',
232
+ _xstateBehavior: '_xstateBehavior',
225
233
  _xstateAnimation: '_xstateAnimation',
226
234
  __sharedGeometry: '__sharedGeometry',
227
235
  __shadeOrig: '__shadeOrig',
@@ -0,0 +1,37 @@
1
+ import type { InputBinding } from './input-types';
2
+
3
+ /**
4
+ * Whether two bindings name the same physical input.
5
+ *
6
+ * This is the native identity rule used by both runtime rebinding and the
7
+ * editor's `.inputmap.json` document. Deadzones do not change identity, while
8
+ * opposite directions on one gamepad axis remain independent controls.
9
+ */
10
+ export function inputBindingsCollide(a: InputBinding, b: InputBinding): boolean {
11
+ if (a.type !== b.type) return false;
12
+ switch (a.type) {
13
+ case 'key':
14
+ return a.code === (b as typeof a).code;
15
+ case 'mouse_button':
16
+ return a.button === (b as typeof a).button;
17
+ case 'mouse_move':
18
+ return true;
19
+ case 'gamepad_button':
20
+ return a.button === (b as typeof a).button;
21
+ case 'gamepad_axis': {
22
+ const other = b as typeof a;
23
+ return a.axis === other.axis && a.direction === other.direction;
24
+ }
25
+ case 'gamepad_axis_pair': {
26
+ const other = b as typeof a;
27
+ return a.xAxis === other.xAxis && a.yAxis === other.yAxis;
28
+ }
29
+ case 'touch_button':
30
+ case 'touch_stick':
31
+ case 'test_axis':
32
+ case 'test_vector2':
33
+ case 'test_pointer_delta':
34
+ case 'test_pointer_position':
35
+ return a.sourceId === (b as typeof a).sourceId;
36
+ }
37
+ }
@@ -1,5 +1,6 @@
1
1
  import { AssetParseError } from '../asset-parse-error';
2
2
  import { resolveUrl } from '../loader';
3
+ import { inputBindingsCollide } from './binding-identity';
3
4
  import type {
4
5
  ActionValueOf,
5
6
  ActionValueSource,
@@ -235,7 +236,7 @@ const INPUT_TRACE_CAP = 2000;
235
236
  * map this class serializes is a valid document for B2 to read back.
236
237
  * - Conflicts: `findConflicts(proposed, excludeActionName?)` scans every
237
238
  * OTHER registered action's bindings for one that's structurally
238
- * equivalent to `proposed` (see the private `bindingsCollide` — same
239
+ * equivalent to `proposed` (see `inputBindingsCollide` — same
239
240
  * physical input identity: same key code, same mouse button, the same
240
241
  * gamepad button/axis/axis-pair, the same touch `sourceId`, etc.) and
241
242
  * returns the (possibly empty) list as plain `BindingConflict` data —
@@ -261,15 +262,28 @@ const INPUT_TRACE_CAP = 2000;
261
262
  *
262
263
  * - `getLastActiveDevice()` returns the `PromptDevice` family of whichever
263
264
  * input arrived most recently — keyboard on `keydown`, mouse on
264
- * `mousedown`/a real `mousemove`, gamepad on a button press or a stick
265
- * pushed meaningfully off-center (checked in `poll()`), touch on
266
- * `setTouchButton(_, true)` or `setTouchStick` pushed meaningfully
267
- * off-center. `null` before any input has arrived.
265
+ * `mousedown`/a real `mousemove`, gamepad on a button press or an axis
266
+ * past ITS OWN DECLARED DEADZONE (checked in `poll()`), touch on
267
+ * `setTouchButton(_, true)` or a `setTouchStick` past the deadzone that
268
+ * stick's binding declares. `null` before any input has arrived. The
269
+ * off-rest threshold is always the game's declaration, never a constant
270
+ * this class picked — see `declaredDeadzone`.
268
271
  * - `getPrompt(actionName)` (device omitted) resolves against it directly,
269
272
  * falling back to `'keyboard'` before any input has arrived — a HUD can
270
273
  * call it every frame with no extra bookkeeping and the label switches on
271
274
  * its own.
272
275
  */
276
+ /**
277
+ * The documented deadzone defaults, one home each. Both are stated in
278
+ * `input/schema.ts`'s own `.describe()` text — spelled here so every reader
279
+ * (the value getters AND the active-device tracker) uses the SAME number, which
280
+ * is the drift the tracker's old hardcoded 0.3 was.
281
+ */
282
+ const GAMEPAD_AXIS_DEFAULT_DEADZONE = 0.15;
283
+ /** `touch_stick`'s documented default: none. A virtual stick reports a value
284
+ * only because a finger is on it, so it has no at-rest jitter floor to clear. */
285
+ const TOUCH_STICK_DEFAULT_DEADZONE = 0;
286
+
273
287
  export class InputManager {
274
288
  private actions = new Map<string, InputBinding[]>();
275
289
  // F1 — each registered/loaded action's declared value shape (defaults to
@@ -344,20 +358,44 @@ export class InputManager {
344
358
  // the player switches controllers, without the caller having to guess or
345
359
  // track this itself. Updated at the same points raw input arrives: keydown
346
360
  // (`'keyboard'`), mousedown/mousemove (`'mouse'`), a gamepad button press or
347
- // a stick pushed meaningfully past rest (`'gamepad'`, both detected in
361
+ // an axis past its declared deadzone (`'gamepad'`, both detected in
348
362
  // `poll()` — a raw resting stick reports small non-zero noise on some
349
- // pads, so this deliberately checks a coarse magnitude, not "any non-zero
350
- // axis"), and a touch button press / stick pushed past rest
363
+ // pads, so this checks the threshold the GAME declared for that axis, not
364
+ // "any non-zero axis"), and a touch button press / stick pushed past rest
351
365
  // (`'touch'` — `setTouchButton`/`setTouchStick`). `null` until the very
352
366
  // first input of any kind arrives. See `getLastActiveDevice`/`getPrompt`.
353
367
  private lastActiveDevice: PromptDevice | null = null;
354
- /** Coarse "is this stick meaningfully off-center" check shared by the
355
- * gamepad and touch-stick active-device heuristics above deliberately a
356
- * higher bar than a binding's own (often much smaller) configured
357
- * deadzone, since this is "did the player just grab this control", not
358
- * "does this binding's value count as non-zero". */
359
- private static isStickActive(v: Vector2): boolean {
360
- return Math.hypot(v.x, v.y) > 0.3;
368
+ /**
369
+ * "Is this control off its rest position" for the active-device tracker,
370
+ * answered from what the GAME DECLARED rather than from a threshold this
371
+ * class invented.
372
+ *
373
+ * It used to be `Math.hypot(v.x, v.y) > 0.3` — a constant nobody could see or
374
+ * change, sitting on top of a deadzone the game had already stated. A game
375
+ * that declares a 0.5 deadzone on its move stick considers everything under
376
+ * 0.5 to be rest; reporting "the player switched to gamepad" at 0.31 makes a
377
+ * HUD's prompt flip to a control the game itself is ignoring. Zero inference
378
+ * (ARCHITECTURE-CORE §The editor protocol) says the fix is to read the
379
+ * declaration, and it is already here in `this.actions`.
380
+ *
381
+ * SMALLEST wins when several bindings name one source: the first magnitude at
382
+ * which ANY of the game's own bindings treats the control as live is the
383
+ * moment the player has grabbed it.
384
+ */
385
+ private declaredDeadzone(
386
+ matches: (binding: InputBinding) => number | undefined,
387
+ documentedDefault: number,
388
+ ): number {
389
+ let smallest: number | undefined;
390
+ for (const bindings of this.actions.values()) {
391
+ for (const binding of bindings) {
392
+ const declared = matches(binding);
393
+ if (declared !== undefined && (smallest === undefined || declared < smallest)) {
394
+ smallest = declared;
395
+ }
396
+ }
397
+ }
398
+ return smallest ?? documentedDefault;
361
399
  }
362
400
  // Task 1.4 — action-level virtual input
363
401
  // for a synthetic player, promoted from hollowstone's `VirtualInput`. Held
@@ -859,15 +897,30 @@ export class InputManager {
859
897
  // above. isPressed/isJustPressed/isJustReleased apply a binding's own
860
898
  // threshold to these raw values (current vs. `gamepadAxesPrev`) on read.
861
899
  const currentAxes = new Map<string, number>();
900
+ // F4 — the deadzone THIS GAME declares on each axis, resolved once per poll
901
+ // (see `declaredDeadzone`): an axis reading past the game's own rest
902
+ // threshold is unambiguous real gamepad input; anything under it is the
903
+ // resting noise every pad emits, and the game is already ignoring it.
904
+ const axisDeadzone = new Map<number, number>();
862
905
  for (let i = 0; i < this.gamepads.length; i++) {
863
906
  const gp = this.gamepads[i];
864
907
  if (!gp) continue;
865
908
  for (let a = 0; a < gp.axes.length; a++) {
866
909
  const value = gp.axes[a] ?? 0;
867
910
  currentAxes.set(`${i}:${a}`, value);
868
- // F4 a stick pushed meaningfully off-center is also unambiguous
869
- // real gamepad input (see `isStickActive`'s doc comment).
870
- if (Math.abs(value) > 0.3) this.lastActiveDevice = 'gamepad';
911
+ let deadzone = axisDeadzone.get(a);
912
+ if (deadzone === undefined) {
913
+ deadzone = this.declaredDeadzone(
914
+ (binding) =>
915
+ (binding.type === 'gamepad_axis' && binding.axis === a) ||
916
+ (binding.type === 'gamepad_axis_pair' && (binding.xAxis === a || binding.yAxis === a))
917
+ ? (binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE)
918
+ : undefined,
919
+ GAMEPAD_AXIS_DEFAULT_DEADZONE,
920
+ );
921
+ axisDeadzone.set(a, deadzone);
922
+ }
923
+ if (Math.abs(value) > deadzone) this.lastActiveDevice = 'gamepad';
871
924
  }
872
925
  }
873
926
  this.gamepadAxesCurrent = currentAxes;
@@ -1011,7 +1064,7 @@ export class InputManager {
1011
1064
  }
1012
1065
  break;
1013
1066
  case 'gamepad_axis': {
1014
- const dz = binding.deadzone ?? 0.15;
1067
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1015
1068
  for (const gp of this.gamepads) {
1016
1069
  if (!gp) continue;
1017
1070
  const val = gp.axes[binding.axis] ?? 0;
@@ -1056,7 +1109,7 @@ export class InputManager {
1056
1109
  // Edge-tracked via raw axis snapshots (poll()): true only on the
1057
1110
  // first frame the thresholded (direction+deadzone) boolean flips
1058
1111
  // from off→on (across any connected gamepad).
1059
- const dz = binding.deadzone ?? 0.15;
1112
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1060
1113
  for (let i = 0; i < this.gamepads.length; i++) {
1061
1114
  const key = `${i}:${binding.axis}`;
1062
1115
  const now = this.gamepadAxesCurrent.get(key) ?? 0;
@@ -1102,7 +1155,7 @@ export class InputManager {
1102
1155
  case 'gamepad_axis': {
1103
1156
  // The mirror of isJustPressed: true only on the first frame the
1104
1157
  // thresholded boolean flips from on→off.
1105
- const dz = binding.deadzone ?? 0.15;
1158
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1106
1159
  for (let i = 0; i < this.gamepads.length; i++) {
1107
1160
  const key = `${i}:${binding.axis}`;
1108
1161
  const now = this.gamepadAxesCurrent.get(key) ?? 0;
@@ -1155,7 +1208,7 @@ export class InputManager {
1155
1208
  }
1156
1209
  break;
1157
1210
  case 'gamepad_axis': {
1158
- const dz = binding.deadzone ?? 0.15;
1211
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1159
1212
  for (let i = 0; i < this.gamepads.length; i++) {
1160
1213
  const gp = this.gamepads[i];
1161
1214
  if (!gp) continue;
@@ -1240,7 +1293,7 @@ export class InputManager {
1240
1293
  if (!bindings) return out;
1241
1294
  for (const binding of bindings) {
1242
1295
  if (binding.type === 'gamepad_axis') {
1243
- const dz = binding.deadzone ?? 0.15;
1296
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1244
1297
  for (let i = 0; i < this.gamepads.length; i++) {
1245
1298
  const gp = this.gamepads[i];
1246
1299
  if (!gp) continue;
@@ -1292,7 +1345,7 @@ export class InputManager {
1292
1345
  private contributeGamepadAxisPair(
1293
1346
  binding: Extract<InputBinding, { type: 'gamepad_axis_pair' }>,
1294
1347
  ): { value: Vector2; source: ActionValueSource }[] {
1295
- const dz = binding.deadzone ?? 0.15;
1348
+ const dz = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1296
1349
  const out: { value: Vector2; source: ActionValueSource }[] = [];
1297
1350
  for (let i = 0; i < this.gamepads.length; i++) {
1298
1351
  const gp = this.gamepads[i];
@@ -1350,7 +1403,10 @@ export class InputManager {
1350
1403
  case 'touch_stick': {
1351
1404
  const raw = this.touchStickValues.get(binding.sourceId) ?? { x: 0, y: 0 };
1352
1405
  out.push({
1353
- value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
1406
+ value: InputManager.rescaleVector2(
1407
+ raw,
1408
+ binding.deadzone ?? TOUCH_STICK_DEFAULT_DEADZONE,
1409
+ ),
1354
1410
  source: { bindingType: 'touch_stick', sourceId: binding.sourceId },
1355
1411
  });
1356
1412
  break;
@@ -1715,46 +1771,6 @@ export class InputManager {
1715
1771
  for (const actionName of this.defaultBindings.keys()) this.resetBindings(actionName);
1716
1772
  }
1717
1773
 
1718
- /**
1719
- * Whether `a` and `b` are the SAME physical input (F3's structural-conflict
1720
- * identity check — see `findConflicts`). Deliberately narrower than deep
1721
- * equality: `deadzone` never participates (two bindings on the same key
1722
- * with different deadzones are still the same physical control), and a
1723
- * `gamepad_axis`'s `direction` DOES participate — opposite directions of one
1724
- * physical axis (e.g. "accelerate" on positive, "brake" on negative) are a
1725
- * legitimate, non-conflicting pair, not a collision.
1726
- */
1727
- private static bindingsCollide(a: InputBinding, b: InputBinding): boolean {
1728
- if (a.type !== b.type) return false;
1729
- switch (a.type) {
1730
- case 'key':
1731
- return a.code === (b as typeof a).code;
1732
- case 'mouse_button':
1733
- return a.button === (b as typeof a).button;
1734
- case 'mouse_move':
1735
- return true; // there is only one physical mouse-move source
1736
- case 'gamepad_button':
1737
- return a.button === (b as typeof a).button;
1738
- case 'gamepad_axis': {
1739
- const other = b as typeof a;
1740
- return a.axis === other.axis && a.direction === other.direction;
1741
- }
1742
- case 'gamepad_axis_pair': {
1743
- const other = b as typeof a;
1744
- return a.xAxis === other.xAxis && a.yAxis === other.yAxis;
1745
- }
1746
- case 'touch_button':
1747
- case 'touch_stick':
1748
- case 'test_axis':
1749
- case 'test_vector2':
1750
- case 'test_pointer_delta':
1751
- case 'test_pointer_position':
1752
- return a.sourceId === (b as typeof a).sourceId;
1753
- default:
1754
- return false;
1755
- }
1756
- }
1757
-
1758
1774
  /**
1759
1775
  * Every EXISTING binding, on every OTHER registered action, that is
1760
1776
  * structurally the same physical input as `proposed` (F3 AC "conflicts are
@@ -1769,7 +1785,7 @@ export class InputManager {
1769
1785
  for (const [actionName, bindings] of this.actions) {
1770
1786
  if (actionName === excludeActionName) continue;
1771
1787
  bindings.forEach((binding, bindingIndex) => {
1772
- if (InputManager.bindingsCollide(proposed, binding)) {
1788
+ if (inputBindingsCollide(proposed, binding)) {
1773
1789
  conflicts.push({ actionName, bindingIndex, binding: structuredClone(binding) });
1774
1790
  }
1775
1791
  });
@@ -1995,8 +2011,17 @@ export class InputManager {
1995
2011
  */
1996
2012
  setTouchStick(sourceId: string, value: Vector2): void {
1997
2013
  this.touchStickValues.set(sourceId, value);
1998
- // F4 — mirrors the gamepad-axis heuristic in poll() (see `isStickActive`).
1999
- if (InputManager.isStickActive(value)) this.lastActiveDevice = 'touch';
2014
+ // F4 — same rule as poll()'s gamepad axes (see `declaredDeadzone`): the
2015
+ // deadzone the game declared on THIS virtual stick decides whether the knob
2016
+ // has left rest, not a number this class picked.
2017
+ const deadzone = this.declaredDeadzone(
2018
+ (binding) =>
2019
+ binding.type === 'touch_stick' && binding.sourceId === sourceId
2020
+ ? (binding.deadzone ?? TOUCH_STICK_DEFAULT_DEADZONE)
2021
+ : undefined,
2022
+ TOUCH_STICK_DEFAULT_DEADZONE,
2023
+ );
2024
+ if (Math.hypot(value.x, value.y) > deadzone) this.lastActiveDevice = 'touch';
2000
2025
  }
2001
2026
 
2002
2027
  /** Clear a virtual stick's value (e.g. simulating the touch point lifting —