diff --git a/XZBT.html b/XZBT.html
index 31c2348..f21f0ae 100644
--- a/XZBT.html
+++ b/XZBT.html
@@ -169,7 +169,7 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
/* src/runtime/constants.js */
const XZBT_FORMAT_VERSION = '0.1';
-const XZBT_RUNTIME_VERSION = '0.1.0-phase3b';
+const XZBT_RUNTIME_VERSION = '0.1.0-phase4-stage0';
const UINT32_RANGE = 0x1_0000_0000;
const RNG_DOMAINS = Object.freeze([
'cadence',
@@ -2022,6 +2022,793 @@ function createAudioControls(context, plan, entries, disposeWith, startTime) {
};
}
+/* src/runtime/visual-contract.js */
+// 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.
+
+
+// ---------------------------------------------------------------------------
+// Vocabularies
+// ---------------------------------------------------------------------------
+
+const COORDINATE_SPACES = Object.freeze(['normalized', 'viewport', 'virtual']);
+const FIT_MODES = Object.freeze(['contain', 'cover', 'stretch']);
+
+/** Visual System Set 0.1 (17.7, 18.2, 18.4, 18.5). */
+const VISUAL_SYSTEM_TYPES = Object.freeze(['graphic', 'particles', 'emitter', 'repeater']);
+
+/** Visual Primitive Set 0.1 (17.9) — closed at fourteen geometry primitives. */
+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). */
+const VISUAL_OBJECT_TYPES = Object.freeze([...VISUAL_PRIMITIVE_TYPES, 'component']);
+
+/** Visual Behavior Set 0.1 (18.6). */
+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). */
+const VISUAL_FIELD_TYPES = Object.freeze([
+ 'directional', 'radial', 'vortex', 'attractor', 'repulsor', 'noise'
+]);
+
+/** Placement distribution set (18.3). */
+const DISTRIBUTION_TYPES = Object.freeze([
+ 'point', 'uniform', 'line', 'rectangle', 'ellipse', 'ring', 'path', 'grid', 'depth'
+]);
+
+/** Safe blend set (17.12). */
+const BLEND_MODES = Object.freeze([
+ 'normal', 'add', 'screen', 'multiply', 'overlay', 'lighten', 'darken', 'difference'
+]);
+
+/** Object filter vocabulary (17.12). */
+const FILTER_TYPES = Object.freeze([
+ 'brightness', 'contrast', 'saturate', 'hue-rotate', 'grayscale', 'sepia', 'invert'
+]);
+
+/** Automation curves and loop modes (19.1). */
+const AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
+const AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
+const LOOP_MODES = Object.freeze(['repeat', 'ping-pong']);
+
+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.
+ */
+const SPAWN_FIELDS = Object.freeze(['lifetime', 'release', 'ownership', 'inputs', 'cancelWithScenario']);
+
+// ---------------------------------------------------------------------------
+// Camera and post-effects
+// ---------------------------------------------------------------------------
+
+/** 19.3. `projection` is authored once: not automatable, not bindable. */
+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 }
+});
+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.
+ */
+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' } }
+});
+
+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).
+ */
+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.
+ */
+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. */
+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.
+ */
+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. */
+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). */
+function postEffectPassCount(effects) {
+ if (!Array.isArray(effects)) return 0;
+ return effects.reduce((total, entry) => total + (POST_EFFECTS[entry?.type]?.passes ?? 0), 0);
+}
+
+/* src/runtime/visual-validation.js */
+// 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.
+
+
+
+
+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 isColor(value) {
+ return typeof value === 'string' && value.length > 0;
+}
+
+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 (!isRecord(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(isRecord(visuals.systems) ? visuals.systems : {})) {
+ if (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 && !isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (!isRecord(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 (isRecord(object) && (object.type === 'group' || object.type === 'component') && isRecord(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.` };
+}
+
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
@@ -2097,6 +2884,7 @@ 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 };
}
@@ -2227,6 +3015,34 @@ function validateCondition(document, condition, path, errors) {
}
}
+/**
+ * The exposed binding-target surface: `parameters.*`, `state.*`,
+ * `audio.buses..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.');
@@ -2237,15 +3053,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.');
@@ -2450,6 +3265,9 @@ function validateStoredRecord(record) {
/* src/runtime/resolution.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 });
+
class SignalProvider {
constructor() {
this.values = new Map([
@@ -2590,6 +3408,7 @@ 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();
@@ -2617,12 +3436,16 @@ 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;
}
@@ -2633,6 +3456,12 @@ 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);
}
@@ -2647,6 +3476,7 @@ 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();
}
@@ -2654,7 +3484,13 @@ 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 {
@@ -2663,7 +3499,7 @@ 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,
@@ -2672,7 +3508,7 @@ 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
});
@@ -2720,8 +3556,32 @@ 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
@@ -2818,7 +3678,7 @@ 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();
}
}
diff --git a/src/runtime/visual-validation.js b/src/runtime/visual-validation.js
index 7817258..b479999 100644
--- a/src/runtime/visual-validation.js
+++ b/src/runtime/visual-validation.js
@@ -28,7 +28,7 @@ import {
VISUAL_LIMITS,
VISUAL_SYSTEM_TYPES
} from './visual-contract.js';
-import { DURATION_PATTERN } from './types.js';
+import { DURATION_PATTERN, isRecord } from './types.js';
const AUTHORING = VISUAL_LIMITS.authoring;
@@ -72,10 +72,6 @@ const SYSTEM_AUTOMATABLE = Object.freeze({
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;
}
@@ -85,7 +81,7 @@ export function validateVisualSubsystem(document, errors, 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.');
+ if (!isRecord(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}'.`);
@@ -104,8 +100,8 @@ export function validateVisualSubsystem(document, errors, helpers) {
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;
+ for (const [id, system] of Object.entries(isRecord(visuals.systems) ? visuals.systems : {})) {
+ if (!isRecord(system)) continue;
count(validateAutomationArray(document, system.automation, `$.visuals.systems.${id}.automation`, { scope: 'system', systemId: id, system, context }, errors, helpers));
}
@@ -123,7 +119,7 @@ function validateScene(document, scene, errors, { validateValueSpec, pushError }
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'visuals.scene is required when visuals is present.');
return {};
}
- if (!isPlainObject(scene)) {
+ if (!isRecord(scene)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'scene must be an object.');
return {};
}
@@ -151,7 +147,7 @@ function validateScene(document, scene, errors, { validateValueSpec, pushError }
if (scene.depthFog !== undefined) {
const fog = scene.depthFog;
- if (!isPlainObject(fog)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog', 'depthFog must be an object.');
+ if (!isRecord(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.');
@@ -166,7 +162,7 @@ function validateScene(document, scene, errors, { validateValueSpec, pushError }
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)) {
+ if (!isRecord(layers)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'layers must be an object.');
return null;
}
@@ -181,7 +177,7 @@ function validateLayers(document, layers, errors, { validateValueSpec, pushError
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; }
+ if (!isRecord(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);
@@ -194,7 +190,7 @@ function validateLayers(document, layers, errors, { validateValueSpec, pushError
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.');
+ if (!isRecord(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}'.`);
}
@@ -223,7 +219,7 @@ function validateEffects(document, effects, errors, { validateValueSpec, pushErr
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.');
+ if (!isRecord(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)) {
@@ -252,7 +248,7 @@ function validateEffects(document, effects, errors, { validateValueSpec, pushErr
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)) {
+ if (!isRecord(fields)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.fields', 'fields must be an object.');
return new Set();
}
@@ -261,7 +257,7 @@ function validateFields(document, fields, errors, { validateValueSpec, pushError
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 (!isRecord(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}'.`);
@@ -283,7 +279,7 @@ function validateFields(document, fields, errors, { validateValueSpec, pushError
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)) {
+ if (!isRecord(systems)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.systems', 'systems must be an object.');
return {};
}
@@ -292,7 +288,7 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa
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 (!isRecord(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.
@@ -335,7 +331,7 @@ function validateSpawn(document, system, lifecycle, path, errors, { validateValu
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.');
+ if (!isRecord(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}'.`);
}
@@ -350,7 +346,7 @@ function validateSpawn(document, system, lifecycle, path, errors, { validateValu
// 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.');
+ if (spawn.inputs !== undefined && !isRecord(spawn.inputs)) fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.inputs`, 'spawn.inputs must be an object.');
}
// ---------------------------------------------------------------------------
@@ -369,14 +365,14 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers)
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.');
+ if (!isRecord(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.');
+ if (!isRecord(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}'.`);
@@ -395,7 +391,7 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers)
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.');
+ if (!isRecord(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.
@@ -485,7 +481,7 @@ function resolveGraphicTarget(target, system) {
let container = system?.content;
let index = 0;
while (index < parts.length) {
- if (!isPlainObject(container) || !Object.hasOwn(container, parts[index])) {
+ if (!isRecord(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]];
@@ -494,7 +490,7 @@ function resolveGraphicTarget(target, system) {
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)) {
+ if (isRecord(object) && (object.type === 'group' || object.type === 'component') && isRecord(object.children)) {
container = object.children;
continue;
}
diff --git a/tools/build-xzbt.mjs b/tools/build-xzbt.mjs
index 1e17c55..ba05ea0 100644
--- a/tools/build-xzbt.mjs
+++ b/tools/build-xzbt.mjs
@@ -16,6 +16,8 @@ const sourceFiles = Object.freeze([
'src/runtime/audio-automation.js',
'src/runtime/audio-graph.js',
'src/runtime/audio-controls.js',
+ 'src/runtime/visual-contract.js',
+ 'src/runtime/visual-validation.js',
'src/runtime/validator.js',
'src/runtime/persistence.js',
'src/runtime/library.js',