@volter/project 0.5.211 → 0.5.213

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 (52) hide show
  1. package/NOTICE +2 -2
  2. package/README.md +8 -15
  3. package/package.json +5 -26
  4. package/schemas/volter-project.schema.json +33 -145
  5. package/schemas/volter-settings.schema.json +11 -49
  6. package/src/adapter/adapter-module.ts +208 -465
  7. package/src/adapter/adapter-surface.ts +4 -32
  8. package/src/adapter/authoring-seam-contract.ts +7 -24
  9. package/src/adapter/authoring.ts +126 -265
  10. package/src/adapter/editor-looks.ts +98 -127
  11. package/src/adapter/finders/registry.ts +6 -21
  12. package/src/adapter/host-context.ts +17 -72
  13. package/src/adapter/index.ts +4 -77
  14. package/src/adapter/manifest-interpreter.ts +7 -50
  15. package/src/adapter/physics-adapter-2d.ts +4 -21
  16. package/src/adapter/root-adapter.ts +37 -111
  17. package/src/adapter/root-seam-contract.ts +3 -6
  18. package/src/adapter/root-stacking.ts +4 -46
  19. package/src/adapter/seam-evidence.ts +0 -16
  20. package/src/adapter/system-adapter.ts +24 -656
  21. package/src/adapter/system-seam-contract.ts +1 -91
  22. package/src/adapter/transform.ts +4 -3
  23. package/src/adapter/workspace-arrangement.ts +33 -49
  24. package/src/manifest/editor-port.ts +10 -36
  25. package/src/manifest/filename.ts +3 -42
  26. package/src/manifest/load-file.ts +9 -15
  27. package/src/manifest/load.ts +44 -179
  28. package/src/manifest/locate.ts +8 -32
  29. package/src/manifest/schema.ts +155 -427
  30. package/src/semver.ts +16 -0
  31. package/src/settings/keys.ts +9 -11
  32. package/src/settings/schema.ts +55 -44
  33. package/schemas/engine-api.json +0 -57
  34. package/schemas/engine-api.md +0 -22
  35. package/src/adapter/binding.ts +0 -445
  36. package/src/adapter/debug-registry-ref.ts +0 -19
  37. package/src/adapter/frame-capture.ts +0 -80
  38. package/src/adapter/ingest/contract-system-slots.ts +0 -28
  39. package/src/adapter/ingest/game-contract-seams.ts +0 -86
  40. package/src/adapter/ingest/game-contract.ts +0 -506
  41. package/src/adapter/ingest/mount-readiness.ts +0 -140
  42. package/src/adapter/loop-gate-report.ts +0 -53
  43. package/src/adapter/native-entry-surface.ts +0 -100
  44. package/src/adapter/render-audio.ts +0 -20
  45. package/src/adapter/render-memory.ts +0 -34
  46. package/src/adapter/renderer-config.ts +0 -84
  47. package/src/adapter/system-slot.ts +0 -301
  48. package/src/core/system-phase.ts +0 -52
  49. package/src/inspection.ts +0 -281
  50. package/src/manifest/configuration-kinds.ts +0 -241
  51. package/src/manifest/kind-modules.ts +0 -58
  52. package/src/manifest/runtime-environment.ts +0 -48
@@ -1,46 +1,14 @@
1
1
  /**
2
- * `editor/volter.adapter.ts` — THE ADAPTER MODULE CONTRACT.
2
+ * The project’s `editor/volter.adapter.ts` default-exports a definition created
3
+ * by {@link defineAdapter}. Regions and documents are strict, validated data;
4
+ * editor Layout, style and keymap are imported runtime values. Unknown keys
5
+ * are rejected.
3
6
  *
4
- * ARCHITECTURE-CORE §The editor protocol, "The adapter is the game's own
5
- * module": the editor is a universal CLIENT of a fixed protocol and every game
6
- * supplies a SERVER — its adapter. That adapter is the game's OWN module,
7
- * `editor/volter.adapter.ts`, sitting beside `volter.project.json`, default-exporting
8
- * {@link defineAdapter}'s result.
9
- *
10
- * It is CODE (bindings are expressions — a scene entry may carry a closure the
11
- * host evaluates against the mounted game) with a CONFIG's discipline: the top
12
- * level is a STATICALLY EVALUABLE BINDING TABLE, readable without booting the
13
- * game. Concretely, that is the split this module enforces:
14
- *
15
- * - the TABLE SHAPE — regions, the scene table, observation declarations —
16
- * is plain data, validated by {@link AdapterDefinitionSchema} the moment
17
- * the module is evaluated. `.strict()` throughout: an unrecognized key is
18
- * an error naming the key, never a silent pass-through (CLAUDE.md,
19
- * "Unknown input must REJECT LOUDLY").
20
- * - individual FIELDS may be closures, evaluated lazily against the mounted
21
- * game (`ObservationDeclaration.answer`). A closure is never required to
22
- * read the table. `editor.Layout` is an imported React component: the
23
- * editor invokes it to compose surfaces and edit/play behavior. It is a
24
- * runtime value, never a serialized manifest setting or a registry key.
25
- *
26
- * **Zero inference.** A binding is a DECLARATION or a finder SELECTION the
27
- * adapter makes — never something the host sniffs out. Finder algorithms
28
- * (scenes from an entrypoint selection, prefabs from story registrations, …)
29
- * ship host-side under `adapter/finders/`, and this module names them by
30
- * SELECTION only ({@link FinderSelection}). That is deliberate and load-bearing
31
- * twice over:
32
- * 1. the engine never runs a finder nobody selected — enforced mechanically
33
- * by `packages/engine/test/finder-import-boundary.test.ts`, whose only
34
- * sanctioned importer of the finder namespace is the editor's adapter
35
- * loader;
36
- * 2. this module therefore imports NOTHING from `adapter/finders/`, so a
37
- * game's `editor/volter.adapter.ts` pulls no finder implementation (and no source
38
- * parser) into its own bundle. The dependency runs one way: finders
39
- * import their parameter types from HERE.
40
- *
41
- * The manifest keeps only what must be readable without evaluating any module
42
- * — identity, roots, boot mode, `server.room`. Everything that binds behavior
43
- * is the adapter's. This wave adds NO manifest field.
7
+ * Bindings explicitly declare values or select host-side finders. This module
8
+ * contains finder parameter types and selections without importing finder
9
+ * implementations, so project bundles do not pull in host source parsers.
10
+ * The manifest retains project identity and roots; the
11
+ * adapter binds behavior.
44
12
  */
45
13
 
46
14
  import type { KeymapContribution, StyleContribution } from './editor-looks';
@@ -50,52 +18,46 @@ import type { ResolvedAdapterRoot } from '../manifest/load';
50
18
  import type { AdapterSurface } from './adapter-surface';
51
19
  import { WRITE_ANCHOR_KINDS, type WriteAnchorKind } from './authoring';
52
20
 
21
+ /** Where the adapter module lives, relative to the project's `volter.project.json`. */
22
+ export const ADAPTER_MODULE_FILENAME = 'editor/volter.adapter.ts';
23
+
53
24
  // ---------------------------------------------------------------------------
