@vgai/engine 0.5.10 → 0.5.12

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 (157) hide show
  1. package/README.md +6 -5
  2. package/dist/adapter/authoring.d.ts +51 -28
  3. package/dist/adapter/authoring.d.ts.map +1 -1
  4. package/dist/adapter/body-marks.d.ts +59 -0
  5. package/dist/adapter/body-marks.d.ts.map +1 -0
  6. package/dist/adapter/body-marks.js +62 -0
  7. package/dist/adapter/constraint.d.ts +41 -0
  8. package/dist/adapter/constraint.d.ts.map +1 -0
  9. package/dist/adapter/constraint.js +27 -0
  10. package/dist/adapter/hierarchy-marks.d.ts +138 -0
  11. package/dist/adapter/hierarchy-marks.d.ts.map +1 -0
  12. package/dist/adapter/hierarchy-marks.js +148 -0
  13. package/dist/adapter/index.d.ts +1 -1
  14. package/dist/adapter/index.d.ts.map +1 -1
  15. package/dist/adapter/ingest/contract-system-adapters.d.ts +64 -0
  16. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -0
  17. package/dist/adapter/ingest/contract-system-adapters.js +134 -0
  18. package/dist/adapter/ingest/game-contract.d.ts +89 -5
  19. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  20. package/dist/adapter/ingest/game-contract.js +11 -3
  21. package/dist/adapter/ingest/scene-capture.d.ts +31 -4
  22. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  23. package/dist/adapter/ingest/scene-capture.js +29 -7
  24. package/dist/adapter/ingest/structural-ids.d.ts +1 -1
  25. package/dist/adapter/ingest/structural-ids.js +2 -2
  26. package/dist/adapter/reflection-probe.d.ts +53 -0
  27. package/dist/adapter/reflection-probe.d.ts.map +1 -0
  28. package/dist/adapter/reflection-probe.js +18 -0
  29. package/dist/asset-formats/index.d.ts +4 -10
  30. package/dist/asset-formats/index.d.ts.map +1 -1
  31. package/dist/asset-formats/index.js +3 -8
  32. package/dist/asset-formats/material.d.ts +0 -66
  33. package/dist/asset-formats/material.d.ts.map +1 -1
  34. package/dist/asset-formats/material.js +0 -8
  35. package/dist/asset-formats/mesh.d.ts +1 -2
  36. package/dist/asset-formats/mesh.d.ts.map +1 -1
  37. package/dist/asset-formats/mesh.js +3 -13
  38. package/dist/asset-formats/render-env.d.ts +2 -2
  39. package/dist/asset-parse-error.d.ts +4 -5
  40. package/dist/asset-parse-error.d.ts.map +1 -1
  41. package/dist/asset-parse-error.js +4 -5
  42. package/dist/config.d.ts +7 -0
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js +7 -0
  45. package/dist/data/curve.d.ts +116 -0
  46. package/dist/data/curve.d.ts.map +1 -0
  47. package/dist/data/curve.js +230 -0
  48. package/dist/data/vite-plugin-data.d.ts +2 -2
  49. package/dist/data/vite-plugin-data.d.ts.map +1 -1
  50. package/dist/data/vite-plugin-data.js +24 -15
  51. package/dist/dev/performance-profiler.d.ts +1 -0
  52. package/dist/dev/performance-profiler.d.ts.map +1 -1
  53. package/dist/dev/performance-profiler.js +25 -2
  54. package/dist/dev/register-render-vitals.d.ts +18 -7
  55. package/dist/dev/register-render-vitals.d.ts.map +1 -1
  56. package/dist/dev/register-render-vitals.js +35 -8
  57. package/dist/ecs/user-data.d.ts +49 -1
  58. package/dist/ecs/user-data.d.ts.map +1 -1
  59. package/dist/ecs/user-data.js +34 -0
  60. package/dist/index.d.ts +2 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +2 -3
  63. package/dist/manifest/index.d.ts +3 -3
  64. package/dist/manifest/index.d.ts.map +1 -1
  65. package/dist/manifest/index.js +2 -2
  66. package/dist/manifest/load.d.ts +20 -63
  67. package/dist/manifest/load.d.ts.map +1 -1
  68. package/dist/manifest/load.js +12 -103
  69. package/dist/manifest/schema.d.ts +21 -103
  70. package/dist/manifest/schema.d.ts.map +1 -1
  71. package/dist/manifest/schema.js +66 -142
  72. package/dist/pixi/index.d.ts +2 -3
  73. package/dist/pixi/index.d.ts.map +1 -1
  74. package/dist/pixi/index.js +2 -3
  75. package/dist/pixi/ingest.d.ts +7 -75
  76. package/dist/pixi/ingest.d.ts.map +1 -1
  77. package/dist/pixi/ingest.js +6 -14
  78. package/dist/pixi/scene-capture.d.ts +2 -2
  79. package/dist/pixi/scene-capture.js +2 -2
  80. package/dist/react/use-data.d.ts +10 -5
  81. package/dist/react/use-data.d.ts.map +1 -1
  82. package/dist/react/use-data.js +15 -15
  83. package/dist/render/viewport-shading.d.ts +1 -1
  84. package/dist/render/viewport-shading.d.ts.map +1 -1
  85. package/dist/render/viewport-shading.js +14 -0
  86. package/dist/runtime/create-runtime.d.ts +12 -0
  87. package/dist/runtime/create-runtime.d.ts.map +1 -1
  88. package/dist/runtime/create-runtime.js +7 -2
  89. package/dist/runtime/mount-game.d.ts +1 -1
  90. package/dist/runtime/mount-game.d.ts.map +1 -1
  91. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  92. package/dist/runtime/mount-manifest.js +5 -1
  93. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  94. package/dist/world3d-react/r3f-adapter.js +129 -6
  95. package/dist/world3d-react/renderer-config.d.ts +15 -0
  96. package/dist/world3d-react/renderer-config.d.ts.map +1 -1
  97. package/dist/world3d-react/renderer-config.js +15 -0
  98. package/dist/world3d-react/world-context.d.ts +11 -1
  99. package/dist/world3d-react/world-context.d.ts.map +1 -1
  100. package/dist/world3d-react/world-context.js +1 -0
  101. package/dist-config/config.js +140 -20
  102. package/dist-config/config.js.map +4 -4
  103. package/package.json +1 -1
  104. package/schemas/engine-capabilities.json +10 -10
  105. package/schemas/vgai-project.schema.json +22 -73
  106. package/src/adapter/authoring.ts +54 -31
  107. package/src/adapter/body-marks.ts +69 -0
  108. package/src/adapter/constraint.ts +71 -0
  109. package/src/adapter/hierarchy-marks.ts +155 -0
  110. package/src/adapter/index.ts +2 -3
  111. package/src/adapter/ingest/contract-system-adapters.ts +174 -0
  112. package/src/adapter/ingest/game-contract.ts +104 -6
  113. package/src/adapter/ingest/scene-capture.ts +61 -11
  114. package/src/adapter/ingest/structural-ids.ts +2 -2
  115. package/src/adapter/reflection-probe.ts +75 -0
  116. package/src/asset-formats/index.ts +4 -10
  117. package/src/asset-formats/material.ts +0 -11
  118. package/src/asset-formats/mesh.ts +3 -16
  119. package/src/asset-parse-error.ts +4 -5
  120. package/src/config.ts +13 -0
  121. package/src/data/curve.ts +263 -0
  122. package/src/data/vite-plugin-data.ts +48 -14
  123. package/src/dev/performance-profiler.ts +21 -2
  124. package/src/dev/register-render-vitals.ts +38 -8
  125. package/src/ecs/user-data.ts +50 -0
  126. package/src/index.ts +2 -3
  127. package/src/manifest/index.ts +3 -15
  128. package/src/manifest/load.ts +33 -139
  129. package/src/manifest/schema.ts +72 -181
  130. package/src/pixi/index.ts +2 -15
  131. package/src/pixi/ingest.ts +13 -87
  132. package/src/pixi/scene-capture.ts +2 -2
  133. package/src/react/use-data.ts +15 -20
  134. package/src/render/viewport-shading.ts +16 -1
  135. package/src/runtime/create-runtime.ts +30 -5
  136. package/src/runtime/mount-game.ts +1 -1
  137. package/src/runtime/mount-manifest.ts +5 -1
  138. package/src/world3d-react/r3f-adapter.tsx +142 -6
  139. package/src/world3d-react/renderer-config.ts +31 -0
  140. package/src/world3d-react/world-context.ts +11 -1
  141. package/dist/asset-formats/instances.d.ts +0 -17
  142. package/dist/asset-formats/instances.d.ts.map +0 -1
  143. package/dist/asset-formats/instances.js +0 -31
  144. package/dist/asset-formats/parse.d.ts +0 -20
  145. package/dist/asset-formats/parse.d.ts.map +0 -1
  146. package/dist/asset-formats/parse.js +0 -34
  147. package/dist/pixi/ingest-iframe.d.ts +0 -82
  148. package/dist/pixi/ingest-iframe.d.ts.map +0 -1
  149. package/dist/pixi/ingest-iframe.js +0 -166
  150. package/dist/render/instance-mesh.d.ts +0 -5
  151. package/dist/render/instance-mesh.d.ts.map +0 -1
  152. package/dist/render/instance-mesh.js +0 -19
  153. package/schemas/mat.schema.json +0 -255
  154. package/src/asset-formats/instances.ts +0 -38
  155. package/src/asset-formats/parse.ts +0 -39
  156. package/src/pixi/ingest-iframe.ts +0 -255
  157. package/src/render/instance-mesh.ts +0 -25
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Curve FIELDS in data assets — a response curve a designer shapes, living
3
+ * inside an ordinary `.data.json` like any other tunable value.
4
+ *
5
+ * A curve is a FIELD TYPE, never a file type: there is no `.curve.json`, no
6
+ * curve asset class, no baked LUT, and no document. `curve(axes)` is a real
7
+ * Zod schema you drop into a `*.schema.ts` beside `z.number()`, so the value
8
+ * lives in the same data file, hot-swaps through the same `defineData` handle,
9
+ * and is edited by the same Data panel that edits everything else.
10
+ *
11
+ * The JSON a designer (or an agent) reads and writes:
12
+ *
13
+ * ```json
14
+ * { "interpolation": "smooth",
15
+ * "points": [{ "x": 0, "y": 1 }, { "x": 12, "y": 0.85 }, { "x": 30, "y": 0.2 }] }
16
+ * ```
17
+ *
18
+ * TWO MODES, NO TANGENTS. `linear` and `smooth`, and nothing else. Per-key
19
+ * tangent handles would double the JSON, make it unreadable to an agent, and
20
+ * hand every author a way to make a curve overshoot its own points. Smoothness
21
+ * is DERIVED instead — Fritsch–Carlson monotone cubic tangents, computed from
22
+ * the points themselves — so the interpolant is guaranteed to stay inside each
23
+ * segment's `[yLeft, yRight]` band. "Shape it by moving points" is the whole
24
+ * authoring model.
25
+ *
26
+ * RUNG AUDIT (recorded decision, 2026-08-14): no npm dependency. The candidate
27
+ * packages (`monotone-cubic-spline` and its siblings) are unmaintained
28
+ * micro-packages, and d3's `curveMonotoneX` is an SVG PATH GENERATOR — it
29
+ * draws, it does not sample `y` at an `x`. What we need is ~40 lines of
30
+ * well-known numerics with no API surface, so it is written here.
31
+ *
32
+ * `sampleCurve` is the entire runtime: a pure function, no class, no cache
33
+ * object, no "curve system". Game code calls it every frame from its own
34
+ * update, the same way it reads any other tuning value.
35
+ */
36
+
37
+ import { z } from 'zod';
38
+
39
+ /** One authored key: a `y` value at an `x` position. No tangents — see above. */
40
+ export interface CurvePoint {
41
+ x: number;
42
+ y: number;
43
+ }
44
+
45
+ /** A curve field's value, exactly as it sits in the `.data.json`. */
46
+ export interface CurveValue {
47
+ interpolation: 'linear' | 'smooth';
48
+ points: CurvePoint[];
49
+ }
50
+
51
+ /**
52
+ * The curve's DOMAIN, declared by the engineer in the schema (never by the
53
+ * data): what the axes mean and how far they run. The editor draws its rails
54
+ * from this and clamps every drag to it; the schema rejects a point outside
55
+ * it.
56
+ */
57
+ export interface CurveAxes {
58
+ x: { min: number; max: number; label?: string };
59
+ y: { min: number; max: number; label?: string };
60
+ }
61
+
62
+ /** How the marker rides out to the emitted JSON Schema — the editor's and the dev menu's detection key, the exact shape `dataRef` uses with `x-vgai-ref`. */
63
+ const CURVE_META_KEY = 'x-vgai-curve';
64
+
65
+ function axisLabel(axes: CurveAxes, axis: 'x' | 'y'): string {
66
+ return axes[axis].label ?? axis;
67
+ }
68
+
69
+ /** Every out-of-domain / ordering problem in one pass, as teaching issues (`parseDataJson`'s "errors teach" doctrine, §6.5). */
70
+ function checkPoints(value: CurveValue, axes: CurveAxes, ctx: z.RefinementCtx): void {
71
+ const points = value.points;
72
+ for (let i = 0; i < points.length; i++) {
73
+ const point = points[i] as CurvePoint;
74
+ for (const axis of ['x', 'y'] as const) {
75
+ const { min, max } = axes[axis];
76
+ if (point[axis] < min || point[axis] > max) {
77
+ ctx.addIssue({
78
+ code: 'custom',
79
+ path: ['points', i, axis],
80
+ message:
81
+ `${point[axis]} is outside this curve's ${axisLabel(axes, axis)} domain ` +
82
+ `[${min}, ${max}] — move the point inside the domain, or widen the axis in the ` +
83
+ `schema (curve({ ${axis}: { min, max } })).`,
84
+ });
85
+ }
86
+ }
87
+ if (i === 0) continue;
88
+ const previous = points[i - 1] as CurvePoint;
89
+ if (point.x <= previous.x) {
90
+ ctx.addIssue({
91
+ code: 'custom',
92
+ path: ['points', i, 'x'],
93
+ message:
94
+ `${point.x} must be strictly greater than the previous point's x (${previous.x}) — ` +
95
+ 'curve points run left to right along x, with no duplicate x. Sort the points, or ' +
96
+ 'nudge this one.',
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * A Zod schema for a data-asset FIELD holding a response curve. Use it in a
104
+ * `.schema.ts` exactly like any other field:
105
+ *
106
+ * ```ts
107
+ * export const TuningSchema = z.object({
108
+ * spawnRate: z.number().min(0).max(10).default(1),
109
+ * difficultyRamp: curve({
110
+ * x: { min: 0, max: 300, label: 'Sim time (s)' },
111
+ * y: { min: 0, max: 2, label: 'Spawn multiplier' },
112
+ * }).describe('How hard the game leans on the player as the round runs.'),
113
+ * });
114
+ * ```
115
+ *
116
+ * The schema is STRICT (an unrecognized key is an error, never a silent
117
+ * strip), demands at least two points, and enforces strictly-increasing `x`
118
+ * with every point inside the declared axes — each with a message that names
119
+ * the fix rather than the rule.
120
+ *
121
+ * The emitted JSON Schema (`toDataJsonSchema`) carries
122
+ * `"x-vgai-curve": axes` — a Zod `.meta()`, which survives `z.toJSONSchema`
123
+ * including inside a table's `additionalProperties` row schema, exactly the
124
+ * way `dataRef`'s `"x-vgai-ref"` does. That marker is what makes the editor's
125
+ * Data panel render a curve EDITOR instead of a raw-JSON cell, with no emitter
126
+ * changes anywhere.
127
+ */
128
+ export function curve(axes: CurveAxes): z.ZodType<CurveValue> {
129
+ const point = z.strictObject({
130
+ x: z.number().describe(`Position along ${axisLabel(axes, 'x')}.`),
131
+ y: z.number().describe(`Value at that position, along ${axisLabel(axes, 'y')}.`),
132
+ });
133
+ return z
134
+ .strictObject({
135
+ interpolation: z
136
+ .enum(['linear', 'smooth'])
137
+ .describe(
138
+ 'How the value moves between points: "linear" for straight segments, ' +
139
+ '"smooth" for a monotone cubic that never overshoots them.',
140
+ ),
141
+ points: z
142
+ .array(point)
143
+ .min(2, {
144
+ message:
145
+ 'a curve needs at least two points — add another { "x": …, "y": … } so the ' +
146
+ 'value has somewhere to travel between.',
147
+ })
148
+ .describe('Keys in increasing x order; the value is clamped outside the first/last x.'),
149
+ })
150
+ .superRefine((value, ctx) => checkPoints(value, axes, ctx))
151
+ .meta({ [CURVE_META_KEY]: axes }) as unknown as z.ZodType<CurveValue>;
152
+ }
153
+
154
+ /**
155
+ * Read a curve field's declared axes back off a LIVE Zod schema (as opposed to
156
+ * the emitted JSON Schema, which the editor's Data panel reads instead).
157
+ *
158
+ * This is the dev-menu capability's detection door: it derives its rows by
159
+ * walking the game's own `TuningSchema` object, so it needs the axes from the
160
+ * schema in hand. Wrappers (`.optional()`, `.default(…)`, `.describe(…)` chains
161
+ * that re-wrap) hide the metadata behind an inner type, so a bounded unwrap
162
+ * walks down to it.
163
+ */
164
+ export function curveAxesOf(schema: z.ZodType): CurveAxes | undefined {
165
+ let current: unknown = schema;
166
+ for (let depth = 0; depth < 5 && current; depth++) {
167
+ const meta = (current as z.ZodType).meta?.() as Record<string, unknown> | undefined;
168
+ const axes = meta?.[CURVE_META_KEY];
169
+ if (axes && typeof axes === 'object' && 'x' in axes && 'y' in axes) return axes as CurveAxes;
170
+ current = (current as { def?: { innerType?: unknown } }).def?.innerType;
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /**
176
+ * Fritsch–Carlson tangents: the ordinary cubic-Hermite slopes, then limited so
177
+ * the interpolant cannot overshoot the data. This is what makes "smooth" safe
178
+ * to hand a designer — a curve that dips below its own points would silently
179
+ * feed the game a value nobody authored.
180
+ */
181
+ function monotoneTangents(points: readonly CurvePoint[]): number[] {
182
+ const n = points.length;
183
+ const secants: number[] = [];
184
+ for (let i = 0; i < n - 1; i++) {
185
+ const left = points[i] as CurvePoint;
186
+ const right = points[i + 1] as CurvePoint;
187
+ secants.push((right.y - left.y) / (right.x - left.x));
188
+ }
189
+ const tangents: number[] = [secants[0] as number];
190
+ for (let i = 1; i < n - 1; i++) {
191
+ tangents.push(((secants[i - 1] as number) + (secants[i] as number)) / 2);
192
+ }
193
+ tangents.push(secants[n - 2] as number);
194
+
195
+ for (let i = 0; i < n - 1; i++) {
196
+ const secant = secants[i] as number;
197
+ if (secant === 0) {
198
+ // A flat segment must stay flat: any slope at either end would bulge.
199
+ tangents[i] = 0;
200
+ tangents[i + 1] = 0;
201
+ continue;
202
+ }
203
+ let alpha = (tangents[i] as number) / secant;
204
+ let beta = (tangents[i + 1] as number) / secant;
205
+ // A tangent fighting the segment's own direction is a local extremum the
206
+ // data does not have.
207
+ if (alpha < 0) {
208
+ tangents[i] = 0;
209
+ alpha = 0;
210
+ }
211
+ if (beta < 0) {
212
+ tangents[i + 1] = 0;
213
+ beta = 0;
214
+ }
215
+ const radius = alpha * alpha + beta * beta;
216
+ if (radius > 9) {
217
+ const scale = 3 / Math.sqrt(radius);
218
+ tangents[i] = scale * alpha * secant;
219
+ tangents[i + 1] = scale * beta * secant;
220
+ }
221
+ }
222
+ return tangents;
223
+ }
224
+
225
+ /**
226
+ * The curve's value at `x` — the whole runtime API.
227
+ *
228
+ * Read it every frame from the game's own update, the way every other data
229
+ * value is read (`tuning.get()` then sample); never precompute a table, and
230
+ * never hold the result across frames — an edit to the `.data.json` hot-swaps
231
+ * underneath you and that is the point.
232
+ *
233
+ * Outside `[first.x, last.x]` the value CLAMPS to the nearest endpoint: a
234
+ * curve declares what it knows about, and extrapolating past it would invent
235
+ * numbers nobody authored.
236
+ */
237
+ export function sampleCurve(value: CurveValue, x: number): number {
238
+ const points = value.points;
239
+ if (points.length === 0) return 0;
240
+ const first = points[0] as CurvePoint;
241
+ const last = points[points.length - 1] as CurvePoint;
242
+ if (points.length === 1 || x <= first.x) return first.y;
243
+ if (x >= last.x) return last.y;
244
+
245
+ let i = 0;
246
+ while (i < points.length - 2 && x >= (points[i + 1] as CurvePoint).x) i++;
247
+ const left = points[i] as CurvePoint;
248
+ const right = points[i + 1] as CurvePoint;
249
+ const span = right.x - left.x;
250
+ if (span <= 0) return left.y; // duplicate x — the schema rejects it; a hand-built value might not
251
+ const t = (x - left.x) / span;
252
+ if (value.interpolation === 'linear') return left.y + (right.y - left.y) * t;
253
+
254
+ const tangents = monotoneTangents(points);
255
+ const t2 = t * t;
256
+ const t3 = t2 * t;
257
+ return (
258
+ (2 * t3 - 3 * t2 + 1) * left.y +
259
+ (t3 - 2 * t2 + t) * span * (tangents[i] as number) +
260
+ (-2 * t3 + 3 * t2) * right.y +
261
+ (t3 - t2) * span * (tangents[i + 1] as number)
262
+ );
263
+ }
@@ -38,8 +38,14 @@
38
38
  * names the registry file to fix.
39
39
  */
40
40
 
41
- import { readdirSync, readFileSync, statSync } from 'node:fs';
42
- import { join, relative } from 'node:path';
41
+ // Node built-ins are imported LAZILY (inside `collectDataCheckProblems`),
42
+ // never statically: this module is re-exported by the `@vgai/engine/config`
43
+ // barrel, which every project `*.schema.ts` reaches through its `data-ref`
44
+ // shim — so this module EVALUATES in the browser graph of any world whose
45
+ // schema the game imports. A static `import { … } from 'node:fs'` dies there
46
+ // ("Module node:fs has been externalized for browser compatibility") before
47
+ // the world can mount. The fs work itself only ever runs in Node (vite build
48
+ // / emit-schemas), where the dynamic import resolves normally.
43
49
  import type { Plugin } from 'vite';
44
50
  import type { z } from 'zod';
45
51
  import { parseDataJson, toDataJsonSchema } from './data-asset';
@@ -62,18 +68,38 @@ export interface VgaiDataCheckOptions {
62
68
  readonly root?: string;
63
69
  }
64
70
 
71
+ /** The Node APIs the checks need, resolved lazily — see the import note above. */
72
+ interface NodeIo {
73
+ readdirSync: typeof import('node:fs')['readdirSync'];
74
+ readFileSync: typeof import('node:fs')['readFileSync'];
75
+ statSync: typeof import('node:fs')['statSync'];
76
+ join: typeof import('node:path')['join'];
77
+ relative: typeof import('node:path')['relative'];
78
+ }
79
+
80
+ async function loadNodeIo(): Promise<NodeIo> {
81
+ const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]);
82
+ return {
83
+ readdirSync: fs.readdirSync,
84
+ readFileSync: fs.readFileSync,
85
+ statSync: fs.statSync,
86
+ join: path.join,
87
+ relative: path.relative,
88
+ };
89
+ }
90
+
65
91
  /** Recursively collect `*.data.json` under `dir` (absent dir → empty — a project with no data assets builds fine). */
66
- function findDataFiles(dir: string): string[] {
92
+ function findDataFiles(io: NodeIo, dir: string): string[] {
67
93
  let entries: string[];
68
94
  try {
69
- entries = readdirSync(dir);
95
+ entries = io.readdirSync(dir) as string[];
70
96
  } catch {
71
97
  return [];
72
98
  }
73
99
  const out: string[] = [];
74
100
  for (const entry of entries) {
75
- const full = join(dir, entry);
76
- if (statSync(full).isDirectory()) out.push(...findDataFiles(full));
101
+ const full = io.join(dir, entry);
102
+ if (io.statSync(full).isDirectory()) out.push(...findDataFiles(io, full));
77
103
  else if (entry.endsWith('.data.json')) out.push(full);
78
104
  }
79
105
  return out;
@@ -116,6 +142,7 @@ interface ParsedDataFiles {
116
142
  }
117
143
 
118
144
  function parseAllDataFiles(
145
+ io: NodeIo,
119
146
  root: string,
120
147
  registered: ReadonlySet<string>,
121
148
  errors: string[],
@@ -123,15 +150,15 @@ function parseAllDataFiles(
123
150
  ): ParsedDataFiles {
124
151
  const parsedByStem = new Map<string, unknown>();
125
152
  const pathsByStem = new Map<string, string[]>();
126
- for (const abs of findDataFiles(join(root, 'src', 'data'))) {
127
- const rel = relative(root, abs).split('\\').join('/');
153
+ for (const abs of findDataFiles(io, io.join(root, 'src', 'data'))) {
154
+ const rel = io.relative(root, abs).split('\\').join('/');
128
155
  const stem = assetStem(rel);
129
156
  const paths = pathsByStem.get(stem);
130
157
  if (paths) paths.push(rel);
131
158
  else pathsByStem.set(stem, [rel]);
132
159
 
133
160
  try {
134
- parsedByStem.set(stem, JSON.parse(readFileSync(abs, 'utf-8')));
161
+ parsedByStem.set(stem, JSON.parse(io.readFileSync(abs, 'utf-8') as string));
135
162
  } catch (err) {
136
163
  errors.push(
137
164
  `"${rel}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
@@ -167,15 +194,22 @@ function parseAllDataFiles(
167
194
  * The fs half of checks 1 + 2, extracted from the plugin hook so it is
168
195
  * directly unit-testable against a fixture folder (no rollup context needed).
169
196
  */
170
- export function collectDataCheckProblems(options: VgaiDataCheckOptions): {
197
+ export async function collectDataCheckProblems(options: VgaiDataCheckOptions): Promise<{
171
198
  errors: string[];
172
199
  warnings: string[];
173
- } {
200
+ }> {
201
+ const io = await loadNodeIo();
174
202
  const root = options.root ?? process.cwd();
175
203
  const errors: string[] = [];
176
204
  const warnings: string[] = [];
177
205
  const registered = new Set(options.assets.map((a) => a.sourcePath.split('\\').join('/')));
178
- const { parsedByStem, ambiguousStems } = parseAllDataFiles(root, registered, errors, warnings);
206
+ const { parsedByStem, ambiguousStems } = parseAllDataFiles(
207
+ io,
208
+ root,
209
+ registered,
210
+ errors,
211
+ warnings,
212
+ );
179
213
 
180
214
  // Check 1 — registered assets validate through the exact runtime parse.
181
215
  for (const asset of options.assets) {
@@ -256,8 +290,8 @@ export function vgaiDataCheck(options: VgaiDataCheckOptions): Plugin {
256
290
  return {
257
291
  name: 'vgai:data-check',
258
292
  apply: 'build',
259
- buildStart() {
260
- const { errors, warnings } = collectDataCheckProblems(options);
293
+ async buildStart() {
294
+ const { errors, warnings } = await collectDataCheckProblems(options);
261
295
  for (const warning of warnings) this.warn(warning);
262
296
  if (errors.length > 0) {
263
297
  // ONE error carrying every problem — fail loud with the full picture,
@@ -109,6 +109,12 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
109
109
  let frameSystems: PerformanceSpan[] = [];
110
110
  let frameComponents: PerformanceSpan[] = [];
111
111
  let enabled = initiallyEnabled;
112
+ // The header's always-visible FPS sparkline needs frame INTERVALS without
113
+ // turning on the full profiler. Full profiling measures phases/systems,
114
+ // issues GPU timer queries at render sites, and intentionally warns about
115
+ // overhead; a glanceable heartbeat must not silently opt the user into all
116
+ // of that work. `telemetryEnabled` records only the frame envelope.
117
+ let telemetryEnabled = false;
112
118
  let recording = false;
113
119
  let frameId = 0;
114
120
  let frameStart = 0;
@@ -177,13 +183,26 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
177
183
  set enabled(value: boolean) {
178
184
  if (enabled === value) return;
179
185
  enabled = value;
186
+ if (value && !telemetryEnabled) lastFrameStart = 0;
187
+ publish();
188
+ },
189
+ get telemetryEnabled() {
190
+ return telemetryEnabled;
191
+ },
192
+ set telemetryEnabled(value: boolean) {
193
+ if (telemetryEnabled === value) return;
194
+ const wasInactive = !enabled && !telemetryEnabled;
195
+ telemetryEnabled = value;
196
+ if (value && wasInactive) lastFrameStart = 0;
180
197
  publish();
181
198
  },
182
199
  get recording() {
183
200
  return recording;
184
201
  },
185
202
  startRecording() {
203
+ const wasInactive = !enabled && !telemetryEnabled;
186
204
  enabled = true;
205
+ if (wasInactive) lastFrameStart = 0;
187
206
  recording = true;
188
207
  frames.length = 0;
189
208
  publish();
@@ -197,7 +216,7 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
197
216
  publish();
198
217
  },
199
218
  beginFrame() {
200
- if (!enabled) return;
219
+ if (!enabled && !telemetryEnabled) return;
201
220
  currentPhases.clear();
202
221
  currentSystems.clear();
203
222
  currentComponents.clear();
@@ -293,7 +312,7 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
293
312
  };
294
313
  },
295
314
  endFrame() {
296
- if (!enabled) return;
315
+ if (!enabled && !telemetryEnabled) return;
297
316
  const timestamp = now();
298
317
  const intervalMs = lastFrameStart === 0 ? 0 : frameStart - lastFrameStart;
299
318
  lastFrameStart = frameStart;
@@ -6,10 +6,19 @@
6
6
  * through `vgai eval`, and `game.providers()`/`game.commands()` to discover
7
7
  * them without being told they exist.
8
8
  *
9
- * Engine-owned and first-party, seeded by the three adapter
10
- * (`adapter/setup-three-root-adapter.ts`) at the same spot it seeds
11
- * `systemAdapters.renderDebug` a game writes nothing to get these, and there
12
- * is no capability to install. The measurement half is `dev/render-vitals.ts`
9
+ * Engine-owned and first-party, seeded by BOTH three-root mount paths — a game
10
+ * writes nothing to get these, and there is no capability to install:
11
+ * - `adapter/setup-three-root-adapter.ts` (the classic imperative
12
+ * `setup(ctx)` world), at the same spot it seeds
13
+ * `systemAdapters.renderDebug`;
14
+ * - `world3d-react/r3f-adapter.tsx` (a TSX/R3F world — the shape EVERY
15
+ * scaffolded project's three root has), right after fiber's first commit
16
+ * resolves the scene.
17
+ * Both is the whole point and not redundancy: they are two independent mounts
18
+ * onto the same `Game`, and a door wired into only one of them is a door half
19
+ * the engine's worlds never get (issue #1518 — the R3F half was dark
20
+ * everywhere, found in the field on a package-native project).
21
+ * The measurement half is `dev/render-vitals.ts`
13
22
  * (derived entirely from profiler frames); the address-book half is
14
23
  * `dev/render-census.ts` (pure walks over the live scene). This module is only
15
24
  * the wiring between them and the registry.
@@ -27,9 +36,11 @@
27
36
  * the drawing buffer halves was never fill-bound.
28
37
  *
29
38
  * ── GATING ──────────────────────────────────────────────────────────────────
30
- * The caller gates. `setup-three-root-adapter.ts` calls this only under
31
- * `devLayersEnabled()` (`runtime/dev-layers.ts` — the ONE owner of "is this a
32
- * dev context"), so a ship build registers nothing and pays nothing.
39
+ * The caller gates. Both seeders call this only under `devLayersEnabled()`
40
+ * (`runtime/dev-layers.ts` — the ONE owner of "is this a dev context"), so a
41
+ * ship build registers nothing and pays nothing. Each also requires a `Game`
42
+ * shell (there is no profiler to fold without one) and a non-headless mount
43
+ * (nothing brackets a submission, so the readings could only ever be empty).
33
44
  *
34
45
  * ── RESOURCE OWNERSHIP ──────────────────────────────────────────────────────
35
46
  * OWNER: {@link registerRenderVitals}'s caller. It allocates one
@@ -112,7 +123,26 @@ export function registerRenderVitals(
112
123
  const taken = registry.adapter
113
124
  .providers()
114
125
  .some((provider) => provider.name === RENDER_VITALS_PROVIDER);
115
- if (taken) return null;
126
+ if (taken) {
127
+ // Loud, by name. This branch used to return `null` in silence, and a
128
+ // silent decline is indistinguishable from a door that was never seeded —
129
+ // which is precisely how issue #1518 stayed a mystery through a field
130
+ // session: every hypothesis about WHY the door was closed had to be
131
+ // guessed, because nothing on any path said anything. It costs one line to
132
+ // make the one legitimate decline explain itself, and the same line names
133
+ // the mount/teardown OVERLAP case (a re-mount seeding before the outgoing
134
+ // mount's `strip()` runs), where the second world would otherwise lose the
135
+ // door and no log would mention it.
136
+ console.warn(
137
+ `[render-vitals] world "${worldId}" did not get the ${RENDER_VITALS_PROVIDER} door: ` +
138
+ 'another live world already owns it (the names are fixed, so the first three root to ' +
139
+ 'mount wins). Its draws still fold into the readings — the profiler is game-scoped — ' +
140
+ 'but render.census/families/toggle address the owning root. If this world IS the ' +
141
+ 'current one, the previous mount was torn down AFTER this one seeded and its ' +
142
+ 'strip() took the door with it.',
143
+ );
144
+ return null;
145
+ }
116
146
 
117
147
  const vitals = createRenderVitals(profiler);
118
148
  const debug = registry.forRoot(worldId);
@@ -76,18 +76,48 @@
76
76
  * implementation parts without adding wrappers.
77
77
  * - `authoringLabel` — authored literal name (or component tag fallback)
78
78
  * for that source-backed component instance.
79
+ * - `authoringComponent` — exact source component identity for portable-CSF
80
+ * association; unlike the label, never user-facing copy.
81
+ * - `authoringDocument` — generic reference to the project-tool document
82
+ * that owns this scene instance's authored asset.
79
83
  * - `editorHelper` — `true` for editor-only helper objects (gizmos, wireframes).
84
+ *
85
+ * Hierarchy-presentation convention (written by GAME code, read by the editor —
86
+ * see `../adapter/hierarchy-marks.ts`, which is the ONLY place these two are
87
+ * read/written from; the `vgai` prefix marks them as the HOST's namespace on a
88
+ * node a game owns, unlike every other key above, which the engine/editor also
89
+ * write):
90
+ * - `vgaiComponentRoot` — display name of the component instance this subtree
91
+ * IS. The node renders as one collapsed, expandable
92
+ * row named for it.
93
+ * - `vgaiBuiltInternal` — `true` on the ROOT of a subtree runtime code
94
+ * CONSTRUCTED rather than authored (skeleton bones,
95
+ * particle renderers). Subtree-scoped: everything
96
+ * below a marked node is built-internal too.
97
+ * - `vgaiBodyOwner` — the rigid body that OWNS this node's pose, written
98
+ * by the code that attaches the body (see
99
+ * `../adapter/body-marks.ts`, the only reader/writer).
100
+ * A transform edit on such a node must be applied to
101
+ * the BODY, or the next physics step writes the node
102
+ * straight back.
80
103
  * - `editorHelperType` — which kind of helper (lights/particles/pivot/navmesh/...).
81
104
  * - `editorIcon` — `true` for editor billboard icon sprites.
82
105
  * - `skeletonVisible` — per-entity editor preference for its bone overlay.
83
106
  * - `skeletonEnabled` — resolved visibility preference on a skeleton helper.
84
107
  * - `envObject` — `true` for environment objects (ambient light, etc.).
108
+ * - `reflectionProbe` — live project-owned reflection probe projected from
109
+ * JSX props for renderer/editor integration.
110
+ * - `constraints` — live project-owned spatial constraints projected
111
+ * into the shared Inspector and viewport.
85
112
  * - `splineControlPoint` — index of a spline control-point drag handle.
86
113
  * - `vcDirIdx` — view-cube face direction index (0=+X,1=-X,2=+Y,...).
87
114
  */
88
115
 
89
116
  import type * as THREE from 'three';
90
117
  import type { ParticleSystem } from 'three.quarks';
118
+ import type { ConstraintMark } from '../adapter/constraint';
119
+ import type { RapierEditableBody } from '../adapter/rapier-physics-adapter';
120
+ import type { ReflectionProbeMark } from '../adapter/reflection-probe';
91
121
  import type { XStateAnimationBinding } from '../animation/xstate-animation-binding';
92
122
 
93
123
  /** The kinds of editor helper objects tagged via `editorHelperType`. */
@@ -112,6 +142,8 @@ export type EditorHelperType =
112
142
  | 'particle-shape'
113
143
  | 'pivot'
114
144
  | 'navmesh'
145
+ | 'constraints'
146
+ | 'reflection-probes'
115
147
  | 'skeletons';
116
148
 
117
149
  /**
@@ -143,12 +175,23 @@ export interface UserDataSchema {
143
175
  authoringRoot: boolean;
144
176
  authoringInstance: string;
145
177
  authoringLabel: string;
178
+ vgaiComponentRoot: string;
179
+ vgaiBuiltInternal: boolean;
180
+ vgaiBodyOwner: RapierEditableBody;
181
+ authoringComponent: string;
182
+ authoringDocument: {
183
+ readonly kind: 'project-tool';
184
+ readonly name: string;
185
+ readonly title: string;
186
+ };
146
187
  editorHelper: boolean;
147
188
  editorHelperType: EditorHelperType;
148
189
  editorIcon: boolean;
149
190
  skeletonVisible: boolean;
150
191
  skeletonEnabled: boolean;
151
192
  envObject: boolean;
193
+ constraints: readonly ConstraintMark[];
194
+ reflectionProbe: ReflectionProbeMark;
152
195
  vcDirIdx: number;
153
196
  /** W1a navmesh path-probe preview polyline: the routed waypoints, mirrored
154
197
  * onto the preview line Object3D so e2e can read the path back from the
@@ -189,12 +232,19 @@ export const UserDataKeys = {
189
232
  authoringRoot: 'authoringRoot',
190
233
  authoringInstance: 'authoringInstance',
191
234
  authoringLabel: 'authoringLabel',
235
+ vgaiComponentRoot: 'vgaiComponentRoot',
236
+ vgaiBuiltInternal: 'vgaiBuiltInternal',
237
+ vgaiBodyOwner: 'vgaiBodyOwner',
238
+ authoringComponent: 'authoringComponent',
239
+ authoringDocument: 'authoringDocument',
192
240
  editorHelper: 'editorHelper',
193
241
  editorHelperType: 'editorHelperType',
194
242
  editorIcon: 'editorIcon',
195
243
  skeletonVisible: 'skeletonVisible',
196
244
  skeletonEnabled: 'skeletonEnabled',
197
245
  envObject: 'envObject',
246
+ constraints: 'constraints',
247
+ reflectionProbe: 'reflectionProbe',
198
248
  vcDirIdx: 'vcDirIdx',
199
249
  navPathPoints: 'navPathPoints',
200
250
  } as const satisfies Record<UserDataKey, string>;
package/src/index.ts CHANGED
@@ -20,13 +20,12 @@
20
20
  * - game setup contract: `GameContext`/`GameCleanup`/`GameSetupFn`, `fromSetup`
21
21
  * - surface vocabulary: `AdapterSurface`
22
22
  * - manifest-driven mount: `mountManifestRoots` + its option/result types
23
- * - asset-format entry points: `parseMaterialFile` (.mat.json), `parseInstancesFile`
24
23
  * - game manifest (vgai.project.json) entry point: `loadGameManifest`
25
24
  */
26
25
 
27
26
  export type { AdapterSurface } from './adapter/adapter-surface';
28
27
  export { fromSetup } from './adapter/setup-three-root-adapter';
29
- export { AssetParseError, parseInstancesFile, parseMaterialFile } from './asset-formats/parse';
28
+ export { AssetParseError } from './asset-parse-error';
30
29
  export type {
31
30
  AdapterRoot,
32
31
  GameManifest,
@@ -35,7 +34,7 @@ export type {
35
34
  ResolvedGameManifest,
36
35
  RootAdapter,
37
36
  } from './manifest';
38
- export { CAPABILITY_CEILINGS, DEFAULT_SERVER_MODULE, loadGameManifest } from './manifest';
37
+ export { DEFAULT_SERVER_MODULE, loadGameManifest } from './manifest';
39
38
  export type { GameSession } from './runtime/create-runtime';
40
39
  export type {
41
40
  MountEntry,
@@ -8,21 +8,9 @@ export {
8
8
  removedManifestFilenameMessage,
9
9
  } from './filename';
10
10
  export type { ResolvedAdapter, ResolvedAdapterRoot, ResolvedGameManifest } from './load';
11
- export { CAPABILITY_CEILINGS, DEFAULT_SERVER_MODULE, loadGameManifest } from './load';
12
- export type {
13
- AdapterRoot,
14
- GameManifest,
15
- IngestStrategy,
16
- RootAdapter,
17
- Tier,
18
- } from './schema';
19
- export {
20
- AdapterRootSchema,
21
- GameManifestSchema,
22
- IngestStrategySchema,
23
- RootAdapterSchema,
24
- TierSchema,
25
- } from './schema';
11
+ export { DEFAULT_SERVER_MODULE, loadGameManifest } from './load';
12
+ export type { AdapterRoot, GameManifest, RootAdapter } from './schema';
13
+ export { AdapterRootSchema, GameManifestSchema, RootAdapterSchema } from './schema';
26
14
 
27
15
  // `loadGameManifestFile` (Node-only, reads `path` via `node:fs`) is
28
16
  // deliberately NOT re-exported here — this barrel must stay import-safe for