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',