54
25
  // Regions
55
26
  // ---------------------------------------------------------------------------
56
27
 
57
28
  /**
58
- * The native substrate serving a region. A surface is only the host-provided
59
- * medium (`canvas`); this declaration is what distinguishes Pixi, Babylon and
60
- * Phaser without runtime sniffing.
61
- */
62
- export const SUBSTRATE_NAMES = ['three', 'pixi', 'dom', 'babylon', 'phaser'] as const;
63
- export type SubstrateName = (typeof SUBSTRATE_NAMES)[number];
64
-
65
- /**
66
- * A region's WORLD BASIS — which axis points up, and where its ground sits.
67
- *
68
- * The editor needs a ground plane to place a dropped asset or a probe point on.
69
- * That plane used to be the constant `y = 0` compiled into `editor-viewport.ts`,
70
- * which is a guess about the GAME's convention: a Z-up world (the CAD/Blender
71
- * idiom foreign R3F games routinely carry) got its drops silently projected onto
72
- * the wrong plane. It is exactly the shape "zero inference" names — a fact the
73
- * game's author can state, so the fix is a declaration slot rather than a
74
- * smarter sniff (ARCHITECTURE-CORE §The editor protocol).
29
+ * A substrate an installed integration serves, as it registers it through its serving half
30
+ * (`ProjectServingModule.substrates`, `@volter/sdk/session/project-serving`). A surface is only
31
+ * the host-provided medium (`canvas`); the substrate is the library a project declares on it, and
32
+ * this registration is what makes that declaration known to the editor. `project` names no
33
+ * substrate itself: a region's declared {@link AdapterRegion.substrate} is validated against the
34
+ * registered names ({@link parseAdapterDefinition}), and a `manifest-roots` region takes its
35
+ * surface's {@link SubstrateRegistration.default} row ({@link regionsFromManifestRoots}).
75
36
  */
