feat(visual): align the schema, validator, and resolution engine with the 0.8 visual contract

Stage 0 of the five-stage plan in reviews/02-application-code-triage.md: the
shared pre-renderer alignment that has to land before slice 4d writes renderer
code, so the runtime foundation and Format Specification revision 0.8 cannot
drift while three slices are built on top of them. Nothing here draws.

The JSON Schema's `visuals` definition was a Phase 0 stub of `camera`,
`systems`, and `passes`. It is replaced by the real container - scene, layers,
systems, fields, camera, effects, and the exhibit-scope `automation` array that
17.3 previously rejected while 19.1 authorized. Fourteen new definitions cover
visual objects, paints, styles, transforms, behaviors, distributions, trails,
links, automation tracks, the spawn container, and visual components, and
`components.visual` is no longer an unconstrained object. Structural exclusions
carry the contract's own prohibitions: no `id` on a visual object, no `links` on
an emitter, no `trail` on a render object, and none of the four spawn-only
lifecycle names at a system's top level.

src/runtime/visual-contract.js is new and plays the role audio-contract.js
plays for sections 14-16: the primitive, system, behavior, field, distribution,
blend, filter, and post-effect vocabularies; the post-effect parameter tables
with their ranges and pass costs; the centralized ceiling table of 19.5 split
into authoring bounds and runtime ceilings; and matchVisualTarget, which
resolves a path against the four section 8.1 visual target families and
distinguishes a dangling reference from a target that exposes no capability.

src/runtime/visual-validation.js is new and validates the shared surface: the
visuals container, the scene model including the conditional viewport `fit`
rule, layers including the rule that a present layer map makes a system's
`layer` required, the common system fields and the `spawn` container,
procedural fields including the conditional noise `direction`, the post-effect
chain, and visual automation with its two declaration scopes, its automatable
registry, its loop modes, and its authoring bound. Type-specific system fields
are carried through unvalidated and are tightened by 4d, 4e, and 4f.

The resolution engine now exposes the four visual families. `target()` returns a
capability record with a per-family stage table, replacing the hardcoded
`namespace === 'buses'` test for the automation and modulation stages, so a
boolean system `visible` correctly has neither. Visual bases sample once from
the `visual` stream domain at the exhibit-scope instantiation boundary, fall
back to their documented defaults - including the scene center for an
unauthored camera position - and clamp through the shared 8.1 safety stage, so
a `focalLength` that resolves below 1 becomes 1 rather than dividing by zero.
`requireAudioStage` becomes `requireStage(path, stage)` with the old name kept
as its automation wrapper.

The validator's binding-target check is no longer a regex over two families. It
reports ERR_INVALID_REFERENCE for an undeclared layer, system, or effect index
and ERR_UNSUPPORTED_TARGET for everything outside the four rows - per-object
properties, field strengths, emitter rates - which is the distinction trace 14
of 17.16 and trace 4 of 19.7 both check.

exhibits/minimal-visual.xzbt is a new fixture exercising the aligned surface:
two layers, a graphic system with system-scope automation, a repeater over a
component, a particle system reading a declared field, a spawned emitter with
its spawn container, two post-effects, and three bindings reaching three of the
four target families.

