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