76
- export interface AdapterRegionBasis {
77
- /** World axis pointing away from the ground. */
78
- readonly up: 'y' | 'z';
79
- /** Ground offset along {@link up}, in world units. */
80
- readonly groundHeight: number;
37
+ export interface SubstrateRegistration {
38
+ /** The name a region declares in `substrate`. */
39
+ readonly substrate: string;
40
+ /** The surface this substrate serves. */
41
+ readonly surface: AdapterSurface;
42
+ /** The source dialects, by name (a spelling of source on this substrate), that a region on it
43
+ * may declare in {@link AdapterRegion.dialect}. */
44
+ readonly dialects: readonly string[];
45
+ /**
46
+ * The default row for {@link surface}: the dialect (one of {@link dialects}) and anchors a
47
+ * first-party root of that surface gets when no explicit `regions` list says otherwise. Absent
48
+ * when this substrate is not the surface's default.
49
+ */
50
+ readonly default?: {
51
+ readonly dialect: string;
52
+ readonly anchors: readonly WriteAnchorKind[];
53
+ };
81
54
  }
82
55
 
83
- /**
84
- * The NATIVE basis — what {@link regionsFromManifestRoots} emits and what a
85
- * region that declares none is read as. +Y up, ground at 0: three.js' own
86
- * convention, which every first-party root is authored in. Stated as a value so
87
- * "the default" has one home instead of a copy at each reader.
88
- */
89
- export const NATIVE_REGION_BASIS: AdapterRegionBasis = Object.freeze({
90
- up: 'y',
91
- groundHeight: 0,
92
- });
93
-
94
56
  /**
95
57
  * One matrix REGION: a root, its surface, and the library selections that
96
58
  * serve it. Universality lives at region granularity — which libraries serve a
97
- * surface is a per-surface ENGINE fact, so a new surface root in an existing
98
- * game is one manifest line and one derived region, never new adapter code
59
+ * surface is a per-surface fact its integration registers, so a new surface root in an existing
60
+ * project is one manifest line and one derived region, never new adapter code
99
61
  * (that is exactly what {@link regionsFromManifestRoots} does).
100
62
  */
101
63
  export interface AdapterRegion {
@@ -103,13 +65,17 @@ export interface AdapterRegion {
103
65
  readonly id: string;
104
66
  /** What the host hands this root. */
105
67
  readonly surface: AdapterSurface;
106
- /** Native substrate, by name. */
107
- readonly substrate: SubstrateName;
108
68
  /**
109
- * Dialect writer, by name (`r3f`, `jsx`, `pixi-react`, …) — the truth
110
- * family's source writer for this region. `null` states outright that the
111
- * host writes no source here: the region's truth is the game's own code,
112
- * which the host does not author (a module or ingest root).
69
+ * Native substrate, by a registered name ({@link SubstrateRegistration}). `null` when the
70
+ * derivation names none: no installed integration gives this region's surface a default row, or
71
+ * the registrations are unknown.
72
+ */
73
+ readonly substrate: string | null;
74
+ /**
75
+ * The source dialect, by name: the spelling of source the host writes for this region, one of
76
+ * its substrate's registered {@link SubstrateRegistration.dialects}. `null` states outright
77
+ * that the host writes no source here: the region's truth is the project's own code, which the
78
+ * host does not author (a module or ingest root).
113
79
  */
114
80
  readonly dialect: string | null;
115
81
  /**
@@ -118,29 +84,22 @@ export interface AdapterRegion {
118
84
  * the matrix, which is a work order — never a silently unsupported edit.
119
85
  */
120
86
  readonly anchors: readonly WriteAnchorKind[];
121
- /**
122
- * This region's world basis. Omitted = {@link NATIVE_REGION_BASIS}, which is
123
- * a DECLARED default (the native adapter emits it outright), not a silent
124
- * fallback — same shape as an absent `editor/volter.adapter.ts` meaning
125
- * `nativeAdapter()`.
126
- */
127
- readonly basis?: AdapterRegionBasis;
128
87
  /**
129
88
  * Project-relative globs naming source files this region OWNS, for the
130
89
  * ambiguous remainder that import reach cannot place.
131
90
  *
132
91
  * A file's surface/dialect is derived from WHICH REGION'S IMPORT CLOSURE
133
92
  * reaches it — a load-bearing fact, because the region's manifest entry is
134
- * what the host actually executes at mount (the game would break first if it
93
+ * what the host actually executes at mount (the project would break first if it
135
94
  * lied). Reach through several regions of the SAME surface is unambiguous;
136
95
  * reach through regions of DIFFERING surfaces, or through none at all, is
137
- * genuinely undecidable from outside, and this is where the game's author
96
+ * genuinely undecidable from outside, and this is where the project's author
138
97
  * states the answer instead of the host guessing it from the file's bytes.
139
98
  *
140
- * READER: `packages/sdk/src/kit/ui-source/file-region-resolver.ts`'s
99
+ * READER: `packages/sdk/src/ui-source/file-region-resolver.ts`'s
141
100
  * `resolveFileRegion`, consulted BEFORE reach — a declared include wins,
142
101
  * because it is a first-party statement about this exact file. Every OID
143
- * stamping tier, the HMR classifier, the R3F authoring diagnostics and
102
+ * stamping tier, the HMR classifier, the dialect's authoring diagnostics and
144
103
  * Content's component grouping read that one resolver.
145
104
  *
146
105
  * {@link regionsFromManifestRoots} emits none: the native default is PURE
@@ -157,18 +116,13 @@ export interface AdapterRegion {
157
116
  * Surfaces this root mounts BESIDE {@link AdapterRegion.surface}, each with
158
117
  * the source files that render on it. Together with `surface` — the manifest
159
118
  * root's own adapter, which is always the PRIMARY — this states the root's
160
- * FULL mounted surface set (decision, 2026-08-16; ARCHITECTURE-CORE §The
161
- * editor protocol).
119
+ * FULL mounted surface set.
162
120
  *
163
- * A root is not always one surface. The shipped case is an R3F game whose
164
- * `three` root mounts a canvas AND adopts a DOM HUD beside it
165
- * (`adoptGameDomRoot` already adopts the pair together): its HUD modules
166
- * render react-dom host elements, and `userData-oid` on a `<div>` is a prop
167
- * react-dom does not recognize, so the editor must know they are the `dom`
168
- * half. Before this slot there was nowhere to say so — `id` names the
169
- * manifest root this region grades, so a second `dom` region for such a root
170
- * would either name a root the host would try to MOUNT (fabrication, which
171
- * the anti-shim rule forbids) or name no root at all.
121
+ * One root may mount several surfaces, such as a canvas and a DOM overlay.
122
+ * The declared surface determines how each source file is stamped: React DOM
123
+ * elements need DOM OIDs, while `userData-oid` belongs to a stage element.
124
+ * Additional surfaces belong to this root's `mounts`; a separate region would
125
+ * identify another manifest root for the host to mount.
172
126
  *
173
127
  * Each mount's `include` is REQUIRED and carries the same glob vocabulary as
174
128
  * {@link AdapterRegion.include} (`**`, `*`, `?`). A mount with no files would
@@ -176,7 +130,7 @@ export interface AdapterRegion {
176
130
  * no question any reader asks.
177
131
  *
178
132
  * READER: the same one — `resolveFileRegion`'s rung 1. A file claimed by a
179
- * region's own `include` AND by a mount is a contradiction in the game's own
133
+ * region's own `include` AND by a mount is a contradiction in the project's own
180
134
  * declaration and settles nothing, exactly as two regions claiming one file
181
135
  * do; it falls through to reach, and failing that to the caller's loud
182
136
  * ambiguous path.
@@ -203,7 +157,7 @@ export interface AdapterRegionMount {
203
157
  const AdapterRegionMountSchema = z
204
158
  .object({
205
159
  surface: z
206
- .enum(['three', 'canvas', 'dom'])
160
+ .enum(['stage', 'canvas', 'dom'])
207
161
  .describe('An additional surface this root mounts, beside the manifest root’s own adapter'),
208
162
  include: z
209
163
  .array(z.string().min(1))
@@ -215,32 +169,28 @@ const AdapterRegionMountSchema = z
215
169
  })
216
170
  .strict();
217
171
 
218
- const AdapterRegionBasisSchema = z
219
- .object({
220
- up: z.enum(['y', 'z']).describe('World axis pointing away from the ground'),
221
- groundHeight: z.number().describe('Ground offset along `up`, in world units'),
222
- })
223
- .strict();
224
-
225
172
  const AdapterRegionSchema = z
226
173
  .object({
227
174
  id: z.string().min(1).describe('Manifest root id this region grades'),
228
- surface: z.enum(['three', 'canvas', 'dom']).describe('Render surface the host hands this root'),
229
- substrate: z.enum(SUBSTRATE_NAMES).describe('Native substrate serving this region, by name'),
175
+ surface: z.enum(['stage', 'canvas', 'dom']).describe('Render surface the host hands this root'),
176
+ substrate: z
177
+ .string()
178
+ .min(1)
179
+ .describe(
180
+ 'Native substrate serving this region, by name; the editor validates it against the ' +
181
+ 'substrates its installed integrations register',
182
+ ),
230
183
  dialect: z
231
184
  .string()
232
185
  .min(1)
233
186
  .nullable()
234
187
  .describe(
235
- 'Dialect writer serving this region, by name; null states outright that the host writes ' +
236
- "no source here (the region's truth is the game's own code)",
188
+ "Source dialect of this region, by name, one of its substrate's registered dialects; null " +
189
+ "states outright that the host writes no source here (the region's truth is the project's own code)",
237
190
  ),
238
191
  anchors: z
239
192
  .array(z.enum(WRITE_ANCHOR_KINDS as [WriteAnchorKind, ...WriteAnchorKind[]]))
240
193
  .describe('Anchor kinds this region’s writer serves'),
241
- basis: AdapterRegionBasisSchema.optional().describe(
242
- 'World basis (up axis + ground height); omit for the native +Y / 0 default',
243
- ),
244
194
  include: z
245
195
  .array(z.string().min(1))
246
196
  .min(1)
@@ -263,7 +213,7 @@ const AdapterRegionSchema = z
263
213
  /**
264
214
  * How a definition supplies its regions: the RULE `'manifest-roots'` (the
265
215
  * native default — a declaration may be a rule rather than a list, the way
266
- * `roots[]` never enumerates a world's contents), or an explicit list a game
216
+ * `roots[]` never enumerates a world's contents), or an explicit list a project
267
217
  * with novel bindings states outright.
268
218
  */
269
219
  export type RegionBinding = 'manifest-roots' | readonly AdapterRegion[];
@@ -280,32 +230,9 @@ const RegionBindingSchema = z.union([
280
230
  * neither is a fact the manifest states: which files a region owns beyond
281
231
  * its entry's reach, and which surfaces its root mounts beside its own.
282
232
  */
283
- export interface AdapterRegionOverlay {
284
- /** See {@link AdapterRegion.include}. */
285
- readonly include?: readonly string[];
286
- /** See {@link AdapterRegion.mounts}. */
287
- readonly mounts?: readonly AdapterRegionMount[];
288
- }
233
+ export type AdapterRegionOverlay = Pick<AdapterRegion, 'include' | 'mounts'>;
289
234
 
290
- const AdapterRegionOverlaySchema = z
291
- .object({
292
- include: z
293
- .array(z.string().min(1))
294
- .min(1)
295
- .optional()
296
- .describe(
297
- 'Project-relative globs (`**`, `*`, `?`) naming source files this derived region owns, ' +
298
- 'for files no root entry’s import closure reaches; a declared include wins over reach',
299
- ),
300
- mounts: z
301
- .array(AdapterRegionMountSchema)
302
- .min(1)
303
- .optional()
304
- .describe(
305
- 'Surfaces this root mounts BESIDE its own adapter, each with the files that render on ' +
306
- 'them; with the derived region’s own surface, the root’s full mounted surface set',
307
- ),
308
- })
235
+ const AdapterRegionOverlaySchema = AdapterRegionSchema.pick({ include: true, mounts: true })
309
236
  .strict()
310
237
  .refine((value) => value.include !== undefined || value.mounts !== undefined, {
311
238
  message:
@@ -314,22 +241,10 @@ const AdapterRegionOverlaySchema = z
314
241
  });
315
242
 
316
243
  /**
317
- * PARAMETERS FOR THE RULE, keyed by manifest root id (decision, 2026-08-17;
318
- * ARCHITECTURE-CORE §The editor protocol, "Declared includes LAYER onto the
319
- * manifest-roots rule").
320
- *
321
- * The finder-selection shape, one level up: `regions: 'manifest-roots'` SELECTS
322
- * the mechanical derivation, and this table DECLARES the per-region parameters
323
- * it cannot derive. The two compose — {@link regionsFromManifestRoots} merges
324
- * them — so a first-party project states one include without hand-writing its
325
- * whole region table, and adding a root still grows the table for free.
326
- *
327
- * Illegal beside an explicit `regions` LIST, and rejected by name: that form is
328
- * the full REPLACEMENT, and its entries carry `include`/`mounts` themselves.
329
- * Two homes for one fact is how a declaration goes stale in one of them.
330
- *
331
- * MEASURED BASIS: a replace-only region binding means no `nativeAdapter()`
332
- * project can declare a single include without restating every region.
244
+ * Per-root parameters for the `manifest-roots` region rule.
245
+ * {@link regionsFromManifestRoots} merges these includes and mounts into its
246
+ * derived regions. An explicit region list carries its own parameters and
247
+ * cannot be combined with this table.
333
248
  */
334
249
  const RegionIncludesSchema = z
335
250
  .record(z.string().min(1), AdapterRegionOverlaySchema)
@@ -344,48 +259,43 @@ const RegionIncludesSchema = z
344
259
  * RESOLVED manifest (`@volter/project/manifest/load` already decided both — this never
345
260
  * re-derives them).
346
261
  *
347
- * The per-surface library table below is an ENGINE fact, stated once:
262
+ * Which library serves a surface is not this module's to say: each surface's
263
+ * row is the {@link SubstrateRegistration.default} an installed integration
264
+ * registers, passed in as `registered`:
348
265
  *
349
- * | root `adapter` | projector | dialect | anchors |
350
- * |-----------------------|-----------|---------------|------------------------------------------------------|
351
- * | `three` | `three` | `r3f` | source-prop, source-structure, construction-literal |
352
- * | `canvas` | `pixi` | `pixi-react` | source-prop, source-structure, construction-literal |
353
- * | `dom` | `dom` | `jsx` | source-prop, source-structure |
354
- * | `{module,surface}` | (surface) | `null` | live-only |
355
- * | `{ingest,surface}` | (surface) | `null` | live-only |
266
+ * | root `adapter` | substrate | dialect | anchors |
267
+ * |--------------------|-------------------|-------------------|-------------------|
268
+ * | a surface | its default row's | the row's dialect | the row's anchors |
269
+ * | a surface, no row | `null` | `null` | live-only |
270
+ * | unknown rows | `null` | `null` | none |
271
+ * | `{module,surface}` | its default row's | `null` | live-only |
272
+ * | `{ingest,surface}` | its default row's | `null` | live-only |
356
273
  *
357
- * `source-structure` rides with every first-party dialect because all three
358
- * write through the SAME structural door (`/__ui-source/struct`) — a region
359
- * that can rewrite a JSX attribute in a file can rewrite that file's element
360
- * tree. It is listed separately rather than folded into `source-prop` because
361
- * they are different doors: a lane could lose one and keep the other, and the
362
- * matrix has to be able to say so.
274
+ * A root whose surface no integration serves gets a region all the same,
275
+ * because the region's id and surface are what file placement reads; it names
276
+ * no substrate and declares `live-only`, since no dialect writer authors it.
363
277
  *
364
- * A module/ingest root's truth is the game's own source, which no first-party
278
+ * A module/ingest root's truth is the project's own source, which no first-party
365
279
  * dialect writer authors — so it declares `live-only` rather than implying a
366
280
  * write path it does not have. That is a red cell and therefore a work order,
367
281
  * which is the point of stating it.
368
282
  *
369
- * Every derived region carries {@link NATIVE_REGION_BASIS} outright, so the
370
- * editor's ground plane READS a declaration on the native path too — the
371
- * default is published, never assumed at the read site.
372
- *
373
283
  * It DERIVES no {@link AdapterRegion.include} and no {@link AdapterRegion.mounts},
374
284
  * and there is no derivation that could: the manifest states ONE adapter per
375
285
  * root and names only its entry, so a root's extra owned files and its second
376
- * mounted surface are facts only the game can state. It states them as
286
+ * mounted surface are facts only the project can state. It states them as
377
287
  * `regionIncludes` PARAMETERS, which this function merges onto the region it
378
- * derives for that root — the rule stays selected, the parameters are declared
379
- * (ARCHITECTURE-CORE §The editor protocol, "Declared includes LAYER onto the
380
- * manifest-roots rule"). A key naming no declared root merges onto nothing;
288
+ * derives for that root — the rule stays selected, the parameters are declared.
289
+ * A key naming no declared root merges onto nothing;
381
290
  * the LOADER reports that, because only it can see both sides.
382
291
  */
383
292
  export function regionsFromManifestRoots(
384
293
  roots: readonly ResolvedAdapterRoot[],
294
+ registered: readonly SubstrateRegistration[],
385
295
  regionIncludes: Readonly<Record<string, AdapterRegionOverlay>> = {},
386
296
  ): AdapterRegion[] {
387
297
  return roots.map((root) => ({
388
- ...derivedRegion(root),
298
+ ...derivedRegion(root, registered),
389
299
  ...overlayOf(regionIncludes[root.id]),
390
300
  }));
391
301
  }
@@ -412,38 +322,22 @@ export function unmatchedRegionIncludeIds(
412
322
  }
413
323
 
414
324
  /** One root's region, before any declared parameters are merged onto it. */
415
- function derivedRegion(root: ResolvedAdapterRoot): AdapterRegion {
325
+ function derivedRegion(
326
+ root: ResolvedAdapterRoot,
327
+ registered: readonly SubstrateRegistration[],
328
+ ): AdapterRegion {
416
329
  const surface = root.surface;
330
+ const row = registered.find((entry) => entry.surface === surface && entry.default !== undefined);
417
331
  const firstParty = root.adapter.type === 'builtin';
418
- const substrate: SubstrateName = surface === 'canvas' ? 'pixi' : surface;
419
- const basis = NATIVE_REGION_BASIS;
420
- if (!firstParty) {
421
- return {
422
- id: root.id,
423
- surface,
424
- substrate,
425
- dialect: null,
426
- anchors: ['live-only'] as const,
427
- basis,
428
- };
429
- }
430
- if (surface === 'dom') {
431
- return {
432
- id: root.id,
433
- surface,
434
- substrate,
435
- dialect: 'jsx',
436
- anchors: ['source-prop', 'source-structure'] as const,
437
- basis,
438
- };
332
+ if (!row?.default) {
333
+ return { id: root.id, surface, substrate: null, dialect: null, anchors: ['live-only'] as const };
439
334
  }
440
335
  return {
441
336
  id: root.id,
442
337
  surface,
443
- substrate,
444
- dialect: surface === 'three' ? 'r3f' : 'pixi-react',
445
- anchors: ['source-prop', 'source-structure', 'construction-literal'] as const,
446
- basis,
338
+ substrate: row.substrate,
339
+ dialect: firstParty ? row.default.dialect : null,
340
+ anchors: firstParty ? [...row.default.anchors] : (['live-only'] as const),
447
341
  };
448
342
  }
449
343
 
@@ -459,8 +353,8 @@ export const EXPORTED_COMPOSITION_REGIONS = 'exported-composition-regions';
459
353
 
460
354
  /**
461
355
  * `scenesFromEntrypointSelection` — read the entrypoint's ACTIVE-SCENE
462
- * SELECTION, the load-bearing reference (the game itself executes it, so it
463
- * cannot drift: the game would break first).
356
+ * SELECTION, the load-bearing reference (the project itself executes it, so it
357
+ * cannot drift: the project would break first).
464
358
  */
465
359
  export interface ScenesFromEntrypointSelectionParams {
466
360
  readonly finder: 'scenesFromEntrypointSelection';
@@ -484,21 +378,9 @@ export interface ScenesFromEntrypointSelectionParams {
484
378
  readonly selection?: string;
485
379
  }
486
380
 
487
- /**
488
- * `prefabsFromStories` — read the colocated portable-CSF story registrations.
489
- * A prefab is an ordinary source component a designer independently places,
490
- * declared by a story whose `meta.component` names it; the story registration
491
- * is the load-bearing reference here (removing it removes the declaration).
492
- * Zero parameters: the selection IS the whole configuration.
493
- */
494
- export interface PrefabsFromStoriesParams {
495
- readonly finder: 'prefabsFromStories';
496
- }
497
-
498
381
  /**
499
382
  * A finder selection: the finder's registered NAME plus its own parameters.
500
- * The set of finders is OPEN (ARCHITECTURE-CORE §The editor protocol,
501
- * "Documents, not scenes"): the engine's own register in
383
+ * The set of finders is OPEN: the engine's own register in
502
384
  * `adapter/finders/registry.ts`, a contribution registers more, and this
503
385
  * module validates only the envelope — each finder's parameters are checked
504
386
  * against ITS schema the moment the loader runs it, loudly and by name.
@@ -517,70 +399,72 @@ const FinderSelectionSchema = z
517
399
  // ---------------------------------------------------------------------------
518
400
 
519
401
  /**
520
- * How the RUNNING game is navigated to an entry — the `reachable` honesty
402
+ * Where a `root-mount` entry sits in its region's entrypoint: the key it holds in
403
+ * the entrypoint's module-level selection table.
404
+ */
405
+ interface RootMountSelection {
406
+ /** Identifier of the table (`const <table> = { <key>: <Component>, … }`). */
407
+ readonly table: string;
408
+ readonly key: string;
409
+ /**
410
+ * The entrypoint indexes its table with this key right now
411
+ * (`scenes[activeScene]`), so mounting the region renders this entry: it is the
412
+ * region's standing document in Edit. Absent when the key cannot be read
413
+ * statically; never a guess.
414
+ */
415
+ readonly active?: boolean;
416
+ }
417
+
418
+ /**
419
+ * How the RUNNING project is navigated to an entry — the `reachable` honesty
521
420
  * clause, stated rather than assumed. An entry whose reach is `none` is a
522
421
  * declared-but-unreachable scene: a red cell carrying its own reason.
523
422
  */
524
423
  export type SceneReach =
525
- /** Mounting the region IS opening it — the single-composition degenerate case. */
526
- | { readonly kind: 'root-mount' }
527
424
  /**
528
- * The entrypoint's own selection table reaches it under `key`.
529
- *
530
- * `active` is the SECOND load-bearing fact about the same slot: the
531
- * entrypoint indexes its table with one key right now (`scenes[activeScene]`),
532
- * and that key's composition is literally what mounting the region renders.
533
- * So an active entry is the region's own standing document — it NAMES that
534
- * document rather than earning a second one beside it, exactly as
535
- * `root-mount` does. Absent means "not known to be the one at the slot",
536
- * which is the honest answer whenever the key cannot be read statically; it
537
- * is never a guess, and it is per-entry because two regions each have their
538
- * own slot with its own occupant.
425
+ * Mounting the entry's region opens it. Without `selection`, the region mounts
426
+ * one composition and this entry is it. With `selection`, the region's
427
+ * entrypoint renders `table[key]` at its swap slot, so the region mounted at
428
+ * that key opens this entry.
539
429
  */
540
- | {
541
- readonly kind: 'entrypoint-selection';
542
- readonly selection: string;
543
- readonly key: string;
544
- readonly active?: boolean;
545
- }
430
+ | { readonly kind: 'root-mount'; readonly selection?: RootMountSelection }
546
431
  /** A portable story mounts it in isolation. */
547
432
  | { readonly kind: 'story'; readonly storyId: string }
548
433
  /**
549
- * The RUNNING game's own published scenes contract reaches it, under
550
- * `sceneId` (`adapter/ingest/game-contract.ts`'s `scenes.list()` ids).
434
+ * The RUNNING project's own published scenes contract reaches it, under
435
+ * `sceneId` (one of the ids its `scenes.list()` publishes).
551
436
  *
552
- * This is the LIVE reach: opening it while playing means asking the game to
553
- * navigate, the way its own buttons do. An authorable entry that also names
554
- * `source` can independently own an Edit isolation document; reach never
555
- * decides whether a source composition can be mounted as a piece.
437
+ * An authorable entry that also names `source` owns an isolation document;
438
+ * reach never decides whether a source composition can be mounted as a piece.
556
439
  *
557
440
  * A DECLARATION, never an inference: the host does not sniff a mapping
558
441
  * between a table entry and a contract id, and it does not assume the two
559
- * vocabularies coincide. The adapter states which contract id this entry is,
560
- * and the host validates that id against the game's OWN `list()` at the
561
- * moment it navigates — so a game that stops publishing the scene refuses by
562
- * name instead of navigating somewhere else.
442
+ * vocabularies coincide. The adapter states which contract id this entry is.
563
443
  */
564
- | { readonly kind: 'game-contract'; readonly sceneId: string }
444
+ | { readonly kind: 'project-contract'; readonly sceneId: string }
565
445
  /** Declared, with no code path that opens it. */
566
446
  | { readonly kind: 'none'; readonly reason: string };
567
447
 
568
- const SceneReachSchema = z.union([
569
- z.object({ kind: z.literal('root-mount') }).strict(),
448
+ const SceneReachSchema = z.discriminatedUnion('kind', [
570
449
  z
571
450
  .object({
572
- kind: z.literal('entrypoint-selection'),
573
- selection: z.string().min(1),
574
- key: z.string().min(1),
575
- active: z.boolean().optional(),
451
+ kind: z.literal('root-mount'),
452
+ selection: z
453
+ .object({
454
+ table: z.string().min(1),
455
+ key: z.string().min(1),
456
+ active: z.boolean().optional(),
457
+ })
458
+ .strict()
459
+ .optional(),
576
460
  })
577
461
  .strict(),
578
462
  z.object({ kind: z.literal('story'), storyId: z.string().min(1) }).strict(),
579
- z.object({ kind: z.literal('game-contract'), sceneId: z.string().min(1) }).strict(),
463
+ z.object({ kind: z.literal('project-contract'), sceneId: z.string().min(1) }).strict(),
580
464
  z.object({ kind: z.literal('none'), reason: z.string().min(1) }).strict(),
581
465
  ]);
582
466
 
583
- /** Where an entry's composition lives in the game's own source. */
467
+ /** Where an entry's composition lives in the project's own source. */
584
468
  export interface SceneSource {
585
469
  /** Project-relative path. */
586
470
  readonly path: string;
@@ -595,15 +479,12 @@ const SceneSourceSchema = z
595
479
  /**
596
480
  * One entry of the scene table. Scenes and prefabs are SIBLINGS here — both
597
481
  * are registered compositions the editor opens in isolation through the same
598
- * verb, differing only in instance site (ARCHITECTURE-CORE §Roots: "A scene is
599
- * a ROLE, not a kind … the same kind of thing as a prefab").
482
+ * verb, differing only in instance site.
600
483
  */
601
484
  export interface DocumentEntry {
602
485
  readonly id: string;
603
486
  readonly label: string;
604
- /** `scene` (swaps at the entrypoint's slot) or `prefab` (placed by a designer). */
605
- /** OPEN: `scene` and `prefab` are the first two kinds; a page, a component,
606
- * a model, a bake are kinds the same way. */
487
+ /** Open document kind, such as scene, prefab, page, component, model or bake. */
607
488
  readonly kind: string;
608
489
  /** Owning region id; `null` for a project-scoped entry bound to no one root. */
609
490
  readonly region: string | null;
@@ -614,12 +495,11 @@ export interface DocumentEntry {
614
495
  readonly authorable: boolean;
615
496
  readonly reach: SceneReach;
616
497
  readonly source?: SceneSource;
617
- /** Optional adapter-declared prerequisite run before `source` is constructed
618
- * in Edit isolation. The export is a zero-argument function; the host never
619
- * infers setup from neighboring filenames or game globals. */
620
- readonly isolationSetup?: SceneSource;
621
498
  /** Which finder produced it; absent = the adapter stated it outright. */
622
499
  readonly finder?: string;
500
+ /** `true` = its editor opens it before its file is written, and its first save writes the file: what `editor.open`
501
+ * of its id opens when the file is not there yet. */
502
+ readonly opensUnwritten?: boolean;
623
503
  }
624
504
 
625
505
  const DocumentEntrySchema = z
@@ -631,8 +511,8 @@ const DocumentEntrySchema = z
631
511
  authorable: z.boolean(),
632
512
  reach: SceneReachSchema,
633
513
  source: SceneSourceSchema.optional(),
634
- isolationSetup: SceneSourceSchema.optional(),
635
514
  finder: z.string().min(1).optional(),
515
+ opensUnwritten: z.boolean().optional(),
636
516
  })
637
517
  .strict();
638
518
 
@@ -659,171 +539,37 @@ const AdapterDocumentTableSchema = z
659
539
  })
660
540
  .strict();
661
541
 
662
- // ---------------------------------------------------------------------------
663
- // Observation
664
- // ---------------------------------------------------------------------------
665
-
666
- /**
667
- * The two faces a declaration projects onto. They are exactly the two
668
- * `DebugAdapter` halves (`adapter/system-adapter.ts`): `state` becomes a
669
- * provider (`providers()` / `state(name)`), `command` becomes a verb
670
- * (`commands()` / `invoke(name, args)`). There is deliberately no third kind —
671
- * see {@link OBSERVATION_KIND_REFUSAL}.
672
- */
673
- export const OBSERVATION_KINDS = ['command', 'state'] as const;
674
- export type ObservationKind = (typeof OBSERVATION_KINDS)[number];
675
-
676
- /**
677
- * Why a declaration may not name a `SystemAdapters` slot here.
678
- *
679
- * A game's system slots are already declared through the game contract's own
680
- * `systemAdapters` carrier (`adapter/ingest/game-contract.ts`), which validates
681
- * each slot per-surface and reports a malformed one BY NAME
682
- * (`contract-system-adapters.ts`). A second door onto the same slots is exactly
683
- * the two-declarations-one-slot problem `game-contract.ts` records for `debug`,
684
- * where it says outright that a second door "would let a game declare two
685
- * different debug planes with no rule for which wins".
686
- */
687
- const OBSERVATION_KIND_REFUSAL =
688
- "observation.kind must be 'command' or 'state' — the two DebugAdapter faces a declaration " +
689
- 'projects onto. A SystemAdapters slot is declared through the game contract’s `systemAdapters` ' +
690
- 'carrier, which validates it per-surface; declaring one here would be a second door onto the ' +
691
- 'same slot with no rule for which wins.';
692
-
693
- /**
694
- * One statically declared observation slot. Declaring a slot and ANSWERING it
695
- * live are separate facts (the two-state rule): a slot declared here whose
696
- * `answer` throws, whose `answer` is absent, or whose game never booted reads as
697
- * broken, never as "the game has none". The projector
698
- * (`adapter/ingest/observation-debug-adapter.ts`) keeps that distinction: an
699
- * unanswerable slot is still LISTED, and refuses by name when read.
700
- */
701
- export interface ObservationDeclaration {
702
- readonly id: string;
703
- readonly kind: ObservationKind;
704
- readonly description?: string | undefined;
705
- /** Command execution side; omitted means a local app-owned command. */
706
- readonly locus?: 'client' | 'server' | undefined;
707
- /**
708
- * Lazily evaluated against the mounted game. The field may be a closure; the
709
- * TABLE around it stays statically readable, which is the whole discipline.
710
- *
711
- * What `game` IS is the host's to hand over and is stated at the projector:
712
- * for an ingest mount it is the REALM the game's own modules ran in, because
713
- * a foreign game's public handles are the only thing the host can honestly
714
- * pass (`observation-debug-adapter.ts`).
715
- */
716
- readonly answer?: (game: unknown, ...args: unknown[]) => unknown;
717
- }
718
-
719
- const ObservationDeclarationSchema = z
720
- .object({
721
- id: z.string().min(1),
722
- kind: z.enum(OBSERVATION_KINDS, { error: () => OBSERVATION_KIND_REFUSAL }),
723
- description: z.string().min(1).optional(),
724
- locus: z.enum(['client', 'server']).optional(),
725
- answer: z
726
- .custom<(game: unknown) => unknown>((value) => typeof value === 'function', {
727
- message: 'observation.answer must be a function evaluated against the mounted game',
728
- })
729
- .optional(),
730
- })
731
- .strict()
732
- .refine((value) => value.kind === 'command' || value.locus === undefined, {
733
- path: ['locus'],
734
- message: 'observation.locus is valid only for a command declaration',
735
- });
736
-
737
- // ---------------------------------------------------------------------------
738
- // Native input
739
- // ---------------------------------------------------------------------------
740
-
741
- /** The action value shapes the session input door already speaks. */
742
- export const ADAPTER_INPUT_VALUE_TYPES = [
743
- 'digital',
744
- 'scalar',
745
- 'vector2',
746
- 'pointerDelta',
747
- 'pointerPosition',
748
- ] as const;
749
- export type AdapterInputValueType = (typeof ADAPTER_INPUT_VALUE_TYPES)[number];
750
- export type AdapterInputValue = boolean | number | { readonly x: number; readonly y: number };
751
-
752
- /** One action in an app-owned input store. */
753
- export interface AdapterInputAction {
754
- readonly name: string;
755
- readonly valueType: AdapterInputValueType;
756
- }
757
-
758
- /**
759
- * Adapter-side binding from the universal session input door to a game's
760
- * ordinary input store. The game owns both its physical-device listeners and
761
- * its state; this table only tells the host how to drive that same state for a
762
- * bot or live REPL session. No component imports this contract.
763
- */
764
- export interface AdapterInputBinding {
765
- /** Manifest root whose native scheduler consumes these actions. */
766
- readonly root: string;
767
- /** Current action vocabulary. A closure permits an app-owned dynamic map. */
768
- readonly actions: (game: unknown) => readonly AdapterInputAction[];
769
- /** Apply one action through the app's own input-store write path. */
770
- readonly set: (game: unknown, action: string, value: AdapterInputValue) => void;
771
- /** Release every app-owned virtual action. */
772
- readonly clear: (game: unknown) => void;
773
- }
774
-
775
- const AdapterInputBindingSchema = z
776
- .object({
777
- root: z.string().min(1),
778
- actions: z.custom<AdapterInputBinding['actions']>((value) => typeof value === 'function', {
779
- message: 'input.actions must be a function returning the app-owned action vocabulary',
780
- }),
781
- set: z.custom<AdapterInputBinding['set']>((value) => typeof value === 'function', {
782
- message: 'input.set must be a function writing the app-owned input store',
783
- }),
784
- clear: z.custom<AdapterInputBinding['clear']>((value) => typeof value === 'function', {
785
- message: 'input.clear must be a function releasing app-owned virtual actions',
786
- }),
787
- })
788
- .strict();
789
-
790
542
  // ---------------------------------------------------------------------------
791
543
  // The definition
792
544
  // ---------------------------------------------------------------------------
793
545
 
794
546
  /** Project-owned editor defaults. User layout choices remain session state. */
795
- export interface AdapterEditorLayoutProps {
796
- readonly playing: boolean;
797
- readonly paused: boolean;
798
- }
799
547
  export interface AdapterEditorConfiguration {
800
- /** Imported React implementation; owns both composition and edit/play presentation. */
801
- readonly Layout?: ComponentType<AdapterEditorLayoutProps>;
548
+ /** Imported React component the editor renders as its composition. */
549
+ readonly Layout?: ComponentType;
802
550
 
803
551
  /**
804
- * The project's LOOK, imported from the package that carries it
805
- * (`@volter/editor-blender`'s `blenderStyle`). Palette, material, icon set and chrome
806
- * regions in one object — the same `StyleContribution` the package's
807
- * `workspace.style` contribution registers, named here as the project's
808
- * choice. Beneath a person's own settings and above their cross-project
809
- * ones (`settings-store.ts`).
552
+ * A style this project PINS, imported from the package that carries it
553
+ * (`<package>`'s exported style): the same `StyleContribution` the package's
554
+ * `workspace.style` contribution registers, with the colour theme it was drawn with. Set, it
555
+ * outranks a person's cross-project choice (`settings-store.ts`); left out, the project wears
556
+ * the product's default, its built-in extension's `configurationDefaults`, and a person chooses
557
+ * freely. Projects `create` scaffolds leave it out.
810
558
  */
811
559
  readonly style?: StyleContribution;
812
- /** The project's KEY BINDINGS, imported the same way (`blenderKeymap`). */
560
+ /** The project's KEY BINDINGS, imported the same way (`<package>`'s exported keymap). */
813
561
  readonly keymap?: KeymapContribution;
814
562
 
815
563
  /** Registered workspace to open when this checkout has no saved workspace. */
816
564
  readonly workspace?: string;
817
565
  /** Inspector layout beneath the active workspace and the user's override. */
818
566
  readonly inspector?: 'column' | 'properties' | 'card';
819
- /** Utility registry ids to reveal when Play settles; omitted means none. */
820
- readonly playUtilities?: readonly string[];
821
567
  }
822
568
 
823
569
  const AdapterEditorConfigurationSchema = z
824
570
  .object({
825
571
  Layout: z
826
- .custom<ComponentType<AdapterEditorLayoutProps>>(
572
+ .custom<ComponentType>(
827
573
  (value) => typeof value === 'function',
828
574
  'Layout must be an imported React component',
829
575
  )
@@ -848,7 +594,6 @@ const AdapterEditorConfigurationSchema = z
848
594
  .optional(),
849
595
  workspace: z.string().min(1).optional(),
850
596
  inspector: z.enum(['column', 'properties', 'card']).optional(),
851
- playUtilities: z.array(z.string().min(1)).optional(),
852
597
  })
853
598
  .strict();
854
599
 
@@ -864,24 +609,20 @@ export interface AdapterDefinition {
864
609
  */
865
610
  readonly regionIncludes: Readonly<Record<string, AdapterRegionOverlay>>;
866
611
  readonly documents: AdapterDocumentTable;
867
- readonly observation: readonly ObservationDeclaration[];
868
- readonly input?: AdapterInputBinding | undefined;
869
612
  }
870
613
 
871
- /** What a game writes. Every field optional — the near-empty adapter is the
872
- * first-party case, and an adapter's SIZE measures the game's distance from
614
+ /** What a project writes. Every field optional — the near-empty adapter is the
615
+ * first-party case, and an adapter's SIZE measures the project's distance from
873
616
  * native. */
874
617
  export interface AdapterDefinitionInput {
875
618
  readonly editor?: AdapterEditorConfiguration;
876
619
  readonly regions?: RegionBinding;
877
620
  readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
878
621
  readonly documents?: AdapterDocumentTable;
879
- readonly observation?: readonly ObservationDeclaration[];
880
- readonly input?: AdapterInputBinding | undefined;
881
622
  }
882
623
 
883
624
  /**
884
- * Why the two forms may not be combined, said in the error a game's author
625
+ * Why the two forms may not be combined, said in the error a project's author
885
626
  * reads. An explicit region list REPLACES the derivation and its entries carry
886
627
  * `include`/`mounts` themselves; layering a second table over it would put one
887
628
  * fact in two homes, with no rule for which wins.
@@ -892,14 +633,12 @@ const REGION_INCLUDES_WITH_LIST_REFUSAL =
892
633
  'its own `include`/`mounts`. Move these globs onto the matching `regions[]` entry, or drop the ' +
893
634
  'list and let the derivation run.';
894
635
 
895
- export const AdapterDefinitionSchema = z
636
+ const AdapterDefinitionSchema = z
896
637
  .object({
897
638
  editor: AdapterEditorConfigurationSchema.optional(),
898
639
  regions: RegionBindingSchema.optional(),
899
640
  regionIncludes: RegionIncludesSchema.optional(),
900
641
  documents: AdapterDocumentTableSchema.optional(),
901
- observation: z.array(ObservationDeclarationSchema).optional(),
902
- input: AdapterInputBindingSchema.optional(),
903
642
  })
904
643
  .strict()
905
644
  // An EMPTY table beside a list is fine and must stay fine: `defineAdapter`
@@ -914,10 +653,19 @@ export const AdapterDefinitionSchema = z
914
653
  { message: REGION_INCLUDES_WITH_LIST_REFUSAL },
915
654
  );
916
655
 
656
+ /** The error a malformed definition throws, naming each key. */
657
+ function invalidDefinition(issues: z.ZodError['issues']): Error {
658
+ return new Error(
659
+ `editor/volter.adapter.ts: invalid adapter definition — ${issues
660
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
661
+ .join('; ')}`,
662
+ );
663
+ }
664
+
917
665
  /**
918
- * Validate and freeze a game's binding table.
666
+ * Validate and freeze a project's binding table.
919
667
  *
920
- * Called TWICE by design and with one schema: once here, in the game's own
668
+ * Called TWICE by design and with one schema: once here, in the project's own
921
669
  * module, so a malformed table fails at the author's own file with the key
922
670
  * named; and once in the host's loader, because the loaded default export is
923
671
  * untrusted input crossing a module boundary. Two call sites, one rule — not
@@ -925,41 +673,19 @@ export const AdapterDefinitionSchema = z
925
673
  */
926
674
  export function defineAdapter(input: AdapterDefinitionInput = {}): AdapterDefinition {
927
675
  const parsed = AdapterDefinitionSchema.safeParse(input);
928
- if (!parsed.success) {
929
- throw new Error(
930
- `editor/volter.adapter.ts: invalid adapter definition — ${parsed.error.issues
931
- .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
932
- .join('; ')}`,
933
- );
934
- }
676
+ if (!parsed.success) throw invalidDefinition(parsed.error.issues);
935
677
  return Object.freeze({
936
678
  ...(input.editor
937
679
  ? {
938
- editor: Object.freeze({
939
- ...input.editor,
940
- ...(input.editor.playUtilities
941
- ? { playUtilities: Object.freeze([...input.editor.playUtilities]) }
942
- : {}),
943
- }),
680
+ editor: Object.freeze({ ...input.editor }),
944
681
  }
945
682
  : {}),
946
683
  regions: input.regions ?? 'manifest-roots',
947
684
  regionIncludes: Object.freeze({ ...(input.regionIncludes ?? {}) }),
948
685
  documents: Object.freeze({ ...(input.documents ?? {}) }),
949
- observation: Object.freeze([...(input.observation ?? [])]),
950
- ...(input.input ? { input: input.input } : {}),
951
686
  });
952
687
  }
953
688
 
954
- /** Extra bindings a near-native game adds without restating the native ones. */
955
- export interface NativeAdapterOptions {
956
- /** Scene-table entries this game states outright, beside what the finders find. */
957
- readonly scenes?: readonly DocumentEntry[];
958
- /** Per-region `include`/`mounts` parameters for the derivation — see
959
- * {@link AdapterDefinition.regionIncludes}. */
960
- readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
961
- }
962
-
963
689
  /**
964
690
  * THE NATIVE DEFAULT — regions derived mechanically from the manifest's
965
691
  * `roots[]`, and a scene table produced by the two shipped finders. This is
@@ -967,12 +693,10 @@ export interface NativeAdapterOptions {
967
693
  * is also what a project with NO `editor/volter.adapter.ts` gets: that absence is the
968
694
  * declared native default, not a silent fallback.
969
695
  */
970
- export function nativeAdapter(options: NativeAdapterOptions = {}): AdapterDefinition {
696
+ export function nativeAdapter(): AdapterDefinition {
971
697
  return defineAdapter({
972
698
  regions: 'manifest-roots',
973
- ...(options.regionIncludes ? { regionIncludes: options.regionIncludes } : {}),
974
699
  documents: {
975
- ...(options.scenes ? { entries: options.scenes } : {}),
976
700
  find: [
977
701
  { finder: 'scenesFromEntrypointSelection', regions: EXPORTED_COMPOSITION_REGIONS },
978
702
  { finder: 'prefabsFromStories' },
@@ -982,16 +706,35 @@ export function nativeAdapter(options: NativeAdapterOptions = {}): AdapterDefini
982
706
  }
983
707
 
984
708
  /**
985
- * Parse an untrusted default export (the loaded `editor/volter.adapter.ts`) into a
986
- * definition. Rejects loudly — an unknown key, a bad finder name, a
987
- * `selection` paired with the rule form all fail by name.
709
+ * Parse an untrusted adapter-module default export through {@link defineAdapter}.
710
+ * Validate explicit regions against installed substrate registrations, including
711
+ * each substrate’s allowed dialects. Finder names and selection parameters are
712
+ * validated by the loader that resolves those selections.
988
713
  */
989
- export function parseAdapterDefinition(value: unknown): AdapterDefinition {
714
+ export function parseAdapterDefinition(
715
+ value: unknown,
716
+ registered: readonly SubstrateRegistration[],
717
+ ): AdapterDefinition {
990
718
  if (value === null || typeof value !== 'object') {
991
719
  throw new Error(
992
720
  'editor/volter.adapter.ts must default-export defineAdapter({…}) / nativeAdapter() — ' +
993
721
  `got ${value === null ? 'null' : typeof value}`,
994
722
  );
995
723
  }
996
- return defineAdapter(value as AdapterDefinitionInput);
724
+ const definition = defineAdapter(value as AdapterDefinitionInput);
725
+ if (definition.regions === 'manifest-roots') return definition;
726
+ const substrates = z.object({
727
+ regions: z.array(z.object({ substrate: z.enum(registered.map((entry) => entry.substrate)) })),
728
+ });
729
+ const parsed = substrates.safeParse({ regions: definition.regions });
730
+ if (!parsed.success) throw invalidDefinition(parsed.error.issues);
731
+ const dialectIssues = definition.regions.flatMap((region, index) => {
732
+ const dialects = registered.find((entry) => entry.substrate === region.substrate)?.dialects ?? [];
733
+ const dialect = z.enum(dialects).nullable().safeParse(region.dialect);
734
+ return dialect.success
735
+ ? []
736
+ : dialect.error.issues.map((issue) => ({ ...issue, path: ['regions', index, 'dialect', ...issue.path] }));
737
+ });
738
+ if (dialectIssues.length > 0) throw invalidDefinition(dialectIssues);
739
+ return definition;
997
740
  }