test/phase4-visual-contract.test.mjs adds 29 tests. `npm test` goes from 102 to
131 passing with zero failures; no existing test changed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0162Jb1J36judZNT8fHabGVt
This commit is contained in:
2026-09-06 16:21:17 +00:00
co-authored by Claude Opus 5
parent 1bc49018f4
commit 6587d3e442
9 changed files with 3153 additions and 30 deletions
+3 -3
View File
@@ -1692,7 +1692,7 @@ A **visual system** is the addressable unit of the visual subsystem and the reso
**Lifecycle configuration lives in its own container.** Every spawned-instance lifecycle field — `lifetime`, `release`, `ownership`, `inputs`, and `cancelWithScenario` — is a field of the `spawn` object and is specified in 19.2. None of the five is a top-level system field under any lifecycle, and the container exists precisely so that they cannot collide with the type-specific fields that already carry two of those names: a `particles` system's top-level `lifetime` is the lifetime of one *particle* (18.2) and an `emitter`'s is the lifetime of one *emitted item* (18.4), while `spawn.lifetime` is the lifetime of the *system instance*; likewise an `emitter`'s or `repeater`'s component `inputs` (18.1) configure one created item, while `spawn.inputs` declares the parameters of the template itself. A spawned emitter therefore expresses instance duration and item duration independently, and a spawned `repeater` — which has no per-item lifetime at all (18.5) — still takes `spawn.lifetime` without contradiction.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names appearing at the top level of a system of either lifecycle. A system declared in `visuals.systems` without a `lifecycle` field is persistent.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`. So are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of **either** lifecycle: those four names exist only inside `spawn`. A top-level `lifetime` is `ERR_UNKNOWN_FIELD` too, with one exception that is the whole point of the container — the two system types whose own field tables declare one, `particles` (18.2) and `emitter` (18.4), where `lifetime` is the per-item duration and has nothing to do with the instance. A system declared in `visuals.systems` without a `lifecycle` field is persistent.
### 17.8 The `graphic` system
@@ -2936,7 +2936,7 @@ A visual system is either **persistent** or **spawned** (PRD 86). The discrimina
| `spawn.inputs` | object | No | `{}` | Parameters the template exposes to the `spawn` action, with the shape, types, and construct scope 18.1 fixes for visual components. Distinct from the component `inputs` of an object inside `emit` or `repeat` (18.1), which configure a created item. |
| `spawn.cancelWithScenario` | boolean | No | `true` | `false` requires `spawn.ownership: "persistent"`, or `ERR_UNSUPPORTED_TARGET`. |
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names at the top level of a system of either lifecycle, per the strict unknown-field policy. This resolves the deferral 17.7 records, and the container is what keeps instance duration and item duration from sharing one key.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of either lifecycle, per the strict unknown-field policy; a top-level `lifetime` is `ERR_UNKNOWN_FIELD` except on `particles` and `emitter`, whose own tables declare it as the per-item duration (17.7). This resolves the deferral 17.7 records, and the container is what keeps instance duration and item duration from sharing one key.
**A template's own `inputs` reference scope.** Inside a spawned template — its type-specific fields, its objects, and its `automation` point values — a ValueSpec may read `{ "ref": "inputs.<parameter-id>" }` for a parameter declared in `spawn.inputs`, exactly as a component's `content` reads its own exposed parameters (18.1). The two scopes never overlap: a component's `content` sees the component's parameters, a template's body sees the template's, and a component instantiated inside a template sees only its own. `inputs.*` outside both is `ERR_INVALID_REFERENCE`, it is not a section 1.3 document namespace, and it grants no section 8.1 capability.
@@ -3249,7 +3249,7 @@ Automated, and executable without a display measurement:
5. Two tracks on one expanded visual target are `ERR_AUTOMATION_CONFLICT`; a behavior writing a channel a track targets on the same object is `ERR_AUTOMATION_CONFLICT`; the same behavior on an object with no track on that channel passes and composes by the 18.6 rule.
6. The authoring bound counts declared records once per declaration: `129` declared tracks or `2049` declared points is `ERR_VISUAL_LIMIT_EXCEEDED` at import, and `128` and `2048` pass however many times a template is later spawned. The live budget is separate: repeated spawns of a legal template up to the boundary succeed, the spawn that would cross it is refused **atomically** — no track allocated, no partial instance, no spawn ordinal consumed, `WARN_VISUAL_CEILING` under the 19.5 cadence, not a scenario failure — and disposing a live instance frees its records so a later spawn succeeds again. No admitted instance ever runs with a subset of its declared tracks.
7. Each of the four new section 8.1 rows takes binding, override, and — where the row permits — automation and modulation, through the shared pipeline, with masking and release behaving as 8.3 and 8.4 require; a `set` or `override` on any per-object, particle, emitter, behavior, or field property is `ERR_UNSUPPORTED_TARGET`, and trace 19 of 18.10 still passes unchanged.
8. A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of `lifetime`, `release`, `ownership`, `inputs`, or `cancelWithScenario` at the top level of a system of either lifecycle; `spawn.cancelWithScenario: false` without `spawn.ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. A spawned `particles` system with both a top-level `lifetime` and a `spawn.lifetime` resolves them independently: its particles expire on the first and its instance releases on the second, and a spawned `repeater` with `spawn.lifetime` and no top-level `lifetime` is legal.
8. A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of either lifecycle, while a top-level `lifetime` passes on `particles` and `emitter` and is `ERR_UNKNOWN_FIELD` on `graphic` and `repeater`; `spawn.cancelWithScenario: false` without `spawn.ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. A spawned `particles` system with both a top-level `lifetime` and a `spawn.lifetime` resolves them independently: its particles expire on the first and its instance releases on the second, and a spawned `repeater` with `spawn.lifetime` and no top-level `lifetime` is legal.
9. Every permitted state transition of 19.2 is exercised and every forbidden one rejected; a `release` of `0ms` still passes through `RELEASING` for one tick; a second `remove` on a `DISPOSED` instance is a no-op.
10. Two spawns of one template resolve independently from `<system-id>#<spawn-ordinal>`, and one seed reproduces both exactly across two runs and across two frame rates.
11. A scenario-owned spawned instance is released at scenario cleanup; a `persistent`-owned instance with `cancelWithScenario: false` survives it; a cleanup that exceeds the 10.2 deadline force-disposes with `WARN_CLEANUP_FORCED`.
+201
View File
@@ -0,0 +1,201 @@
{
"xzbt": "0.1",
"meta": {
"id": "minimal-visual",
"name": "Minimal Visual",
"version": "1.0.0",
"author": "XZBT",
"description": "A minimal generic exhibit exercising the visual contract surface that Stage 0 aligns: the scene and layer model, a graphic system, a procedural system, a declared field, a spawned template with its spawn container, exhibit- and system-scope automation, the post-effect chain, and the four external target families.",
"license": "CC0-1.0",
"tags": ["minimal", "visual"]
},
"runtime": {
"seed": 42
},
"parameters": {
"depth": {
"type": "number",
"default": 1,
"min": 0.5,
"max": 2,
"step": 0.01,
"label": "Field depth"
}
},
"state": {
"intensity": { "type": "number", "initial": 0.35, "min": 0, "max": 1 }
},
"bindings": [
{ "source": "parameters.depth", "target": "visuals.camera.zoom" },
{ "source": "state.intensity", "target": "visuals.layers.near.opacity" },
{ "source": "state.intensity", "target": "visuals.effects[0].amount", "scale": 0.6 }
],
"components": {
"visual": {
"panel": {
"parameters": {
"tint": { "type": "color", "default": "#3a6ea5" },
"lit": { "type": "boolean", "default": false }
},
"content": {
"frame": {
"type": "rounded-rectangle",
"size": { "width": 24, "height": 16 },
"radius": 2,
"style": { "fill": { "ref": "inputs.tint" }, "stroke": "#0b1118" }
}
}
}
}
},
"visuals": {
"scene": {
"coordinateSpace": "virtual",
"width": 1600,
"height": 900,
"fit": "contain",
"background": "#020308",
"depthFog": { "color": "#020308", "near": 200, "far": 1400, "density": 0.8 }
},
"layers": {
"far": { "parallax": 0.2 },
"near": { "opacity": 1 }
},
"camera": {
"x": 800,
"y": 450,
"zoom": 1,
"projection": "perspective",
"focalLength": 1200
},
"fields": {
"current": {
"type": "noise",
"scale": 220,
"speed": 0.08,
"octaves": 3,
"persistence": 0.5,
"amplitude": 40,
"mode": "curl"
}
},
"systems": {
"horizon": {
"type": "graphic",
"layer": "far",
"content": {
"band": {
"type": "rectangle",
"position": { "x": 0, "y": 620 },
"size": { "width": 1600, "height": 4 },
"style": { "fill": "#1d2b3a" }
}
},
"automation": [
{
"target": "band.style.opacity",
"interpolation": "smooth",
"loop": { "mode": "ping-pong", "count": "infinite" },
"points": [
{ "at": "0ms", "value": 0.4 },
{ "at": "12s", "value": 0.9 }
]
}
]
},
"wall": {
"type": "repeater",
"layer": "far",
"repeat": {
"type": "component",
"component": "panel",
"inputs": {
"lit": { "choose": [{ "value": true, "weight": 1 }, { "value": false, "weight": 3 }] }
}
},
"count": 96,
"distribution": {
"type": "grid",
"origin": { "x": 120, "y": 90 },
"columns": 12,
"rows": 8,
"spacing": { "x": 34, "y": 26 }
}
},
"motes": {
"type": "particles",
"layer": "near",
"capacity": 400,
"count": 400,
"lifetime": "12s",
"fields": ["current"],
"distribution": {
"type": "rectangle",
"center": { "x": 800, "y": 450 },
"size": { "width": 1600, "height": 900 }
},
"velocity": { "x": { "random": { "min": -6, "max": 6 } }, "y": { "random": { "min": 4, "max": 18 } } },
"drag": 0.05,
"render": { "type": "point", "style": { "fill": "#cfe4ff" } },
"automation": [
{
"target": "drag",
"mode": "absolute",
"points": [
{ "at": "0ms", "value": 0.05 },
{ "at": "20s", "value": 0.25 }
]
}
]
},
"flare": {
"type": "emitter",
"layer": "near",
"lifecycle": "spawned",
"spawn": {
"lifetime": "6s",
"release": "400ms",
"ownership": "persistent",
"cancelWithScenario": false,
"inputs": {
"tint": { "type": "color", "default": "#ffcf9b" }
}
},
"emit": {
"type": "component",
"component": "panel",
"inputs": { "tint": { "ref": "inputs.tint" } }
},
"rate": 6,
"burst": [{ "at": "0s", "count": 24 }],
"capacity": 120,
"lifetime": "2s",
"distribution": { "type": "ellipse", "center": { "x": 400, "y": 300 }, "radius": 40 },
"velocity": { "x": { "random": { "min": -30, "max": 30 } }, "y": -80 }
}
},
"effects": [
{ "type": "vignette", "amount": 0.45, "radius": 0.7 },
{ "type": "bloom", "threshold": 0.7, "intensity": 0.5, "radius": 8 }
],
"automation": [
{
"target": "camera.zoom",
"mode": "scale",
"interpolation": "smooth",
"loop": { "mode": "ping-pong", "count": "infinite" },
"points": [
{ "at": "0ms", "value": 1 },
{ "at": "24s", "value": 1.35 }
]
},
{
"target": "layers.far.parallax",
"points": [
{ "at": "0ms", "value": 0.2 },
{ "at": "18s", "value": 0.35 }
]
}
]
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
export const XZBT_FORMAT_VERSION = '0.1';
export const XZBT_RUNTIME_VERSION = '0.1.0-phase3b';
export const XZBT_RUNTIME_VERSION = '0.1.0-phase4-stage0';
export const UINT32_RANGE = 0x1_0000_0000;
export const RNG_DOMAINS = Object.freeze([
'cadence',
+54 -8
View File
@@ -11,10 +11,14 @@ import {
valueMatchesType
} from './types.js';
import { ConditionEvaluator, ValueResolver } from './values.js';
import { CAMERA_FIELDS, POST_EFFECTS, matchVisualTarget, visualTargetSource } from './visual-contract.js';
import { applyAutomationMode, automationValueAt, resolveNumericStages, sampleAutomationTrack } from './audio-automation.js';
const STEP_SECONDS = 1 / 60;
const NO_STAGES = Object.freeze({ binding: true, automation: false, override: true, modulation: false });
const AUDIO_BUS_STAGES = Object.freeze({ binding: true, automation: true, override: true, modulation: true });
export class SignalProvider {
constructor() {
this.values = new Map([
@@ -155,6 +159,7 @@ export class ResolutionEngine {
this.parameters = new Map();
this.state = new Map();
this.busValues = new Map();
this.visualValues = new Map();
this.automation = new Map();
this.modulation = new Map();
this.preModulation = new Map();
@@ -182,12 +187,16 @@ export class ResolutionEngine {
target(path) {
const parts = path.split('.');
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain' && this.document.audio?.buses?.[parts[2]]) {
return { namespace: 'buses', id: parts[2], spec: { type: 'number', min: 0, max: 4 } };
return { namespace: 'buses', id: parts[2], spec: { type: 'number', min: 0, max: 4 }, stages: AUDIO_BUS_STAGES };
}
// The four visual target families of 19.1. A record with only a `reason` is
// a visual path that resolves to no capability; the caller reports it.
const visual = matchVisualTarget(this.document, path);
if (visual) return visual.reason ? null : visual;
const [namespace, id, extra] = path.split('.');
if (extra !== undefined) return null;
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id] };
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id] };
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id], stages: NO_STAGES };
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id], stages: NO_STAGES };
return null;
}
@@ -198,6 +207,12 @@ export class ResolutionEngine {
if (!this.busValues.has(target.id)) this.busValues.set(target.id, this.valueResolver.evaluate(this.document.audio.buses[target.id].gain ?? 1, this.rng.stream('sound', `bus:${target.id}`), path));
return this.busValues.get(target.id);
}
if (target.namespace.startsWith('visual-')) {
// Visual system-level bases are sampled once, lazily, from the `visual`
// stream domain — the exhibit-scope instantiation boundary of 17.14.
if (!this.visualValues.has(path)) this.visualValues.set(path, this.valueResolver.evaluate(visualTargetSource(this.document, target), this.rng.stream('visual', `target:${path}`), path));
return this.visualValues.get(path);
}
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
}
@@ -212,6 +227,7 @@ export class ResolutionEngine {
for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`);
for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`);
for (const id of Object.keys(this.document.audio?.buses ?? {})) this.resolveTarget(`audio.buses.${id}.gain`);
for (const path of this.visualTargetPaths()) this.resolveTarget(path);
for (const listener of this.listeners) listener(this);
return this.snapshot();
}
@@ -219,7 +235,13 @@ export class ResolutionEngine {
resolveTarget(path) {
if (this.resolved.has(path)) return this.resolved.get(path);
const target = this.target(path);
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path);
if (!target) {
// A visual path inside a family that names something undeclared is a
// reference error; one outside every family exposes no capability (19.1).
const visual = matchVisualTarget(this.document, path);
if (visual?.reason === 'unsupported') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `'${path}' exposes no resolution capability.`, path);
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path);
}
if (this.resolving.has(path)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Resolution cycle reached '${path}'.`, path);
this.resolving.add(path);
try {
@@ -228,7 +250,7 @@ export class ResolutionEngine {
const winner = this.overrides.select(path);
let value;
if (isNumericType(target.spec.type)) {
const track = target.namespace === 'buses' ? this.automation.get(path) : null;
const track = target.stages?.automation ? this.automation.get(path) : null;
value = resolveNumericStages(lower, {
binding: binding ? (base) => this.bindingValue(binding, target, base) : undefined,
automation: track ? (base) => applyAutomationMode(base, automationValueAt(track, this.logicalMilliseconds - track.startedAt), track.mode) : undefined,
@@ -237,7 +259,7 @@ export class ResolutionEngine {
this.preModulation.set(path, preModulation);
return preModulation;
},
modulation: target.namespace === 'buses' ? [...(this.modulation.get(path)?.values() ?? [])].reduce((sum, contribution) => sum + contribution, 0) : 0,
modulation: target.stages?.modulation ? [...(this.modulation.get(path)?.values() ?? [])].reduce((sum, contribution) => sum + contribution, 0) : 0,
min: target.spec.min, max: target.spec.max,
round: target.spec.type === 'integer' ? roundHalfAwayFromZero : undefined
});
@@ -285,8 +307,32 @@ export class ResolutionEngine {
preModulationValue(path) { this.get(path); return this.preModulation.get(path); }
/**
* Every target family the shared pipeline exposes, enumerated so bindings and
* overrides against them resolve on the same tick as everything else. The
* four visual families are system-level only (19.1); no per-object property
* appears here, and none may.
*/
visualTargetPaths() {
const visuals = this.document.visuals;
if (!visuals) return [];
const paths = [];
for (const field of Object.keys(CAMERA_FIELDS)) paths.push(`visuals.camera.${field}`);
for (const id of Object.keys(visuals.layers ?? {})) paths.push(`visuals.layers.${id}.opacity`);
for (const id of Object.keys(visuals.systems ?? {})) paths.push(`visuals.systems.${id}.visible`);
(visuals.effects ?? []).forEach((entry, index) => {
for (const parameter of Object.keys(POST_EFFECTS[entry?.type]?.numeric ?? {})) paths.push(`visuals.effects[${index}].${parameter}`);
});
return paths;
}
requireAudioStage(path) {
if (this.target(path)?.namespace !== 'buses') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Automation/modulation is not exposed for '${path}'.`, path);
this.requireStage(path, 'automation');
}
requireStage(path, stage) {
const target = this.target(path);
if (!target?.stages?.[stage]) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `The ${stage} stage is not exposed for '${path}'.`, path);
}
// Internal engine registration surface. The graph-only authoring syntax does
@@ -383,7 +429,7 @@ export class ResolutionEngine {
stateSnapshot() { return Object.fromEntries(this.state); }
snapshot() { return Object.fromEntries(this.resolved); }
dispose() {
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.preModulation.clear();
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.visualValues.clear(); this.preModulation.clear();
this.overrides.clear(); this.transitions.clear(); this.resolved.clear();
}
}
+33 -3
View File
@@ -1,4 +1,6 @@
import { validateAudioSubsystem } from './audio-graph.js';
import { matchVisualTarget } from './visual-contract.js';
import { validateVisualSubsystem } from './visual-validation.js';
import {
ALLOWED_META_FIELDS,
ALLOWED_TOP_LEVEL_FIELDS,
@@ -89,6 +91,7 @@ export function validateExhibit(document, filename = 'document.xzbt') {
validateDefinitions(document, errors);
validateBindings(document, errors);
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
return { valid: errors.length === 0, errors, warnings: [], filename };
}
@@ -219,6 +222,34 @@ function validateCondition(document, condition, path, errors) {
}
}
/**
* The exposed binding-target surface: `parameters.*`, `state.*`,
* `audio.buses.<id>.gain`, and the four visual target families 19.1 adds. A
* target inside a family that names something undeclared is
* ERR_INVALID_REFERENCE; one outside every family is ERR_UNSUPPORTED_TARGET.
* Per-object visual properties fall in the second case and stay there in 0.1.
*/
function bindingTargetType(document, target) {
const visual = matchVisualTarget(document, target);
if (visual) return visual.reason ? null : visual.spec.type;
if (typeof target !== 'string') return null;
if (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)) return referenceType(document, target);
return null;
}
function validateBindingTarget(document, target, location, errors) {
const visual = matchVisualTarget(document, target);
if (visual) {
if (visual.reason === 'reference') pushError(errors, 'ERR_INVALID_REFERENCE', location, `Binding target '${target}' does not resolve.`);
else if (visual.reason === 'unsupported') pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed. Per-object visual properties are not externally addressable in 0.1.`);
return;
}
validateReference(document, target, location, errors);
if (!(typeof target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)))) {
pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed.`);
}
}
function validateBindings(document, errors) {
if (document.bindings === undefined) return;
if (!Array.isArray(document.bindings)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.');
@@ -229,15 +260,14 @@ function validateBindings(document, errors) {
if (!isPlainObject(binding)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
for (const field of Object.keys(binding)) if (!BINDING_FIELDS.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unknown binding field '${field}'.`);
validateReference(document, binding.source, `${path}.source`, errors);
validateReference(document, binding.target, `${path}.target`, errors);
if (!(typeof binding.target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(binding.target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(binding.target)))) pushError(errors, 'ERR_UNSUPPORTED_TARGET', `${path}.target`, `Binding target '${binding.target}' is not exposed.`);
validateBindingTarget(document, binding.target, `${path}.target`, errors);
for (const field of ['scale', 'offset']) if (binding[field] !== undefined && !Number.isFinite(binding[field])) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${field}`, `${field} must be finite.`);
if (binding.clamp !== undefined && (!Array.isArray(binding.clamp) || binding.clamp.length !== 2 || binding.clamp.some((value) => !Number.isFinite(value)))) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'clamp must contain two finite numbers.');
else if (binding.clamp?.[0] > binding.clamp?.[1]) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'clamp minimum cannot exceed maximum.');
if (binding.smoothing !== undefined && (typeof binding.smoothing !== 'string' || !DURATION_PATTERN.test(binding.smoothing))) pushError(errors, 'ERR_INVALID_DURATION', `${path}.smoothing`, 'Invalid smoothing duration.');
if (binding.when !== undefined) validateCondition(document, binding.when, `${path}.when`, errors);
const sourceType = referenceType(document, binding.source);
const targetType = referenceType(document, binding.target);
const targetType = bindingTargetType(document, binding.target);
if (sourceType && targetType) {
const transformed = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms');
if (transformed && (!isNumericType(sourceType) || !isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding transforms require numeric endpoints.');
+300
View File
@@ -0,0 +1,300 @@
// Visual subsystem contract registries, sections 17-19 of the Format
// Specification at revision 0.8. This module is the single place the runtime
// reads the normative vocabularies, ceilings, and target capabilities from, in
// the same role `audio-contract.js` plays for sections 14-16.
//
// Stage 0 scope (pre-slice-4d): vocabularies, the centralized ceiling table,
// the four section 8.1 target families, and the structural surface of the
// `visuals` block. It draws nothing. Slices 4d, 4e, and 4f add the renderer,
// the procedural systems, and the automation/lifecycle/effect execution that
// these registries describe.
import { ID_PATTERN } from './constants.js';
// ---------------------------------------------------------------------------
// Vocabularies
// ---------------------------------------------------------------------------
export const COORDINATE_SPACES = Object.freeze(['normalized', 'viewport', 'virtual']);
export const FIT_MODES = Object.freeze(['contain', 'cover', 'stretch']);
/** Visual System Set 0.1 (17.7, 18.2, 18.4, 18.5). */
export const VISUAL_SYSTEM_TYPES = Object.freeze(['graphic', 'particles', 'emitter', 'repeater']);
/** Visual Primitive Set 0.1 (17.9) — closed at fourteen geometry primitives. */
export const VISUAL_PRIMITIVE_TYPES = Object.freeze([
'point', 'line', 'polyline', 'polygon', 'rectangle', 'rounded-rectangle',
'ellipse', 'arc', 'ring', 'path', 'bezier', 'spline', 'text', 'group'
]);
/** The fifteenth visual *object* type, which declares no geometry (18.1). */
export const VISUAL_OBJECT_TYPES = Object.freeze([...VISUAL_PRIMITIVE_TYPES, 'component']);
/** Visual Behavior Set 0.1 (18.6). */
export const VISUAL_BEHAVIOR_TYPES = Object.freeze([
'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander',
'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce',
'attract', 'repel', 'field-follow', 'morph'
]);
/** Procedural field set (18.7). */
export const VISUAL_FIELD_TYPES = Object.freeze([
'directional', 'radial', 'vortex', 'attractor', 'repulsor', 'noise'
]);
/** Placement distribution set (18.3). */
export const DISTRIBUTION_TYPES = Object.freeze([
'point', 'uniform', 'line', 'rectangle', 'ellipse', 'ring', 'path', 'grid', 'depth'
]);
/** Safe blend set (17.12). */
export const BLEND_MODES = Object.freeze([
'normal', 'add', 'screen', 'multiply', 'overlay', 'lighten', 'darken', 'difference'
]);
/** Object filter vocabulary (17.12). */
export const FILTER_TYPES = Object.freeze([
'brightness', 'contrast', 'saturate', 'hue-rotate', 'grayscale', 'sepia', 'invert'
]);
/** Automation curves and loop modes (19.1). */
export const AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
export const AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
export const LOOP_MODES = Object.freeze(['repeat', 'ping-pong']);
export const LIFECYCLE_MODES = Object.freeze(['persistent', 'spawned']);
/**
* Fields of the `spawn` container (19.2). Section 17.7 makes each of these
* `ERR_UNKNOWN_FIELD` at the top level of a system, which is what keeps a
* spawned instance's `lifetime` distinct from a particle's.
*/
export const SPAWN_FIELDS = Object.freeze(['lifetime', 'release', 'ownership', 'inputs', 'cancelWithScenario']);
// ---------------------------------------------------------------------------
// Camera and post-effects
// ---------------------------------------------------------------------------
/** 19.3. `projection` is authored once: not automatable, not bindable. */
export const CAMERA_FIELDS = Object.freeze({
x: { type: 'number' },
y: { type: 'number' },
zoom: { type: 'number', min: 0.01, max: 100, default: 1 },
rotation: { type: 'number', default: 0 },
focalLength: { type: 'number', min: 1, max: 100_000, default: 1000 }
});
export const CAMERA_PROJECTIONS = Object.freeze(['orthographic', 'perspective']);
/**
* 19.4, in normative array order. `numeric` carries each parameter's range and
* default; `colors` are authored literals and take no ValueSpec. `passes` is
* the frame budget cost: blur and bloom read back the frame and cost two.
*/
export const POST_EFFECTS = Object.freeze({
vignette: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.5 }, radius: { min: 0, max: 1, default: 0.75 }, softness: { min: 0, max: 1, default: 0.5 } },
colors: { color: '#000000' }
},
scanlines: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.3 }, spacing: { min: 1, max: 64, default: 3 }, thickness: { min: 0, max: 1, default: 0.5 }, speed: { default: 0 } },
colors: {}
},
grain: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.15 }, scale: { min: 0.25, max: 16, default: 1 }, speed: { min: 0, max: 60, default: 24 } },
colors: {}
},
'color-adjust': {
passes: 1,
numeric: { brightness: { min: 0, max: 4, default: 1 }, contrast: { min: 0, max: 4, default: 1 }, saturation: { min: 0, max: 4, default: 1 }, hueRotate: { default: 0 } },
colors: {}
},
blur: { passes: 2, numeric: { radius: { min: 0, max: 32, default: 4 } }, colors: {} },
bloom: {
passes: 2,
numeric: { threshold: { min: 0, max: 1, default: 0.7 }, intensity: { min: 0, max: 2, default: 0.6 }, radius: { min: 0, max: 32, default: 8 } },
colors: {}
},
fade: { passes: 1, numeric: { amount: { min: 0, max: 1, default: 0 } }, colors: { color: '#000000' } }
});
export const POST_EFFECT_TYPES = Object.freeze(Object.keys(POST_EFFECTS));
/**
* `color-adjust`'s authored parameter names map onto the 17.12 filter
* operations without renaming the authored fields (19.4).
*/
export const EFFECT_FILTER_OPERATIONS = Object.freeze({
brightness: 'brightness', contrast: 'contrast', saturation: 'saturate', hueRotate: 'hue-rotate'
});
// ---------------------------------------------------------------------------
// The centralized ceiling table (19.5)
// ---------------------------------------------------------------------------
/**
* Authoring bounds reject an exhibit at import; runtime ceilings shed work by a
* documented deterministic rule and keep the exhibit running. Every aggregate
* value here is provisional until the slice 4h GC6 measurement runs its
* shape is normative now, its number is not measured.
*/
export const VISUAL_LIMITS = Object.freeze({
authoring: Object.freeze({
layers: 16,
declaredSystems: 64,
expandedStaticObjects: 16_384,
groupNesting: 8,
componentNesting: 8,
polygonVertices: 512,
pathCommands: 512,
splinePoints: 256,
gradientStops: 16,
strokeDashEntries: 8,
filtersPerObject: 4,
behaviorsPerObject: 8,
declaredFields: 8,
referencedFieldsPerSystem: 4,
particleCapacity: 4096,
emitterCapacity: 512,
repeaterCount: 1024,
burstEntries: 16,
gridDimension: 256,
trailLength: 128,
maxLinks: 1024,
nearestLinksPerItem: 8,
pairwiseLinkedPopulation: 256,
textCharacters: 256,
postEffectEntries: 4,
effectRadius: 32,
automationTracks: 128,
automationPoints: 2048,
automationPointsPerTrack: 256
}),
runtime: Object.freeze({
liveParticles: 8192,
liveEmittedItems: 2048,
liveSpawnedInstances: 64,
linkSegmentsPerTick: 4096,
trailHistorySamples: 32_768,
fieldEvaluationsPerTick: 32_768,
offscreenBuffersPerFrame: 16,
postEffectPassesPerFrame: 8,
devicePixelRatioCeiling: 2,
backingStoreEdge: 4096,
liveAutomationTracks: 128,
liveAutomationPoints: 2048,
sustainedShedTicks: 120
})
});
/** 19.5: one diagnostic per (code, subject) key per logical second. */
export const DIAGNOSTIC_CADENCE_MS = 1000;
// ---------------------------------------------------------------------------
// The four section 8.1 visual target families (19.1)
// ---------------------------------------------------------------------------
const EFFECT_INDEX_PATTERN = /^visuals\.effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
/**
* Resolve a target path against the four visual target families. Returns a
* capability record, or `null` when the path is not a visual target at all.
*
* A path inside a family that names something undeclared returns
* `{ reason: 'reference' }` so the caller can raise ERR_INVALID_REFERENCE
* rather than ERR_UNSUPPORTED_TARGET; a path that looks visual but is outside
* every family returns `{ reason: 'unsupported' }`. That distinction is what
* trace 4 of 19.7 and trace 14 of 17.16 both check.
*/
export function matchVisualTarget(document, path) {
if (typeof path !== 'string' || !path.startsWith('visuals.')) return null;
const visuals = document?.visuals;
const effect = EFFECT_INDEX_PATTERN.exec(path);
if (effect) {
const index = Number(effect[1]);
const entry = Array.isArray(visuals?.effects) ? visuals.effects[index] : undefined;
if (!entry) return { reason: 'reference' };
const definition = POST_EFFECTS[entry.type];
const parameter = definition?.numeric?.[effect[2]];
if (!parameter) return { reason: 'unsupported' };
return {
family: 'visuals.effects', namespace: 'visual-effect', id: `${index}.${effect[2]}`,
index, parameter: effect[2],
spec: { type: 'number', min: parameter.min, max: parameter.max },
default: parameter.default,
stages: { binding: true, automation: true, override: true, modulation: false }
};
}
const parts = path.split('.');
if (parts.length === 3 && parts[1] === 'camera') {
const field = CAMERA_FIELDS[parts[2]];
if (!field) return { reason: 'unsupported' };
return {
family: 'visuals.camera', namespace: 'visual-camera', id: parts[2],
spec: { type: 'number', min: field.min, max: field.max },
default: field.default,
stages: { binding: true, automation: true, override: true, modulation: true }
};
}
if (parts.length === 4 && parts[1] === 'layers') {
if (!ID_PATTERN.test(parts[2]) || !visuals?.layers?.[parts[2]]) return { reason: 'reference' };
if (parts[3] !== 'opacity') return { reason: 'unsupported' };
return {
family: 'visuals.layers', namespace: 'visual-layer', id: parts[2],
spec: { type: 'number', min: 0, max: 1 }, default: 1,
stages: { binding: true, automation: true, override: true, modulation: false }
};
}
if (parts.length === 4 && parts[1] === 'systems') {
if (!ID_PATTERN.test(parts[2]) || !visuals?.systems?.[parts[2]]) return { reason: 'reference' };
if (parts[3] !== 'visible') return { reason: 'unsupported' };
return {
family: 'visuals.systems', namespace: 'visual-system', id: parts[2],
spec: { type: 'boolean' }, default: true,
// Boolean targets take no automation and no modulation stage: those
// stages are absent, not identity hooks (8.1).
stages: { binding: true, automation: false, override: true, modulation: false }
};
}
return { reason: 'unsupported' };
}
/** The authored ValueSpec behind a visual target, or its documented default. */
export function visualTargetSource(document, target) {
const visuals = document?.visuals ?? {};
switch (target.namespace) {
case 'visual-camera': return visuals.camera?.[target.id] ?? cameraDefault(visuals, target.id);
case 'visual-layer': return visuals.layers?.[target.id]?.opacity ?? 1;
case 'visual-system': return visuals.systems?.[target.id]?.visible ?? true;
case 'visual-effect': return visuals.effects?.[target.index]?.[target.parameter] ?? target.default;
default: return undefined;
}
}
/**
* 19.3: the camera default is the scene center, so an exhibit that never
* mentions the camera looks identical to one that declares its defaults.
*/
function cameraDefault(visuals, field) {
if (field !== 'x' && field !== 'y') return CAMERA_FIELDS[field].default;
const scene = visuals.scene ?? {};
if (scene.coordinateSpace === 'normalized') return 0.5;
// In `viewport` space the scene rectangle *is* the display rectangle, whose
// size is not known at import. The resolved base is 0 here and slice 4d
// substitutes the live display center for an unauthored camera position.
const extent = field === 'x' ? scene.width : scene.height;
return Number.isFinite(extent) ? extent / 2 : 0;
}
/** Frame pass cost of an authored effect chain (19.4). */
export function postEffectPassCount(effects) {
if (!Array.isArray(effects)) return 0;
return effects.reduce((total, entry) => total + (POST_EFFECTS[entry?.type]?.passes ?? 0), 0);
}
+504
View File
@@ -0,0 +1,504 @@
// Structural and semantic validation of the `visuals` block, sections 17-19 at
// Format Specification revision 0.8.
//
// Stage 0 scope (pre-slice-4d). This module validates the *shared* surface that
// the schema, the target resolver, and every later visual slice all depend on:
// the `visuals` container and its allowed fields, the scene model, layers, the
// common system fields and the `spawn` container, procedural fields, the
// post-effect chain, and visual automation with its two declaration scopes and
// its automatable registry. Type-specific system fields — a particle system's
// emission block, an emitter's bursts, a repeater's distribution — are carried
// through unvalidated and are tightened by slices 4d, 4e, and 4f, which is
// where the runtime that reads them lands.
import { ID_PATTERN } from './constants.js';
import {
AUTOMATION_CURVES,
AUTOMATION_MODES,
BLEND_MODES,
CAMERA_FIELDS,
CAMERA_PROJECTIONS,
COORDINATE_SPACES,
FIT_MODES,
LIFECYCLE_MODES,
LOOP_MODES,
POST_EFFECTS,
SPAWN_FIELDS,
VISUAL_FIELD_TYPES,
VISUAL_LIMITS,
VISUAL_SYSTEM_TYPES
} from './visual-contract.js';
import { DURATION_PATTERN } from './types.js';
const AUTHORING = VISUAL_LIMITS.authoring;
const VISUALS_FIELDS = new Set(['scene', 'layers', 'systems', 'fields', 'camera', 'effects', 'automation']);
const SCENE_FIELDS = new Set(['coordinateSpace', 'width', 'height', 'fit', 'background', 'depthFog']);
const DEPTH_FOG_FIELDS = new Set(['color', 'near', 'far', 'density']);
const LAYER_FIELDS = new Set(['opacity', 'blend', 'visible', 'parallax']);
const CAMERA_ALLOWED = new Set([...Object.keys(CAMERA_FIELDS), 'projection']);
const COMMON_SYSTEM_FIELDS = new Set(['type', 'layer', 'visible', 'lifecycle', 'automation', 'spawn']);
const SPAWN_ONLY_AT_TOP_LEVEL = new Set(['release', 'ownership', 'inputs', 'cancelWithScenario']);
/** 17.7: `lifetime` at a system's top level is the per-item duration, and only these two types have one. */
const TYPES_WITH_ITEM_LIFETIME = new Set(['particles', 'emitter']);
const TRACK_FIELDS = new Set(['target', 'mode', 'interpolation', 'loop', 'points']);
const FIELD_COMMON = new Set(['type', 'bounds', 'enabled']);
const FIELD_TYPE_FIELDS = Object.freeze({
directional: ['direction', 'strength'],
radial: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
vortex: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
attractor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
repulsor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
noise: ['scale', 'speed', 'octaves', 'persistence', 'amplitude', 'mode', 'direction', 'center', 'size']
});
/** Numeric object properties a `graphic` system's automation may address (19.1). */
const GRAPHIC_NUMERIC_PROPERTIES = new Set([
'position.x', 'position.y', 'z',
'transform.translate.x', 'transform.translate.y', 'transform.translate.z',
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
'transform.skew.x', 'transform.skew.y', 'transform.origin.x', 'transform.origin.y',
'style.opacity', 'style.strokeWidth', 'style.pointSize', 'style.strokeDashOffset', 'style.blur',
'size.width', 'size.height', 'radius', 'innerRadius', 'startAngle', 'endAngle', 'tension'
]);
const POINT_PROPERTY = /^points\[\d+\]\.[xyz]$/;
/** 19.1: the automatable properties of a procedural system. A repeater has none. */
const SYSTEM_AUTOMATABLE = Object.freeze({
particles: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
emitter: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
repeater: new Set()
});
const EFFECT_INDEX = /^effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isColor(value) {
return typeof value === 'string' && value.length > 0;
}
export function validateVisualSubsystem(document, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, path, message) => pushError(errors, code, path, message);
const visuals = document.visuals;
if (visuals === undefined) return;
if (!isPlainObject(visuals)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals', 'visuals must be an object.');
for (const field of Object.keys(visuals)) {
if (!VISUALS_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.${field}`, `Unrecognized visuals field '${field}'.`);
}
const scene = validateScene(document, visuals.scene, errors, helpers);
const layers = validateLayers(document, visuals.layers, errors, helpers);
validateCamera(document, visuals.camera, errors, helpers);
const effects = validateEffects(document, visuals.effects, errors, helpers);
const fields = validateFields(document, visuals.fields, errors, helpers);
const systems = validateSystems(document, visuals.systems, layers, fields, errors, helpers);
const context = { scene, layers, effects, systems };
let tracks = 0;
let points = 0;
const count = (result) => { tracks += result.tracks; points += result.points; };
count(validateAutomationArray(document, visuals.automation, '$.visuals.automation', { scope: 'exhibit', context }, errors, helpers));
for (const [id, system] of Object.entries(isPlainObject(visuals.systems) ? visuals.systems : {})) {
if (!isPlainObject(system)) continue;
count(validateAutomationArray(document, system.automation, `$.visuals.systems.${id}.automation`, { scope: 'system', systemId: id, system, context }, errors, helpers));
}
// 19.1: the authoring bound counts declared records once per declaration.
// The live budget of the same numbers is a runtime check at spawn time.
if (tracks > AUTHORING.automationTracks) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation tracks (${tracks}) exceed the ${AUTHORING.automationTracks} authoring bound.`);
if (points > AUTHORING.automationPoints) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation points (${points}) exceed the ${AUTHORING.automationPoints} authoring bound.`);
}
// ---------------------------------------------------------------------------
function validateScene(document, scene, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (scene === undefined) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'visuals.scene is required when visuals is present.');
return {};
}
if (!isPlainObject(scene)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'scene must be an object.');
return {};
}
for (const field of Object.keys(scene)) {
if (!SCENE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${field}`, `Unrecognized scene field '${field}'.`);
}
const space = scene.coordinateSpace ?? 'virtual';
if (!COORDINATE_SPACES.includes(space)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.coordinateSpace', `Unsupported coordinate space '${scene.coordinateSpace}'.`);
const virtual = space === 'virtual';
for (const axis of ['width', 'height']) {
const value = scene[axis];
if (!virtual && value !== undefined) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${axis}`, `${axis} is only declared in the 'virtual' coordinate space.`);
else if (virtual && value === undefined) fail('ERR_SCHEMA_VALIDATION', `$.visuals.scene.${axis}`, `${axis} is required in the 'virtual' coordinate space.`);
else if (virtual && (!Number.isFinite(value) || value < 1 || value > 16_384)) fail('ERR_OUT_OF_BOUNDS', `$.visuals.scene.${axis}`, `${axis} must be between 1 and 16384.`);
}
// 17.4 (V17): in `viewport` an absent `fit` is accepted and takes no default;
// only an explicitly authored non-`stretch` value is rejected.
if (scene.fit !== undefined) {
if (!FIT_MODES.includes(scene.fit)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', `Unsupported fit '${scene.fit}'.`);
else if (space === 'viewport' && scene.fit !== 'stretch') fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', "An explicit fit other than 'stretch' has no meaning in the 'viewport' coordinate space.");
}
if (scene.background !== undefined && !isColor(scene.background)) fail('ERR_TYPE_MISMATCH', '$.visuals.scene.background', 'background must be a color.');
if (scene.depthFog !== undefined) {
const fog = scene.depthFog;
if (!isPlainObject(fog)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog', 'depthFog must be an object.');
else {
for (const field of Object.keys(fog)) if (!DEPTH_FOG_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.depthFog.${field}`, `Unrecognized depthFog field '${field}'.`);
if (!isColor(fog.color)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.color', 'depthFog.color is required.');
if (!Number.isFinite(fog.far)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.far', 'depthFog.far is required.');
else if (Number.isFinite(fog.near ?? 0) && fog.far <= (fog.near ?? 0)) fail('ERR_INVALID_RANGE_ORDER', '$.visuals.scene.depthFog.far', 'depthFog.far must exceed depthFog.near.');
if (fog.density !== undefined && (!Number.isFinite(fog.density) || fog.density < 0 || fog.density > 1)) fail('ERR_OUT_OF_BOUNDS', '$.visuals.scene.depthFog.density', 'depthFog.density must be between 0 and 1.');
}
}
return scene;
}
function validateLayers(document, layers, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (layers === undefined) return null;
if (!isPlainObject(layers)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'layers must be an object.');
return null;
}
const ids = Object.keys(layers);
// 17.5 (V17): a present-but-empty layer map is rejected; an exhibit that
// wants the implicit layer omits the key entirely.
if (ids.length === 0) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'An empty layer map is not a layer set; omit the key for the implicit layer.');
return null;
}
if (ids.length > AUTHORING.layers) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.layers', `At most ${AUTHORING.layers} layers.`);
for (const [id, layer] of Object.entries(layers)) {
const path = `$.visuals.layers.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Layer ID '${id}' is invalid.`);
if (!isPlainObject(layer)) { fail('ERR_SCHEMA_VALIDATION', path, 'Layer must be an object.'); continue; }
for (const field of Object.keys(layer)) if (!LAYER_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized layer field '${field}'.`);
if (layer.opacity !== undefined) validateValueSpec(document, layer.opacity, `${path}.opacity`, errors);
if (layer.visible !== undefined) validateValueSpec(document, layer.visible, `${path}.visible`, errors);
if (layer.blend !== undefined && !BLEND_MODES.includes(layer.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.blend`, `Unsupported blend mode '${layer.blend}'.`);
if (layer.parallax !== undefined && !Number.isFinite(layer.parallax)) fail('ERR_TYPE_MISMATCH', `${path}.parallax`, 'parallax must be a finite number.');
}
return new Set(ids);
}
function validateCamera(document, camera, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (camera === undefined) return;
if (!isPlainObject(camera)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera', 'camera must be an object.');
for (const field of Object.keys(camera)) {
if (!CAMERA_ALLOWED.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.camera.${field}`, `Unrecognized camera field '${field}'.`);
}
if (camera.projection !== undefined && !CAMERA_PROJECTIONS.includes(camera.projection)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera.projection', `Unsupported projection '${camera.projection}'.`);
}
for (const [field, spec] of Object.entries(CAMERA_FIELDS)) {
const value = camera[field];
if (value === undefined) continue;
validateValueSpec(document, value, `$.visuals.camera.${field}`, errors);
// 19.3 (V20): a *literal* outside the closed range is rejected at import;
// a value that resolves outside it is clamped by the 8.1 safety stage.
if (typeof value === 'number' && ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max))) {
fail('ERR_OUT_OF_BOUNDS', `$.visuals.camera.${field}`, `camera.${field} must be between ${spec.min} and ${spec.max}.`);
}
}
}
function validateEffects(document, effects, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (effects === undefined) return [];
if (!Array.isArray(effects)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.effects', 'effects must be an array.');
return [];
}
if (effects.length > AUTHORING.postEffectEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.effects', `At most ${AUTHORING.postEffectEntries} post-effect entries.`);
effects.forEach((entry, index) => {
const path = `$.visuals.effects[${index}]`;
if (!isPlainObject(entry)) return fail('ERR_SCHEMA_VALIDATION', path, 'Effect entry must be an object.');
const definition = POST_EFFECTS[entry.type];
if (!definition) return fail('ERR_INVALID_EFFECT_TYPE', `${path}.type`, `Unsupported post-effect type '${entry.type}'.`);
for (const field of Object.keys(entry)) {
if (field === 'type' || field === 'enabled') continue;
if (!Object.hasOwn(definition.numeric, field) && !Object.hasOwn(definition.colors, field)) {
fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Effect '${entry.type}' declares no parameter '${field}'.`);
}
}
if (entry.enabled !== undefined) validateValueSpec(document, entry.enabled, `${path}.enabled`, errors);
for (const [parameter, range] of Object.entries(definition.numeric)) {
const value = entry[parameter];
if (value === undefined) continue;
validateValueSpec(document, value, `${path}.${parameter}`, errors);
if (typeof value === 'number' && ((range.min !== undefined && value < range.min) || (range.max !== undefined && value > range.max))) {
fail('ERR_OUT_OF_BOUNDS', `${path}.${parameter}`, `${parameter} must be between ${range.min} and ${range.max}.`);
}
}
// 19.4: `color` parameters and `type` are authored once, never ValueSpecs.
for (const parameter of Object.keys(definition.colors)) {
if (entry[parameter] !== undefined && !isColor(entry[parameter])) fail('ERR_TYPE_MISMATCH', `${path}.${parameter}`, `${parameter} must be an authored color literal.`);
}
});
return effects;
}
function validateFields(document, fields, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (fields === undefined) return new Set();
if (!isPlainObject(fields)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.fields', 'fields must be an object.');
return new Set();
}
const ids = Object.keys(fields);
if (ids.length > AUTHORING.declaredFields) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.fields', `At most ${AUTHORING.declaredFields} declared fields.`);
for (const [id, field] of Object.entries(fields)) {
const path = `$.visuals.fields.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Field ID '${id}' is invalid.`);
if (!isPlainObject(field)) { fail('ERR_SCHEMA_VALIDATION', path, 'Field must be an object.'); continue; }
if (!VISUAL_FIELD_TYPES.includes(field.type)) { fail('ERR_INVALID_FIELD_TYPE', `${path}.type`, `Unsupported field type '${field.type}'.`); continue; }
const allowed = new Set([...FIELD_COMMON, ...FIELD_TYPE_FIELDS[field.type]]);
for (const key of Object.keys(field)) if (!allowed.has(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Field type '${field.type}' declares no '${key}'.`);
if (field.enabled !== undefined) validateValueSpec(document, field.enabled, `${path}.enabled`, errors);
if (field.type === 'noise') {
const mode = field.mode ?? 'curl';
if (!['curl', 'gradient', 'value'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported noise mode '${field.mode}'.`);
// 18.7 (A2): `direction` is required under `value` and unknown otherwise.
if (mode === 'value' && field.direction === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, "A 'value' noise field requires a direction.");
if (mode !== 'value' && field.direction !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.direction`, `direction is not declared under noise mode '${mode}'.`);
if (field.octaves !== undefined && (!Number.isInteger(field.octaves) || field.octaves < 1 || field.octaves > 4)) fail('ERR_OUT_OF_BOUNDS', `${path}.octaves`, 'octaves must be an integer from 1 to 4.');
if (field.persistence !== undefined && (!Number.isFinite(field.persistence) || field.persistence < 0 || field.persistence > 1)) fail('ERR_OUT_OF_BOUNDS', `${path}.persistence`, 'persistence must be between 0 and 1.');
if (field.scale !== undefined && (!Number.isFinite(field.scale) || field.scale <= 0)) fail('ERR_OUT_OF_BOUNDS', `${path}.scale`, 'scale must be above 0.');
}
}
return new Set(ids);
}
function validateSystems(document, systems, layers, fields, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (systems === undefined) return {};
if (!isPlainObject(systems)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.systems', 'systems must be an object.');
return {};
}
const ids = Object.keys(systems);
if (ids.length > AUTHORING.declaredSystems) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.systems', `At most ${AUTHORING.declaredSystems} declared visual systems.`);
for (const [id, system] of Object.entries(systems)) {
const path = `$.visuals.systems.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `System ID '${id}' is invalid.`);
if (!isPlainObject(system)) { fail('ERR_SCHEMA_VALIDATION', path, 'System must be an object.'); continue; }
if (!VISUAL_SYSTEM_TYPES.includes(system.type)) fail('ERR_INVALID_SYSTEM_TYPE', `${path}.type`, `Unsupported visual system type '${system.type}'.`);
// 17.7 (V17): the presence of the layer map decides whether `layer` is required.
if (layers) {
if (system.layer === undefined) fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'A system must name a declared layer when visuals.layers is present.');
else if (!layers.has(system.layer)) fail('ERR_INVALID_REFERENCE', `${path}.layer`, `Layer '${system.layer}' is not declared.`);
} else if (system.layer !== undefined) {
fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'No layers are declared, so there is no layer to name.');
}
if (system.visible !== undefined) validateValueSpec(document, system.visible, `${path}.visible`, errors);
const lifecycle = system.lifecycle ?? 'persistent';
if (!LIFECYCLE_MODES.includes(lifecycle)) fail('ERR_SCHEMA_VALIDATION', `${path}.lifecycle`, `Unsupported lifecycle '${system.lifecycle}'.`);
// 17.7 (A1): the four spawn-only names never appear at a system's top
// level; `lifetime` does, but only where the type's own table declares it
// as the per-item duration.
for (const field of Object.keys(system)) {
if (SPAWN_ONLY_AT_TOP_LEVEL.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `'${field}' is a field of the spawn container, not of the system.`);
}
if (system.lifetime !== undefined && !TYPES_WITH_ITEM_LIFETIME.has(system.type)) {
fail('ERR_UNKNOWN_FIELD', `${path}.lifetime`, `A '${system.type}' system declares no lifetime; a spawned instance's duration is spawn.lifetime.`);
}
validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError });
if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.');
if (Array.isArray(system.fields)) {
if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`);
system.fields.forEach((reference, index) => {
if (!fields.has(reference)) fail('ERR_INVALID_REFERENCE', `${path}.fields[${index}]`, `Field '${reference}' is not declared.`);
});
}
}
return systems;
}
function validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError }) {
const fail = (code, location, message) => pushError(errors, code, location, message);
const spawn = system.spawn;
if (spawn === undefined) return;
if (lifecycle !== 'spawned') return fail('ERR_UNKNOWN_FIELD', `${path}.spawn`, 'A persistent system has no spawn container.');
if (!isPlainObject(spawn)) return fail('ERR_SCHEMA_VALIDATION', `${path}.spawn`, 'spawn must be an object.');
for (const field of Object.keys(spawn)) {
if (!SPAWN_FIELDS.includes(field)) fail('ERR_UNKNOWN_FIELD', `${path}.spawn.${field}`, `Unrecognized spawn field '${field}'.`);
}
for (const field of ['lifetime', 'release']) {
const value = spawn[field];
if (value !== undefined && (typeof value !== 'string' || !DURATION_PATTERN.test(value))) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal.`);
}
if (spawn.ownership !== undefined && spawn.ownership !== 'persistent') fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.ownership`, "spawn.ownership accepts only 'persistent'.");
if (spawn.cancelWithScenario !== undefined) {
if (typeof spawn.cancelWithScenario !== 'boolean') fail('ERR_TYPE_MISMATCH', `${path}.spawn.cancelWithScenario`, 'spawn.cancelWithScenario must be a boolean.');
// 19.2 (V23): severing the originating-scenario relationship requires that
// the instance's resources belong to the performance root.
else if (spawn.cancelWithScenario === false && spawn.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', `${path}.spawn.cancelWithScenario`, "cancelWithScenario: false requires ownership: 'persistent'.");
}
if (spawn.inputs !== undefined && !isPlainObject(spawn.inputs)) fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.inputs`, 'spawn.inputs must be an object.');
}
// ---------------------------------------------------------------------------
// Visual automation (19.1)
// ---------------------------------------------------------------------------
function validateAutomationArray(document, tracks, path, scope, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, location, message) => pushError(errors, code, location, message);
if (tracks === undefined) return { tracks: 0, points: 0 };
if (!Array.isArray(tracks)) {
fail('ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
return { tracks: 0, points: 0 };
}
let points = 0;
const written = new Set();
tracks.forEach((track, index) => {
const location = `${path}[${index}]`;
if (!isPlainObject(track)) return fail('ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
for (const field of Object.keys(track)) if (!TRACK_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${location}.${field}`, `Unrecognized automation field '${field}'.`);
if (track.mode !== undefined && !AUTOMATION_MODES.includes(track.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.mode`, `Unsupported automation mode '${track.mode}'.`);
if (track.interpolation !== undefined && !AUTOMATION_CURVES.includes(track.interpolation)) fail('ERR_SCHEMA_VALIDATION', `${location}.interpolation`, `Unsupported interpolation '${track.interpolation}'.`);
if (track.loop !== undefined) {
const loop = track.loop;
if (!isPlainObject(loop)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop`, 'loop must be an object.');
else {
for (const field of Object.keys(loop)) if (field !== 'mode' && field !== 'count') fail('ERR_UNKNOWN_FIELD', `${location}.loop.${field}`, `Unrecognized loop field '${field}'.`);
if (!LOOP_MODES.includes(loop.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop.mode`, `Unsupported loop mode '${loop.mode}'.`);
// 19.1 (V12): `infinite` is legal in every scope, bounded by whatever
// owns the track, so no scope check belongs here.
if (loop.count !== undefined && loop.count !== 'infinite' && (!Number.isInteger(loop.count) || loop.count < 1)) {
fail('ERR_OUT_OF_BOUNDS', `${location}.loop.count`, "loop.count must be 'infinite' or an integer of at least 1.");
}
}
}
if (!Array.isArray(track.points) || track.points.length < 2) fail('ERR_SCHEMA_VALIDATION', `${location}.points`, 'A track requires at least 2 points.');
else {
if (track.points.length > AUTHORING.automationPointsPerTrack) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${location}.points`, `At most ${AUTHORING.automationPointsPerTrack} points per track.`);
points += track.points.length;
let previous = -Infinity;
track.points.forEach((point, pointIndex) => {
const pointPath = `${location}.points[${pointIndex}]`;
if (!isPlainObject(point)) return fail('ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
for (const field of Object.keys(point)) if (field !== 'at' && field !== 'value') fail('ERR_UNKNOWN_FIELD', `${pointPath}.${field}`, `Unrecognized point field '${field}'.`);
// 19.1: `at` is a duration literal only, never a procedural TimeSpec,
// so the strictly-increasing rule stays decidable at import.
if (typeof point.at !== 'string' || !DURATION_PATTERN.test(point.at)) fail('ERR_INVALID_DURATION', `${pointPath}.at`, 'Point at must be a duration literal.');
else {
const milliseconds = durationMilliseconds(point.at);
if (milliseconds <= previous) fail('ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation point times must be strictly increasing.');
previous = milliseconds;
}
if (!Object.hasOwn(point, 'value')) fail('ERR_SCHEMA_VALIDATION', `${pointPath}.value`, 'Automation point requires a value.');
else validateValueSpec(document, point.value, `${pointPath}.value`, errors);
});
}
const resolution = resolveAutomationTarget(track.target, scope);
if (resolution.code) fail(resolution.code, `${location}.target`, resolution.message);
else if (written.has(resolution.key)) fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, `More than one track controls '${track.target}'.`);
else written.add(resolution.key);
});
return { tracks: tracks.length, points };
}
function durationMilliseconds(value) {
const [, scalar, unit] = DURATION_PATTERN.exec(value);
return Number(scalar) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
}
/**
* 19.1: two declaration scopes, and a track in one naming a target in the other
* is ERR_INVALID_REFERENCE. A target that exists but exposes no automation
* stage a repeater property, a boolean, a color is ERR_UNSUPPORTED_TARGET.
*/
function resolveAutomationTarget(target, scope) {
if (typeof target !== 'string' || target.length === 0) {
return { code: 'ERR_SCHEMA_VALIDATION', message: 'A track requires a target.' };
}
const context = scope.context;
const head = target.split('.')[0].replace(/\[\d+\]$/, '');
if (scope.scope === 'exhibit') {
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'Exhibit-scope automation never reaches into a system; declare the track on the system.' };
if (head === 'scene') {
if (!['scene.depthFog.near', 'scene.depthFog.far', 'scene.depthFog.density'].includes(target)) {
return { code: 'ERR_UNSUPPORTED_TARGET', message: `Scene property '${target}' is not automatable.` };
}
return { key: target };
}
if (head === 'layers') {
const parts = target.split('.');
if (parts.length !== 3 || !context.layers?.has(parts[1])) return { code: 'ERR_INVALID_REFERENCE', message: `Layer '${parts[1]}' is not declared.` };
if (parts[2] !== 'opacity' && parts[2] !== 'parallax') return { code: 'ERR_UNSUPPORTED_TARGET', message: `Layer property '${parts[2]}' is not automatable.` };
return { key: target };
}
if (head === 'camera') {
const parts = target.split('.');
// 19.3: `projection` is authored once — not automatable, not bindable.
if (parts.length !== 2 || !Object.hasOwn(CAMERA_FIELDS, parts[1])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Camera property '${target}' is not automatable.` };
return { key: target };
}
if (head === 'effects') {
const match = EFFECT_INDEX.exec(target);
if (!match) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect target '${target}' is not automatable.` };
const entry = context.effects?.[Number(match[1])];
if (!entry) return { code: 'ERR_INVALID_REFERENCE', message: `Effect index ${match[1]} is outside the authored chain.` };
if (!Object.hasOwn(POST_EFFECTS[entry.type]?.numeric ?? {}, match[2])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect '${entry.type}' has no numeric parameter '${match[2]}'.` };
return { key: target };
}
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not in the automatable registry.` };
}
// System scope. Targets are relative to the owning system.
if (['scene', 'layers', 'camera', 'effects'].includes(head)) {
return { code: 'ERR_INVALID_REFERENCE', message: 'System-scope automation never reaches exhibit-scope properties.' };
}
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'No track may address another system.' };
const type = scope.system?.type;
if (type === 'graphic') return resolveGraphicTarget(target, scope.system);
const automatable = SYSTEM_AUTOMATABLE[type];
if (!automatable) return { code: 'ERR_UNSUPPORTED_TARGET', message: `A '${type}' system exposes no automatable property.` };
// 19.1 (V11): a repeater has no automatable property at all, `step` included.
if (!automatable.has(target)) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not automatable on a '${type}' system.` };
return { key: `${scope.systemId}.${target}` };
}
function resolveGraphicTarget(target, system) {
const parts = target.split('.');
let container = system?.content;
let index = 0;
while (index < parts.length) {
if (!isPlainObject(container) || !Object.hasOwn(container, parts[index])) {
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
}
const object = container[parts[index]];
index += 1;
const remainder = parts.slice(index).join('.');
if (remainder.length === 0) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' names an object, not a numeric property of one.` };
if (GRAPHIC_NUMERIC_PROPERTIES.has(remainder) || POINT_PROPERTY.test(remainder)) return { key: target };
// Not a property tail, so the next segment must be a container key.
if (isPlainObject(object) && (object.type === 'group' || object.type === 'component') && isPlainObject(object.children)) {
container = object.children;
continue;
}
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${remainder}' is not a numeric object property.` };
}
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
}
+354
View File
@@ -0,0 +1,354 @@
// Phase 4 Stage 0 — shared pre-renderer alignment.
//
// Covers the parts of sections 17-19 (revision 0.8) that the schema, the
// validator, and the shared resolution pipeline must agree on before slice 4d
// writes any renderer code: the `visuals` container surface, the scene and
// layer rules, the `spawn` lifecycle container, procedural fields, the
// post-effect chain, visual automation and its two scopes, and the four
// section 8.1 visual target families.
//
// It draws nothing. Traces of 17.16, 18.10, and 19.7 that require a renderer
// belong to slices 4d, 4e, and 4f and are not claimed here.
import assert from 'node:assert/strict';
import test from 'node:test';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { RuntimeFault } from '../src/runtime/types.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { POST_EFFECTS, VISUAL_LIMITS, matchVisualTarget, postEffectPassCount } from '../src/runtime/visual-contract.js';
function document(visuals, overrides = {}) {
return {
xzbt: '0.1',
meta: { id: 'visual-study', name: 'Visual Study' },
runtime: { seed: 42 },
state: { energy: { type: 'number', initial: 0.5, min: 0, max: 1 } },
visuals,
...overrides
};
}
const scene = { coordinateSpace: 'virtual', width: 1600, height: 900 };
const graphic = (extra = {}) => ({ type: 'graphic', content: { band: { type: 'rectangle', size: { width: 10, height: 2 } } }, ...extra });
function codes(doc) {
return validateExhibit(doc).errors.map((error) => error.code);
}
function expectClean(doc, label) {
const result = validateExhibit(doc);
assert.deepEqual(result.errors, [], `${label} should validate: ${JSON.stringify(result.errors)}`);
}
// ---------------------------------------------------------------------------
// 17.3-17.5 — the container, the scene, and layers
// ---------------------------------------------------------------------------
test('the visuals container accepts exactly its seven declared fields (17.3, V1)', () => {
expectClean(document({ scene, automation: [] }), 'exhibit-scope automation');
assert.ok(codes(document({ scene, passes: [] })).includes('ERR_UNKNOWN_FIELD'));
assert.ok(codes(document({})).includes('ERR_SCHEMA_VALIDATION'), 'scene is required when visuals is present');
});
test('virtual space requires width and height, and no other space accepts them (17.4)', () => {
assert.ok(codes(document({ scene: { coordinateSpace: 'virtual', width: 1600 } })).includes('ERR_SCHEMA_VALIDATION'));
assert.ok(codes(document({ scene: { coordinateSpace: 'normalized', width: 1 } })).includes('ERR_UNKNOWN_FIELD'));
expectClean(document({ scene: { coordinateSpace: 'normalized' } }), 'normalized scene');
});
test('viewport accepts an absent fit and rejects only an explicit non-stretch one (17.4, V17)', () => {
expectClean(document({ scene: { coordinateSpace: 'viewport' } }), 'absent fit in viewport');
expectClean(document({ scene: { coordinateSpace: 'viewport', fit: 'stretch' } }), 'explicit stretch in viewport');
assert.ok(codes(document({ scene: { coordinateSpace: 'viewport', fit: 'contain' } })).includes('ERR_SCHEMA_VALIDATION'));
});
test('the layer map decides whether a system must name a layer (17.5, 17.7, V17)', () => {
// Absent map: the implicit layer, and naming one is a dangling reference.
expectClean(document({ scene, systems: { horizon: graphic() } }), 'implicit layer');
assert.ok(codes(document({ scene, systems: { horizon: graphic({ layer: 'far' }) } })).includes('ERR_INVALID_REFERENCE'));
// Present map: naming one is required, and no first-layer fallback exists.
expectClean(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) } }), 'declared layer');
assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic() } })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'near' }) } })).includes('ERR_INVALID_REFERENCE'));
// Present but empty is not a layer set.
assert.ok(codes(document({ scene, layers: {} })).includes('ERR_SCHEMA_VALIDATION'));
});
test('more than sixteen layers is a visual limit (17.5, 19.5)', () => {
const layers = Object.fromEntries(Array.from({ length: 17 }, (_, index) => [`l${index}`, {}]));
assert.ok(codes(document({ scene, layers })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
delete layers.l16;
expectClean(document({ scene, layers }), 'sixteen layers');
});
// ---------------------------------------------------------------------------
// 17.7 / 19.2 — the spawn container (A1)
// ---------------------------------------------------------------------------
test('spawned lifecycle fields live only in the spawn container (17.7, 19.2, A1)', () => {
const spawned = (spawn, extra = {}) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn, ...extra } } });
expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'spawn container');
// A persistent system has no spawn container at all.
assert.ok(codes(document({ scene, systems: { horizon: graphic({ spawn: { lifetime: '2s' } }) } })).includes('ERR_UNKNOWN_FIELD'));
// The four spawn-only names never appear at a system's top level.
for (const field of ['release', 'ownership', 'inputs', 'cancelWithScenario']) {
const doc = document({ scene, systems: { horizon: graphic({ [field]: field === 'cancelWithScenario' ? true : 'x' }) } });
assert.ok(codes(doc).includes('ERR_UNKNOWN_FIELD'), `${field} at system top level`);
}
});
test('a top-level lifetime is the per-item duration, and only two types have one (17.7, A1)', () => {
const particles = { type: 'particles', render: { type: 'point' }, count: 10, lifetime: '4s' };
expectClean(document({ scene, systems: { motes: particles } }), 'particle lifetime');
// The same key on a graphic or repeater is an unknown field, not an instance duration.
assert.ok(codes(document({ scene, systems: { horizon: graphic({ lifetime: '4s' }) } })).includes('ERR_UNKNOWN_FIELD'));
// A spawned particles system carries both, independently.
expectClean(document({ scene, systems: { motes: { ...particles, lifecycle: 'spawned', spawn: { lifetime: '30s' } } } }), 'both lifetimes');
});
test('severing the scenario relationship requires persistent ownership (19.2, V23)', () => {
const doc = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } });
assert.ok(codes(doc({ cancelWithScenario: false })).includes('ERR_UNSUPPORTED_TARGET'));
expectClean(doc({ cancelWithScenario: false, ownership: 'persistent' }), 'persistent-owned survivor');
expectClean(doc({ cancelWithScenario: true }), 'default relationship');
});
// ---------------------------------------------------------------------------
// 18.7 — procedural fields (A2)
// ---------------------------------------------------------------------------
test('a value-mode noise field requires direction and no other mode accepts it (18.7, A2)', () => {
const field = (extra) => document({ scene, fields: { current: { type: 'noise', scale: 200, ...extra } } });
expectClean(field({ mode: 'value', direction: 90 }), 'value mode with direction');
assert.ok(codes(field({ mode: 'value' })).includes('ERR_SCHEMA_VALIDATION'));
assert.ok(codes(field({ mode: 'curl', direction: 90 })).includes('ERR_UNKNOWN_FIELD'));
expectClean(field({}), 'curl by default');
assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, fields: { current: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE'));
});
// ---------------------------------------------------------------------------
// 19.3 / 19.4 — camera and post-effects
// ---------------------------------------------------------------------------
test('focalLength has a closed range with a real lower bound (19.3, V20)', () => {
expectClean(document({ scene, camera: { focalLength: 1 } }), 'the documented minimum');
assert.ok(codes(document({ scene, camera: { focalLength: 0 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { focalLength: 0.5 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { zoom: 200 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { projection: 'isometric' } })).includes('ERR_SCHEMA_VALIDATION'));
});
test('the post-effect chain is closed at seven types and four entries (19.4)', () => {
expectClean(document({ scene, effects: [{ type: 'bloom', threshold: 0.6 }] }), 'a bloom entry');
assert.ok(codes(document({ scene, effects: [{ type: 'chromatic' }] })).includes('ERR_INVALID_EFFECT_TYPE'));
assert.ok(codes(document({ scene, effects: [{ type: 'blur', threshold: 1 }] })).includes('ERR_UNKNOWN_FIELD'));
assert.ok(codes(document({ scene, effects: [{ type: 'blur', radius: 64 }] })).includes('ERR_OUT_OF_BOUNDS'));
const five = Array.from({ length: 5 }, () => ({ type: 'fade' }));
assert.ok(codes(document({ scene, effects: five })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
test('blur and bloom cost two frame passes each, everything else one (19.4)', () => {
assert.equal(postEffectPassCount([{ type: 'blur' }, { type: 'bloom' }, { type: 'fade' }, { type: 'grain' }]), 6);
// Four entries of two passes is exactly the eight-pass ceiling of 19.5.
assert.equal(postEffectPassCount(Array.from({ length: 4 }, () => ({ type: 'blur' }))), VISUAL_LIMITS.runtime.postEffectPassesPerFrame);
});
// ---------------------------------------------------------------------------
// 19.1 — visual automation
// ---------------------------------------------------------------------------
const track = (target, extra = {}) => ({ target, points: [{ at: '0ms', value: 0 }, { at: '2s', value: 1 }], ...extra });
test('exhibit-scope automation reaches scene, layers, camera, and effects (19.1)', () => {
expectClean(document({ scene, layers: { far: {} }, camera: {}, effects: [{ type: 'fade' }], automation: [
track('camera.zoom'), track('layers.far.opacity'), track('effects[0].amount'),
track('scene.depthFog.density')
], systems: {} }), 'every exhibit-scope family');
assert.ok(codes(document({ scene, automation: [track('camera.projection')] })).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(document({ scene, layers: { far: {} }, automation: [track('layers.near.opacity')] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[3].amount')] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[0].color')] })).includes('ERR_UNSUPPORTED_TARGET'));
});
test('the two declaration scopes never reach across (19.1)', () => {
const doc = document({ scene, systems: { horizon: { ...graphic(), automation: [track('camera.zoom')] } } });
assert.ok(codes(doc).includes('ERR_INVALID_REFERENCE'), 'system scope cannot address the camera');
const other = document({ scene, systems: { horizon: graphic(), motes: { ...graphic(), automation: [track('systems.horizon.band.z')] } } });
assert.ok(codes(other).includes('ERR_INVALID_REFERENCE'), 'no track addresses another system');
assert.ok(codes(document({ scene, systems: { horizon: graphic() }, automation: [track('systems.horizon.band.z')] })).includes('ERR_INVALID_REFERENCE'));
});
test('a repeater exposes no automatable property, step included (19.1, V11)', () => {
const repeater = (automation) => document({ scene, systems: { wall: { type: 'repeater', repeat: { type: 'point' }, count: 4, automation } } });
assert.ok(codes(repeater([track('step.x')])).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(repeater([track('count')])).includes('ERR_UNSUPPORTED_TARGET'));
// A particle system does expose its live channels.
const particles = document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 4, automation: [track('rate'), track('drag')] } } });
expectClean(particles, 'particle live channels');
});
test('a graphic system automates numeric object properties by container key (19.1)', () => {
const system = { type: 'graphic', content: { hull: { type: 'group', children: { band: { type: 'rectangle', size: { width: 4, height: 2 } } } } } };
expectClean(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.transform.rotation')] } } }), 'nested numeric property');
assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.style.blend')] } } })).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.missing.z')] } } })).includes('ERR_INVALID_REFERENCE'));
});
test('an infinite loop is legal in every scope (19.1, V12)', () => {
const loop = { mode: 'ping-pong', count: 'infinite' };
expectClean(document({ scene, camera: {}, automation: [track('camera.zoom', { loop })] }), 'exhibit scope');
expectClean(document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, automation: [track('rate', { loop })] } } }), 'persistent system');
// Both spawned cases: with a declared instance lifetime and without one.
const spawned = (spawn) => document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, lifecycle: 'spawned', spawn, automation: [track('rate', { loop })] } } });
expectClean(spawned({ lifetime: '8s' }), 'finite-lifetime spawned');
expectClean(spawned({}), 'indefinite spawned');
});
test('one track per target, strictly increasing point times (19.1)', () => {
const doc = document({ scene, camera: {}, automation: [track('camera.zoom'), track('camera.zoom')] });
assert.ok(codes(doc).includes('ERR_AUTOMATION_CONFLICT'));
const backwards = { target: 'camera.zoom', points: [{ at: '2s', value: 0 }, { at: '1s', value: 1 }] };
assert.ok(codes(document({ scene, camera: {}, automation: [backwards] })).includes('ERR_INVALID_RANGE_ORDER'));
const procedural = { target: 'camera.zoom', points: [{ at: { random: { min: 0, max: 1 } }, value: 0 }, { at: '1s', value: 1 }] };
assert.ok(codes(document({ scene, camera: {}, automation: [procedural] })).includes('ERR_INVALID_DURATION'));
});
test('declared automation records are an authoring bound counted once each (19.1, V7)', () => {
const many = Array.from({ length: 129 }, (_, index) => track(`effects[${index % 4}].amount`));
const doc = document({ scene, effects: Array.from({ length: 4 }, () => ({ type: 'fade' })), automation: many });
assert.ok(codes(doc).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
// ---------------------------------------------------------------------------
// 8.1 / 19.1 — the four visual target families
// ---------------------------------------------------------------------------
test('matchVisualTarget separates unsupported targets from dangling references (19.1, V2)', () => {
const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] });
for (const path of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.systems.horizon.visible', 'visuals.effects[0].radius']) {
assert.equal(matchVisualTarget(doc, path).reason, undefined, path);
}
assert.equal(matchVisualTarget(doc, 'visuals.layers.near.opacity').reason, 'reference');
assert.equal(matchVisualTarget(doc, 'visuals.effects[2].radius').reason, 'reference');
assert.equal(matchVisualTarget(doc, 'visuals.layers.far.visible').reason, 'unsupported', 'layer visibility is deliberately absent');
assert.equal(matchVisualTarget(doc, 'visuals.camera.projection').reason, 'unsupported');
assert.equal(matchVisualTarget(doc, 'visuals.systems.horizon.content.band.style.opacity').reason, 'unsupported');
assert.equal(matchVisualTarget(doc, 'parameters.activity'), null);
});
test('the stage table matches the four section 8.1 rows (19.1)', () => {
const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] });
assert.deepEqual(matchVisualTarget(doc, 'visuals.camera.zoom').stages, { binding: true, automation: true, override: true, modulation: true });
assert.deepEqual(matchVisualTarget(doc, 'visuals.layers.far.opacity').stages, { binding: true, automation: true, override: true, modulation: false });
// A boolean target takes no automation and no modulation: absent, not identity hooks.
assert.deepEqual(matchVisualTarget(doc, 'visuals.systems.horizon.visible').stages, { binding: true, automation: false, override: true, modulation: false });
assert.deepEqual(matchVisualTarget(doc, 'visuals.effects[0].radius').stages, { binding: true, automation: true, override: true, modulation: false });
});
test('bindings reach the four families and nothing else (8.1, 19.1, V2)', () => {
const base = { scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] };
const bound = (target) => document(base, { bindings: [{ source: 'state.energy', target }] });
for (const target of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.effects[0].radius']) {
expectClean(bound(target), target);
}
assert.ok(codes(bound('visuals.systems.horizon.content.band.style.opacity')).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(bound('visuals.fields.current.strength')).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(bound('visuals.layers.near.opacity')).includes('ERR_INVALID_REFERENCE'));
// A boolean target and a numeric source do not match.
assert.ok(codes(bound('visuals.systems.horizon.visible')).includes('ERR_TYPE_MISMATCH'));
});
// ---------------------------------------------------------------------------
// Resolution — bases, clamps, and stage exposure
// ---------------------------------------------------------------------------
function engine(visuals, overrides = {}) {
const doc = document(visuals, overrides);
return new ResolutionEngine(doc, new SeededRNG(42));
}
test('visual target bases resolve from the document, or from their documented defaults (19.3)', () => {
const resolver = engine({ scene, layers: { far: { opacity: 0.4 } }, systems: { horizon: graphic({ layer: 'far' }) }, camera: { zoom: 2 }, effects: [{ type: 'blur', radius: 6 }] });
assert.equal(resolver.get('visuals.camera.zoom'), 2);
assert.equal(resolver.get('visuals.camera.focalLength'), 1000, 'documented default');
assert.equal(resolver.get('visuals.camera.x'), 800, 'the scene center, so a declared default looks identical');
assert.equal(resolver.get('visuals.layers.far.opacity'), 0.4);
assert.equal(resolver.get('visuals.systems.horizon.visible'), true);
assert.equal(resolver.get('visuals.effects[0].radius'), 6);
// A parameter the effect's own table does not declare exposes no capability.
assert.throws(() => resolver.get('visuals.effects[0].threshold'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
});
test('a resolved camera value clamps into the closed range rather than failing (19.3, V20)', () => {
const resolver = engine({ scene, camera: { focalLength: { ref: 'state.energy' } } });
// state.energy is 0.5, below the documented minimum of 1.
assert.equal(resolver.get('visuals.camera.focalLength'), 1);
});
test('a layer opacity binding runs through the shared pipeline and clamps to [0, 1] (8.1)', () => {
const resolver = engine(
{ scene, layers: { far: { opacity: 0.2 } } },
{ bindings: [{ source: 'state.energy', target: 'visuals.layers.far.opacity', scale: 4 }] }
);
assert.equal(resolver.get('visuals.layers.far.opacity'), 1, '0.5 * 4 clamps to the row range');
});
test('the automation stage is refused where the 8.1 row does not expose it (8.1, 19.1)', () => {
const resolver = engine({ scene, systems: { horizon: graphic() }, camera: {} });
assert.doesNotThrow(() => resolver.requireStage('visuals.camera.zoom', 'automation'));
assert.throws(() => resolver.requireStage('visuals.systems.horizon.visible', 'automation'), (error) => error instanceof RuntimeFault && error.code === 'ERR_UNSUPPORTED_TARGET');
assert.throws(() => resolver.requireStage('visuals.layers.far.opacity', 'modulation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
assert.throws(() => resolver.requireStage('parameters.nope', 'automation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
});
test('effect parameter ranges in the contract match the 19.4 tables', () => {
assert.equal(POST_EFFECTS.bloom.numeric.intensity.max, 2);
assert.equal(POST_EFFECTS.grain.numeric.speed.max, 60);
assert.equal(POST_EFFECTS.scanlines.numeric.spacing.min, 1);
assert.equal(VISUAL_LIMITS.authoring.declaredSystems, 64);
assert.equal(VISUAL_LIMITS.authoring.expandedStaticObjects, 16384);
});
// ---------------------------------------------------------------------------
// Fixture and schema integrity
// ---------------------------------------------------------------------------
test('the minimal visual exhibit validates end to end', async () => {
const { readFileSync } = await import('node:fs');
const { parseAndValidateExhibit } = await import('../src/runtime/validator.js');
const source = readFileSync(new URL('../exhibits/minimal-visual.xzbt', import.meta.url), 'utf8');
const result = parseAndValidateExhibit(source, 'minimal-visual.xzbt');
assert.deepEqual(result.errors, [], JSON.stringify(result.errors));
// Its three bindings reach three of the four families through the pipeline.
const resolver = new ResolutionEngine(result.document, new SeededRNG(42));
assert.equal(resolver.get('visuals.camera.zoom'), 1, 'bound to parameters.depth');
assert.equal(resolver.get('visuals.layers.near.opacity'), 0.35);
assert.ok(Math.abs(resolver.get('visuals.effects[0].amount') - 0.21) < 1e-12);
assert.equal(resolver.get('visuals.systems.flare.visible'), true);
});
test('every $ref in the JSON Schema resolves to a declared definition', async () => {
const { readFileSync } = await import('node:fs');
const schema = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url), 'utf8'));
const declared = new Set(Object.keys(schema.definitions));
const missing = [];
const walk = (node) => {
if (Array.isArray(node)) return node.forEach(walk);
if (typeof node !== 'object' || node === null) return;
for (const [key, value] of Object.entries(node)) {
if (key === '$ref' && typeof value === 'string') {
const name = value.replace('#/definitions/', '');
if (!declared.has(name)) missing.push(value);
} else walk(value);
}
};
walk(schema);
assert.deepEqual(missing, []);
// 17.3: the visuals container declares exactly its seven fields.
assert.deepEqual(Object.keys(schema.properties.visuals.properties).sort(),
['automation', 'camera', 'effects', 'fields', 'layers', 'scene', 'systems']);
// 17.8: a visual object carries no `id`; its container key is its identity.
assert.equal(schema.definitions.VisualObject.properties.id, false);
});