# XZBT Format Specification 0.1 **XZBT format version:** 0.1 **Document revision:** 0.9 **Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem contract is complete (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses, Phase 3c automation/lifecycle/protection); master-protection values remain provisional pending GC6 measurement; the Visual subsystem contract is complete (Phase 4a scene/primitives/transforms/appearance in section 17, Phase 4b components/procedural systems/behaviors/fields in section 18, and Phase 4c automation/lifecycle/camera/post-effects/ceilings in section 19); the aggregate visual ceilings of 19.5 remain provisional pending the slice 4h GC6 measurement; the Cadence and Event Subsystems contract is complete in section 20 (Phase 5); and the remaining subsystem contracts are in progress **Related resources:** [PRD](../XZBT_0-1_MVP_Product_Requirements_Document.md), [decisions](XZBT_0-1_Gap_Closure_Decisions.md), [verification](XZBT_0-1_Verification_Gates.md) This document defines normative syntax and runtime semantics for XZBT 0.1 exhibits. An exhibit is a UTF-8 JSON document that configures generic procedural visual, audio, cadence, and orchestration primitives. It does not contain executable JavaScript. ## 1. Document Structure & Metadata ### 1.1 Root Structure A conforming `.xzbt` exhibit document consists of a top-level JSON object with the following properties: | Field | Type | Required | Description | | :--- | :--- | :---: | :--- | | `xzbt` | `string` | **Yes** | Must be exactly `"0.1"`. Mismatched versions fail validation immediately. | | `meta` | `object` | **Yes** | Exhibit identity and descriptive metadata. | | `runtime` | `object` | No | Initial simulation and PRNG seed configuration. | | `parameters` | `object` | No | User-tunable configuration definitions. Map of ID -> ParameterSpec. | | `state` | `object` | No | Simulation-owned mutable variables. Map of ID -> StateSpec. | | `signals` | `object` | No | Read-only runtime environmental signals. | | `ui` | `object` | No | Generated control grouping, widgets, and layout preferences. | | `components` | `object` | No | Reusable audio and visual sub-assemblies. | | `visuals` | `object` | No | Visual canvas, camera, rendering passes, and primitive systems. | | `audio` | `object` | No | Master bus, auxiliary buses, routing, and synthesizer definitions. | | `sounds` | `object` | No | One-shot sound event templates. | | `cadence` | `object` | No | Procedural pulse clocks, rhythm pools, and recurring trigger policies. | | `modulators` | `object` | No | Continuous low-frequency oscillators, noise, and sample-and-hold generators. | | `bindings` | `array` | No | Directed value-propagation links between sources and target properties. | | `events` | `object` | No | Discrete state-change and lifecycle trigger handlers. | | `scenarios` | `object` | No | Autonomous orchestrated sequences, timelines, and temporary overrides. | Strict unknown-field policy: Any unrecognized property in behavior-bearing sections (`parameters`, `state`, `visuals`, `audio`, `cadence`, `modulators`, `bindings`, `events`, `scenarios`) is a fatal validation error (`ERR_UNKNOWN_FIELD`). ### 1.2 Metadata (`meta`) Metadata provides provenance and UI presentation details. It never executes or modifies runtime logic: | Field | Type | Required | Description | | :--- | :--- | :---: | :--- | | `id` | `string` | **Yes** | Unique exhibit identifier. Pattern: `^[a-z][a-z0-9_-]*$`. Max 64 chars. | | `name` | `string` | **Yes** | Human-readable title displayed in the library and header. Max 128 chars. | | `version` | `string` | No | Exhibit semantic version string (e.g., `"1.0.0"`). | | `author` | `string` | No | Author or creator attribution string. Max 128 chars. | | `description`| `string` | No | Brief narrative description of the exhibit. Max 1024 chars. | | `license` | `string` | No | License terms (e.g., `"CC-BY-4.0"`, `"All Rights Reserved"`). | | `tags` | `array[string]` | No | Array of category or aesthetic keywords. Max 16 tags. | ### 1.3 Identifiers and Namespaces * **Identifier Syntax:** All resource IDs (exhibit ID, parameter IDs, state IDs, bus IDs, scenario IDs) must strictly match: ```text ^[a-z][a-z0-9_-]*$ ``` * **Reference Path Syntax:** Dot-delimited path referencing a target property or namespace: ```text .[.] ``` Valid canonical namespace prefixes: `parameters.*`, `state.*`, `signals.*`, `modulators.*`, `audio.buses.*`, `visuals.systems.*`, `visuals.layers.*`, `visuals.camera.*`, `visuals.effects.*`. Dots are strictly forbidden within identifier names themselves. An ordered container is indexed rather than keyed: `visuals.effects[].` and `points[].x` (17.13) are the two such forms in 0.1. --- ## 2. Shared Type System & Coercion Rules Every value in XZBT belongs to one of the following concrete primitive types: | Type | Definition & Constraints | Serialization Example | | :--- | :--- | :--- | | `number` | IEEE 754 64-bit float. Must be finite (`Number.isFinite(v) === true`). `NaN`, `+Infinity`, and `-Infinity` are strictly forbidden and fail validation/evaluation. | `440.0`, `-0.5`, `1e3` | | `integer` | IEEE 754 64-bit float restricted to integer values (`Number.isInteger(v) === true`). | `1`, `42`, `-8` | | `boolean` | Logical truth value: `true` or `false`. | `true`, `false` | | `string` | Valid UTF-8 text string. | `"drift"`, `"sine"` | | `color` | CSS-compatible color: `#rgb`, `#rrggbb`, `#rrggbbaa`, or standard CSS color keyword. | `"#e8ad57"`, `"#11151c"` | | `enum` | String constrained to an explicitly declared set of allowed tokens. | `"triangle"` in `["sine", "triangle", "saw"]` | **Strict Coercion Ban:** The runtime performs **no implicit type coercion**. A string containing digits (e.g. `"440"`) will **not** be coerced into a number; a non-zero number will **not** be coerced into a boolean. Type mismatches produce `ERR_TYPE_MISMATCH`. --- ## 3. Parameter and State Specifications ### 3.1 Parameters (`parameters.`) Parameters represent user-configurable settings. They have documented defaults and bounds: ```json { "activity": { "type": "number", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "label": "Activity Level" } } ``` * **Storage Distinction:** User edits to parameters are persisted in client storage separately from default exhibit definitions. * **Protection from `set`:** Actions triggered by scenarios or events **cannot** use `set` to mutate parameters. Temporary programmatic modifications must use `override`. ### 3.2 State Variables (`state.`) State variables represent simulation-owned state. They are initialized at launch and manipulated by runtime events and scenarios: ```json { "machine-load": { "type": "number", "initial": 0.25, "min": 0.0, "max": 1.0 } } ``` * **Persistence Distinction:** State variables are transient simulation variables and are not stored across browser restarts unless explicitly configured. --- ## 4. ValueSpec 0.1 Normative Specification A `ValueSpec` is the universal declarative expression used wherever a dynamic or configurable value is accepted. A `ValueSpec` must match one of the following five forms: ### 4.1 Literal Constant A raw JSON number, boolean, string, or color: ```json 440.0 ``` ### 4.2 Reference (`ref`) Reads the currently resolved value of a declared target: ```json { "ref": "parameters.activity" } ``` References are evaluated dynamically during each simulation tick unless used within a construct with documented static sampling timing. ### 4.3 Random Range (`random`) Samples a pseudo-random value from a uniform bounded range: ```json { "random": { "min": 200.0, "max": 800.0, "integer": false } } ``` * `min` (`number`, required): Lower bound. * `max` (`number`, required): Upper bound (`max >= min`). * `integer` (`boolean`, optional, default `false`): If `true`, output is rounded to an integer via `Math.floor(min + prng() * (max - min + 1))`. * **Sampling Boundary:** Sampled **only** at object instantiation or event invocation. It is **never** sampled per-frame. ### 4.4 Weighted Selection (`choose`) Selects one item from an array of weighted options: ```json { "choose": [ { "value": "sine", "weight": 6 }, { "value": "triangle", "weight": 3 }, { "value": "square", "weight": 1 } ] } ``` * `choose` (`array[object]`, required): Non-empty array of choice objects. * Each entry requires `value` (`literal` or nested `ValueSpec`) and `weight` (`number > 0`). ### 4.5 Calculation Operator (`op`) Evaluates an arithmetic or mathematical operation over argument operands: ```json { "op": "multiply", "args": [ { "ref": "parameters.activity" }, 1.5 ] } ``` #### Supported Operators and Arity | Operator | Arity | Description | Domain Rules & Safe Fallback | | :--- | :---: | :--- | :--- | | `abs` | 1 | Absolute value: `\|a\|` | Finite number. | | `negate` | 1 | Arithmetic negation: `-a` | Finite number. | | `round` | 1 | Nearest integer: `Math.round(a)` | Finite number. | | `floor` | 1 | Floor integer: `Math.floor(a)` | Finite number. | | `ceil` | 1 | Ceiling integer: `Math.ceil(a)` | Finite number. | | `add` | 2 | Addition: `a + b` | Finite number. | | `subtract` | 2 | Subtraction: `a - b` | Finite number. | | `multiply` | 2 | Multiplication: `a * b` | Finite number. | | `divide` | 2 | Division: `a / b` | **Division-by-zero protection:** If `b === 0`, evaluates safely to `0.0` (never `NaN` or `Infinity`). | | `min` | 2 | Minimum: `Math.min(a, b)` | Finite numbers. | | `max` | 2 | Maximum: `Math.max(a, b)` | Finite numbers. | | `clamp` | 3 | Range clamp: `[val, min, max]` | Evaluates to `Math.min(max, Math.max(min, val))`. Requires `min <= max`. | | `lerp` | 3 | Linear interpolation: `[a, b, t]` | Evaluates to `a + (b - a) * t`. Unclamped `t` unless combined with `clamp`. | --- ## 5. ConditionSpec 0.1 Normative Specification A `ConditionSpec` evaluates to a boolean (`true` or `false`) and controls scenario triggers, conditional actions, and branching logic. ### 5.1 Comparison Expressions Compares two `ValueSpec` expressions: ```json { "op": "gt", "left": { "ref": "state.machine-load" }, "right": 0.85 } ``` * Supported comparison operators: * `eq`: Equality (`left === right`) * `ne`: Inequality (`left !== right`) * `gt`: Greater than (`left > right`) * `gte`: Greater than or equal to (`left >= right`) * `lt`: Less than (`left < right`) * `lte`: Less than or equal to (`left <= right`) * `left` and `right` must evaluate to compatible primitive types (`number` with `number`, `boolean` with `boolean`, `string` with `string`). Cross-type comparison produces `ERR_TYPE_MISMATCH`. ### 5.2 Logical Combinators Combines child condition expressions: * **Conjunction (`and`):** All child conditions must evaluate to `true`. Short-circuits on first `false`. ```json { "and": [ { "op": "gt", "left": { "ref": "state.machine-load" }, "right": 0.5 }, { "op": "eq", "left": { "ref": "parameters.enabled" }, "right": true } ] } ``` * **Disjunction (`or`):** At least one child condition must evaluate to `true`. Short-circuits on first `true`. ```json { "or": [ { "op": "lt", "left": { "ref": "state.energy" }, "right": 0.1 }, { "op": "eq", "left": { "ref": "state.alarm" }, "right": true } ] } ``` * **Negation (`not`):** Inverts the child condition. ```json { "not": { "op": "eq", "left": { "ref": "parameters.mute" }, "right": true } } ``` ### 5.3 Edge-Triggering & Re-Arming Semantics * **Rising-Edge Trigger:** When a condition is used as an event trigger or scenario trigger, it fires **only** when its evaluation transitions from `false` on tick $T-1$ to `true` on tick $T$. * **Re-Arming Rule:** As long as the condition remains continuously `true`, it **will not fire again**. It must evaluate to `false` on at least one tick to re-arm before it can fire on a subsequent `true` evaluation. --- ## 6. TimeSpec and DurationSpec 0.1 ### 6.1 Duration Literals Durations are expressed as strings containing a non-negative finite number and a single explicit unit: * `ms`: Milliseconds (e.g., `"250ms"`, `"16.67ms"`) * `s`: Seconds (e.g., `"4s"`, `"0.5s"`) * `m`: Minutes (e.g., `"2.5m"`, `"1m"`) * `h`: Hours (e.g., `"1.5h"`, `"8h"`) Compound formats (e.g., `"1m30s"`) are strictly invalid (`ERR_INVALID_DURATION`). Internally, the runtime converts all durations to millisecond floating-point numbers. A duration that is already a **non-negative finite number** is that millisecond value and is accepted wherever this contract says DurationSpec. The literal string is the authored form and the only form a hand-written document uses; the numeric form exists because a DurationSpec may be the resolved output of a ValueSpec or a bounded `TimeSpec` (6.2), which produces milliseconds rather than a literal. A negative or non-finite number is `ERR_INVALID_DURATION`, as is any other type. This applies at every DurationSpec field, `spawn.lifetime` and `spawn.release` (19.2) included; the one exception is an automation track's `at`, which 19.1 fixes as a duration literal only so that point ordering stays decidable at import. ### 6.2 Procedural Bounded Duration (`TimeSpec`) Where procedural timing is permitted (e.g., cadence intervals, scenario wait steps), a bounded random TimeSpec may be used: ```json { "random": { "min": "500ms", "max": "2.5s" } } ``` --- ## 7. Diagnostic Error Code Standard To ensure consistent error reporting between structural schema validation, semantic validation, and runtime execution, all diagnostic errors must use the following standard codes: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_SCHEMA_VALIDATION` | Structural | Missing required fields, invalid JSON types, or malformed top-level shapes. | | `ERR_UNKNOWN_FIELD` | Structural / Semantic | Behavior-bearing block contains an undeclared property. | | `ERR_UNSUPPORTED_VERSION` | Structural | Document `xzbt` attribute is not `"0.1"`. | | `ERR_INVALID_ID` | Structural / Semantic | Identifier fails `^[a-z][a-z0-9_-]*$` regex or exceeds length limits. | | `ERR_INVALID_REFERENCE` | Semantic | Reference path does not resolve to an existing declared resource or property. | | `ERR_TYPE_MISMATCH` | Semantic / Runtime | Provided value or expression type does not match target property type. | | `ERR_CYCLIC_DEPENDENCY` | Semantic | Directed cycle detected in bindings or reference dependencies. | | `ERR_OUT_OF_BOUNDS` | Semantic / Runtime | Constant or default value violates min/max clamps. | | `ERR_INVALID_OPERATOR` | Semantic | ValueSpec `op` or ConditionSpec `op` is not in the recognized operator set. | | `ERR_INVALID_ARITY` | Semantic | ValueSpec `args` array length does not match operator requirement. | | `ERR_INVALID_DURATION` | Structural / Semantic | Duration string violates single-unit regex or contains negative values. | | `ERR_UNSUPPORTED_TARGET` | Semantic / Runtime | An action, binding, or resolution stage addresses a target that does not expose that operation. | | `ERR_CONFLICTING_BINDING` | Semantic | More than one enabled ordinary binding writes a scalar target. | | `ERR_INVALID_TRANSITION` | Semantic / Runtime | A transition is incompatible with its target, easing, scope, or duration. | | `ERR_DISPATCH_BUDGET` | Runtime | Ordinary event/action work exceeded the per-tick dispatch budget. | | `WARN_CLOCK_STALL` | Runtime | Elapsed wall time or accumulated work exceeded the fixed-step per-turn limits and was discarded. | | `WARN_CLEANUP_FORCED` | Runtime | A cleanup owner reached its deadline and force-disposed remaining resources. | | `INFO_AUDIO_UNLOCK_SKIP` | Runtime | One or more pre-unlock one-shots were intentionally not replayed. | | `INFO_AUDIO_PAUSE_SKIP` | Runtime | One or more one-shots invoked while paused were intentionally not replayed. | | `ERR_INVALID_NODE_TYPE` | Semantic | Audio graph node `type` is not a member of Audio Graph Node Set 0.1. | | `ERR_NODE_LIMIT_EXCEEDED` | Semantic | An authoring limit is exceeded (oscillator partials, resonator modes, expanded nodes or routes per sound, or automation tracks or points per expanded sound). | | `ERR_INVALID_RANGE_ORDER` | Semantic / Runtime | Paired bounds (e.g. `sample-hold` `min`/`max`) are not in strictly increasing order — literal pairs at import, resolved pairs at node instantiation — or automation `at` values are not strictly increasing (import). | | `ERR_INVALID_ROUTE` | Semantic | An audio or modulation route is structurally resolvable but illegal under the audio graph legality rules. | | `ERR_NO_AUDIBLE_PATH` | Semantic | A sound's expanded audio graph has no chain of audio routes from a non-control source to `output`. | | `ERR_COMPONENT_RECURSION` | Semantic | An audio component instantiates itself transitively, or component nesting exceeds 8 levels. | | `WARN_AUDIO_RATE_CLAMP` | Runtime | A frequency field was clamped to the device's `audioMaxFrequency` at node instantiation. | | `ERR_AUTOMATION_CONFLICT` | Semantic | More than one writer directly controls one property: two automation tracks on one recipe instance or one visual scope, or a visual behavior and an automation track on one object channel. | | `ERR_INDETERMINATE_ONESHOT` | Semantic | A `oneshot` recipe has no computable finite ending; its audible path begins at an unbounded source. | | `WARN_AUTOMATION_FALLBACK` | Runtime | An `exponential` automation segment had a zero or sign-crossing endpoint and fell back to linear interpolation. | | `WARN_VOICE_LIMIT` | Runtime | A voice ceiling was reached; an instance was evicted or a request refused. | | `WARN_AUDIO_NONFINITE` | Runtime | A non-finite sample reached the master chain and the containing block was muted. | | `WARN_AUDIO_UNAVAILABLE` | Runtime | No audio device is available; the performance continues silently. | | `ERR_INVALID_SYSTEM_TYPE` | Semantic | A `visuals.systems.` `type` is not a member of the Visual System Set 0.1. | | `ERR_INVALID_PRIMITIVE_TYPE` | Semantic | A visual object `type` is neither a member of Visual Primitive Set 0.1 (the fourteen geometry primitives of 17.9) nor the `component` object type 18.1 adds beside it. | | `ERR_INVALID_PATH` | Semantic | A path command sequence is structurally valid but illegal: it does not begin with `move`, or it closes a subpath that is not open. | | `ERR_VISUAL_LIMIT_EXCEEDED` | Semantic / Runtime | A visual authoring or runtime ceiling of the centralized table of 19.5 is exceeded (layers, group nesting, vertices, path commands, spline points, gradient stops, filters, declared systems, expanded static objects, particle capacity, emitter capacity, repeater count, behaviors per object, declared or referenced fields, trail length, link count and linked population, automation tracks and points, post-effect entries). | | `WARN_VISUAL_APPROXIMATION` | Runtime | A declared appearance feature is unavailable on the active renderer and the documented fallback was used. | | `ERR_INVALID_DISTRIBUTION_TYPE` | Semantic | A placement distribution `type` is not a member of the placement distribution set of 18.3. | | `ERR_INVALID_DISTRIBUTION` | Semantic | A placement distribution is structurally valid but illegal in its context, such as an index-driven placement on a continuous-rate emission. | | `ERR_INVALID_BEHAVIOR_TYPE` | Semantic | A behavior `type` is not a member of Visual Behavior Set 0.1. | | `ERR_INVALID_BEHAVIOR_TARGET` | Semantic | A behavior addresses a channel outside its permitted set, or one the owning primitive does not have. | | `ERR_INVALID_FIELD_TYPE` | Semantic | A `visuals.fields.` `type` is not a member of the procedural field set of 18.7. | | `ERR_MORPH_INCOMPATIBLE` | Semantic | A `morph` behavior's source and target differ in `type`, point count, or spline `mode`. | | `ERR_UNBOUNDED_EMISSION` | Semantic | A particle system or emitter declares emission but neither a per-item `lifetime` nor a total `limit`, so it can create items without bound. | | `ERR_INVALID_EFFECT_TYPE` | Semantic | A `visuals.effects` entry `type` is not a member of the post-effect vocabulary of 19.4. | | `WARN_VISUAL_CEILING` | Runtime | An aggregate runtime ceiling of 19.5 was reached: work was shed or a spawn refused, and the exhibit continues. | ## 8. Shared value resolution, bindings, and transitions ### 8.1 Resolution pipeline and target capabilities For each supported target, evaluate: ```text base -> binding -> automation -> winning override -> modulation -> safety clamp ``` Stages that the target does not expose are absent, not identity hooks available to authors. The shared 0.1 registry is: | Target family | Type and base | Binding target | Automation | Override | Additive modulation | Safety clamp | | --- | --- | :---: | :---: | :---: | :---: | --- | | `parameters.` | Declared primitive; stored user value | Yes | No | Yes | No | Declared `min`/`max`; integer targets round to the nearest integer after interpolation and before clamping | | `state.` | Declared primitive; current state value | Yes | No | Yes | No | Declared `min`/`max` | | `audio.buses..gain` | Number; bus `gain` ValueSpec | Yes | Yes | Yes | Yes | `[0, 4]` in 0.1 before master output protection | | `visuals.camera.` | Number; camera `x`, `y`, `zoom`, `rotation`, `focalLength` | Yes | Yes | Yes | Yes | The per-field range of 19.3 | | `visuals.layers..opacity` | Number; layer `opacity` | Yes | Yes | Yes | No | `[0, 1]` | | `visuals.systems..visible` | Boolean; system `visible` | Yes | No | Yes | No | — | | `visuals.effects[].` | Number; the post-effect parameter | Yes | Yes | Yes | No | The parameter's range in 19.4 | `signals.*` and `modulators.*` are read-only sources and never binding or action targets. `instances.*` is a runtime action-addressing namespace, not a ValueSpec or binding namespace. Sound inputs are sampled at sound invocation and cannot be ordinary binding targets. The four visual rows are the whole visual surface: section 19.1 adds them and adds nothing else, so per-object geometry, transform, and style properties, procedural-system fields, behavior fields, field strengths, and emitter rates are not binding, `set`, or `override` targets in 0.1. Audio recipe-instance properties and any target family absent from this table remain unsupported until their subsystem contract adds an explicit capability row. Merely being numeric does not grant automation, override, or modulation support. Parameters store user configuration separately from exhibit defaults. State stores simulation values separately from parameters. State is not an implicit layer above user configuration. References read the value resolved for the current logical tick. Parameter controls read and edit stored user values and show an override indicator whenever any live override exists for the parameter, including a masked or releasing override. Within one logical tick, resolve the directed dependency graph in topological order. Each source reference observes the source's resolved value for that same tick. Dependency cycles are `ERR_CYCLIC_DEPENDENCY`; the runtime must not insert an undocumented previous-tick delay. ### 8.2 BindingSpec The canonical fields are `source`, `target`, `scale`, `offset`, `clamp`, `smoothing`, and `when`. Earlier planning-only spellings `from`, `to`, and `transform` are not 0.1 aliases and are rejected as `ERR_UNKNOWN_FIELD`. ```json { "source": "state.machine-load", "target": "audio.buses.deep.gain", "scale": 0.5, "offset": 0.5, "clamp": [0, 1], "smoothing": "50ms", "when": { "op": "gt", "left": { "ref": "parameters.activity" }, "right": 0 } } ``` `source` and `target` are required reference paths. `scale` and `offset` are finite numbers with defaults `1` and `0`. `clamp`, when present, is a two-number inclusive `[minimum, maximum]` array with `minimum <= maximum`. `smoothing` is a DurationSpec and defaults to `"0ms"` (disabled). `when` is a ConditionSpec and defaults to `true`. For numeric sources and targets, first calculate `clamp(source * scale + offset)`. A nonnumeric binding must omit `scale`, `offset`, `clamp`, and nonzero `smoothing`, and its source and target types must match exactly. A false `when` disables the binding for that tick and exposes the target's base stage. When a binding becomes enabled, its smoother initializes to the transformed source value; disabled time is not replayed. Nonzero smoothing is the deterministic one-pole update below, evaluated once per fixed logical tick. `tau` is the authored smoothing duration in seconds, `dt` is the fixed logical step, `x` is the transformed and binding-clamped input, and `yPrevious` is the prior enabled tick's binding output: ```text alpha = 1 - exp(-dt / tau) y = yPrevious + alpha * (x - yPrevious) ``` At most one ordinary binding may target a scalar property. Multiple writers are `ERR_CONFLICTING_BINDING` even when their `when` conditions appear mutually exclusive; conditional exclusivity is not a precedence mechanism. A binding to a missing or read-only target is `ERR_UNSUPPORTED_TARGET`. ### 8.3 Override action and precedence An override action has required `type: "override"`, `target`, `value`, and `scope` fields. It may also contain common action fields plus `priority`, `duration`, and `transition`. | Field | Contract | | --- | --- | | `target` | A target whose capability row permits override. | | `value` | ValueSpec sampled once when the override instance activates; result must match the target type. | | `scope` | `"scenario"` or `"duration"`. | | `priority` | Optional integer from `-1000` through `1000`. If omitted under a scenario, inherit the scenario instance priority; otherwise use `0`. | | `duration` | Required and greater than `0ms` for duration scope; forbidden for scenario scope. Time begins at activation, including while masked. | | `transition` | Optional OverrideTransitionSpec; defaults to zero-duration `in` and `out` with `linear` easing. | Each activated override receives a monotonically increasing runtime `activationSequence` within the performance and a runtime ID `instances.override-`. An author `id`, when supplied, identifies the action definition and does not replace the runtime ID. The live winner is the greatest tuple `(priority, activationSequence)`. Scope affects lifetime only: duration scope receives no precedence advantage over scenario scope. Underlying binding and automation stages continue to evaluate while masked. Overrides also advance their own durations and envelopes while masked. When a winner changes, the newly winning override's attack begins from the target's current pre-modulation resolved value, preventing a discontinuity. If that override had already completed its attack while masked, it takes effect immediately at its sampled value. ### 8.4 TransitionSpec and interruption A numeric `set` action accepts `{ "duration": DurationSpec, "easing": Easing }`, with defaults `"0ms"` and `"linear"`. An override accepts `{ "in": DurationSpec, "out": DurationSpec, "easing": Easing }`, with both durations defaulting to `"0ms"`. Easing is one of `linear`, `ease-in`, `ease-out`, or `ease-in-out`, defined for normalized `t` in `[0,1]`: ```text linear: t ease-in: t * t ease-out: 1 - (1 - t) * (1 - t) ease-in-out: 2 * t * t when t < 0.5 1 - ((-2 * t + 2)^2) / 2 otherwise ``` Zero duration is an immediate step. Non-numeric targets permit only zero-duration transitions; any nonzero duration is `ERR_INVALID_TRANSITION`. Numeric interpolation uses double precision. Integer targets round half away from zero after interpolation and then apply their declared clamp. An attack interpolates from the pre-modulation value visible immediately before the override becomes winner to its sampled override value. Release begins when duration expires, its scenario owner terminates, or the override is explicitly removed. Release output on each tick is: ```text lerp(currentLowerValue, releaseStartValue, 1 - easing(elapsed / outDuration)) ``` `currentLowerValue` is recomputed on every tick without the releasing override, so stored user edits, bindings, and automation remain observable during release. `releaseStartValue` is the override's actual pre-modulation output when release began. A zero-duration release removes the override before that tick resolves. A higher-priority interruption does not cancel a lower override. The lower override continues its lifetime and release while masked. If the higher override releases, the next live winner is selected and its current envelope value is used. Reactivating the same action creates a distinct override instance with a new activation sequence. The safety clamp always runs after modulation. A constant override or binding value outside the target's legal range is `ERR_OUT_OF_BOUNDS` at validation; a dynamic result is clamped at runtime and emits a rate-limited `ERR_OUT_OF_BOUNDS` diagnostic rather than poisoning the graph. ### 8.5 Required resolution traces The normative deterministic traces are stored with the GC3 tests. They cover: | Case | Expected behavior | | --- | --- | | Bus gain is bound to activity, then directly overridden | Override supplies the pre-modulation value; the binding continues underneath and its current value becomes the release destination | | Activity is overridden and referenced by a binding | The binding observes resolved activity during the same logical tick | | User edits stored activity while its override is active | Stored edit persists; release approaches the updated lower value | | Two overrides compete | Higher priority wins; equal priority uses greater activation sequence | | Duration and scenario overrides compete | Priority and activation sequence decide, not scope | | A target has legal additive modulation | Modulation follows the winning override; safety clamp runs last | | Binding conditions, smoothing, clamps, and invalid graphs | Disabled binding exposes base, re-enable initializes smoothing, and conflicts/cycles are rejected | ## 9. Time, audio alignment, and random evaluation ### 9.1 Fixed logical clock The 0.1 logical step is exactly `1000 / 60` milliseconds. Runtime time is represented by an integer `tickIndex`; logical seconds are `tickIndex / 60`. Rendering reads the most recently published state and never advances simulation time. The foreground driver accumulates nonnegative elapsed monotonic time. One browser turn executes at most eight logical ticks. A single driver observation contributes at most `250ms`; excess elapsed time is discarded and emits one rate-limited `WARN_CLOCK_STALL` diagnostic. After eight ticks, the driver yields and retains at most one step of accumulator; additional accumulated time is discarded with the same diagnostic. These bounds prevent catch-up bursts. They are runtime responsiveness policy, not permission for accelerated tests to skip authored ticks. Each logical tick uses this order: 1. Increment `tickIndex` and latch queued user/external inputs in arrival-sequence order. Tests replace external inputs, including audio analysis, with deterministic fixtures. 2. Advance time-based modulators and automation; resolve the dependency graph in topological order. 3. Evaluate cadence, event conditions, and scenario timelines once against that tick's resolved snapshot; enqueue work using document order as the final tie-break. 4. Drain ordinary event/action work up to the GC5 dispatch budget. Actions in one array execute in document order; a state mutation invalidates affected resolved values before the next action executes. 5. Advance owned audio/visual instance state and perform mandatory cleanup, which is not charged to the ordinary dispatch budget. 6. Schedule eligible audio work within the lookahead horizon, then publish the immutable state read by rendering and generated UI. Application pause and document visibility are independent pause reasons. Logical progression and audio are paused while either reason is present. On any transition from paused to running, reset the monotonic wall-time anchor while preserving `tickIndex` and the sub-step accumulator. Do not add paused wall time or replay missed work. Removing the visibility reason does not remove an explicit application pause. ### 9.2 Audio clock mapping and unlock The initial audio scheduling lookahead is `100ms`, refilled after every logical tick. Maintain an anchor pair `(logicalSeconds, audioContext.currentTime)` and map an eligible logical start to the audio clock relative to that pair. On audio unlock or resume, establish a fresh anchor at the current logical position. Engine-owned future starts from an obsolete anchor are cancelled and rescheduled where the Web Audio node contract permits it. If audio is locked, logical sound invocations still receive their deterministic IDs, ownership, sampled values, and nominal logical intervals, but create no Web Audio nodes. At unlock: - a one-shot whose logical start is earlier than the unlock position is skipped, whether expired or partially elapsed; emit one rate-limited `INFO_AUDIO_UNLOCK_SKIP` count rather than replaying it; - a continuous sound that is still logically owned starts at its current logical phase/state, not from its original attack, and applies an engine-owned `20ms` anti-click ramp from silence to its current gain; - future invocations inside the new lookahead are scheduled normally. Pause or visibility loss suspends the AudioContext after cancelling engine-owned future starts. Resume re-anchors and refills the horizon. Accelerated logical tests validate scheduling decisions only; audible behavior, anti-click ramps, and real-time soak acceptance require browser/audio observations. ### 9.3 Seed normalization and PRNG An authored numeric seed must be an integer in `[0, 4294967295]` and is normalized with unsigned 32-bit semantics. For `"random"`, obtain one unsigned 32-bit integer from `crypto.getRandomValues`, store it with the performance record, and use that recorded integer for every subsequent derivation. Failure to obtain entropy is a startup error; time or `Math.random()` is not a fallback. XZBT 0.1 uses `xoshiro128**` with unsigned 32-bit arithmetic. A stream seed is FNV-1a-32 over the UTF-8 bytes of: ```text xzbt-0.1\0\0\0 ``` Expand that hash into four words with SplitMix32. If all four words are zero, set the fourth word to `1`. Each sample converts the next unsigned result to `[0,1)` by division by `4294967296`. Integer ranges use unbiased rejection sampling; weighted choices consume one sample and select by cumulative positive weight in document order. The reserved domains are `cadence`, `scenario`, `visual`, `sound`, and `manual-sample`. Stable instance keys use the declared definition ID plus that definition's monotonically increasing invocation ordinal within its domain. A subsystem may add a documented child key but may not draw from another domain. Rendering consumes no procedural stream. Manual SAMPLE always uses `manual-sample`; therefore it cannot perturb cadence, scenario, visual, or automatic sound choices. Random and weighted-choice ValueSpecs are sampled once at the containing object's documented instantiation or invocation boundary. Nested random ValueSpecs use the same owning stream in depth-first, property-document order. Evolving randomness belongs to modulators and consumes only the owning modulator/visual stream at logical ticks. Reproducibility means identical procedural decisions for the same XZBT runtime version, normalized seed, stable exhibit definition, and logical input sequence. It does not promise identical pixels, floating-point audio samples, or browser timing across devices. ## 10. Ownership, failure, and bounded dispatch ### 10.1 Ownership propagation Every action dispatch carries an ownership context: a scenario instance owner or the performance root. An event action passes the same context to the invoked event; nesting never resets ownership. Sound, spawn, subscription, and override resources inherit that context unless their resource contract permits `ownership: "persistent"` and the action explicitly requests it. Persistent resources transfer to the performance root. Unsupported persistent ownership is `ERR_UNSUPPORTED_TARGET`. A scenario instance owns its future timeline records, repeats, relative actions, subscriptions, scenario-scope overrides, duration overrides it creates, and nonpersistent continuous audio/visual resources, including resources created indirectly through nested events. A duration override owned by a scenario begins release at its authored deadline or owner termination, whichever occurs first. `set` changes are persistent mutations, not owned resources. A successfully executed `set` is never rolled back because a later action, hook, or scenario fails. ### 10.2 Hook order and allowed termination work `onStart` runs after the instance owner and deterministic streams exist but before timeline activation. It may use the full Action Model. A critical `onStart` failure cancels remaining start actions, prevents timeline activation, and terminates the instance as `FAILED`. Termination first blocks new ordinary dispatch for the owner and cancels its future timeline/repeat/relative work. Exactly one termination hook then runs: | Cause | Hook | Terminal state after cleanup | | --- | --- | --- | | Natural duration/timeline completion | `onComplete` | `COMPLETED` | | Manual cancel, replacement, exhibit deactivation | `onCancel` | `CANCELLED` | | Critical startup, ordinary action, or nested-event failure | `onCancel` | `FAILED` | Termination hooks execute their actions in document order and may contain only `set` and one-shot `sound` actions. Termination sounds are owned by the cleanup owner and may not request persistent ownership. `override`, `event`, `spawn`, `remove`, and `control` actions in a termination hook are validation errors. A hook action failure is diagnosed and remaining hook actions continue regardless of `critical`; it cannot suppress cleanup or replace the original terminal cause. After the hook attempt, detach subscriptions, release overrides and continuous audio, remove visual systems, clear scheduler records, and release runtime references. Release work transfers to an engine cleanup owner. Each release uses the shorter of its authored release and `5s`; the cleanup owner has an absolute deadline of five seconds of advancing logical time, after which remaining resources are force-disposed with `WARN_CLEANUP_FORCED`. Destroying or replacing the entire performance force-disposes immediately after its deactivation hook; it does not leave a detached timer. ### 10.3 Condition trigger state A condition trigger has `disarmed`, `holding`, and `armed` states. A false evaluation clears its hold timer and arms it. Once armed, continuous true evaluations accumulate logical ticks; it fires once when the declared `for` interval is met, then becomes disarmed. It cannot fire again until at least one later logical tick evaluates false. Startup with a true condition begins disarmed, preventing an unobserved pre-launch edge from firing. ### 10.4 Deferred starts Each scenario definition may have at most one deferred request. Its creation tick and sampled trigger inputs are retained. A later opportunity for the same definition neither adds a request nor extends expiry. The expiry is `eligibility.timeout` when present, otherwise `5m`, measured in advancing logical time. At each tick, remove expired requests first. Then consider the remaining queue in descending scenario priority, ascending creation tick, and scenario document order. Immediately before dispatch, recheck that the definition is enabled, its eligibility is true, cooldown permits it, and its concurrency conflict has cleared. A failed recheck leaves the request pending until a later tick or expiry; it does not resample the original trigger. ### 10.5 Dispatch budget and feedback Static validation rejects direct and indirect event invocation cycles and detectable event/scenario trigger feedback cycles as `ERR_CYCLIC_DEPENDENCY`. Runtime uses a second backstop because data-dependent feedback may remain. One logical tick permits at most `1024` ordinary dispatch units. Entering an event consumes one unit and each attempted action consumes one unit, including skipped `when`/`chance` actions. When the next unit would exceed the budget, emit `ERR_DISPATCH_BUDGET`, discard the remaining ordinary queue for that tick, and terminate each scenario owner represented by discarded or currently executing feedback work as `FAILED`. Unowned work is dropped. Mandatory termination hooks and cleanup are budget-exempt and use a separate hard limit of `256` termination-hook actions per owner; exceeding it truncates the hook and continues cleanup. The maximum nested event depth remains 16 and is checked before entering the next event. Budget and depth failures are critical runtime failures for an owned chain. Diagnostics include tick, owner, event/action path, consumed units, and discarded queue count. ## 11. Contract completion register Complete shared contracts before implementing dependent subsystems. Use PRD section numbers as stable lookup references. | Contract | Existing PRD input | Required completion | Status | | --- | --- | --- | --- | | Document/schema | 9-14, 113-116, 121 | Top-level shapes, unknown-field policy, identifier regex, reference paths, standard diagnostic codes | **Complete (Rev 0.2)** | | Values and conditions | 15-21, 33 | Operator arity/table, division-by-zero protection, sampling timing boundaries, edge-trigger re-arming | **Complete (Rev 0.2)** | | References and bindings | 13, 17, 31-32 | Target-capability table, instance/input scope, evaluation order, cycles, disabled bindings, exact smoothing | **Complete (Rev 0.3 / GC3)** | | Actions and transitions | 22-30 | Shared `set`/`override` fields and defaults, override target matrix, interrupted transitions, instance IDs; subsystem action matrices remain with their subsystems | **Shared contract complete (Rev 0.3 / GC3)** | | Audio | 34-60 | Complete recipe/component shapes, node defaults, automation timing, clamp implementation, one-shot endings and tails, unlock behavior, master protection contract | **Complete (Rev 0.4 / Phase 3a-3c):** units, graph objects, all sixteen node types, routing, modulation, graph legality, authoring limits, components, sounds/recipes, buses, automation tracks and precedence, lifecycle states and release, determinable one-shot endings, voice ceilings, unlock and pause behavior, and the master-protection contract shape. Master-protection *values* (peak ceiling, tolerance, release behavior) are provisional pending GC6 measurement | | Cadence | 61-68 | Exact selection/cooldown/overlap fields, clocks, pool collisions, pending audio, manual sampling isolation and continuous-sample termination | **Complete (Rev 0.9 / Phase 5):** section 20 fixes cadence classes, clocks, interval ranges, selection algorithm, cooldown, overlap policy, anti-repetition relaxation, minimum automatic gap, ambient maintenance, and manual SAMPLE PRNG stream isolation | | Visuals | 69-89 | Primitive/system/behavior fields, angle and motion units, transform order, depth projection, field behavior, morph compatibility, effect approximation and limits | **Complete (Rev 0.7 / Phase 4a-4c):** section 17 fixes the pipeline, canonical units and the angle convention, the `visuals` container, layers, the scene model and coordinate/fit modes, depth sign and sorting, the fourteen primitives, common properties, transform composition order, appearance and the safe blend set, paths and splines, and the once-at-instantiation resolution boundary. Section 18 fixes visual components and their `inputs` scope, particle systems and their normative integrator, the nine placement distributions, emitters and exact emission timing, repeaters and the `repeat.*` scope, the seventeen-behavior vocabulary and its channel set, the six procedural fields and their normative coherent-noise function, and trails, ribbons, and links. Section 19 fixes visual automation and its two declaration scopes and loop modes, the four visual rows it adds to the section 8.1 table and nothing beyond them, the persistent and spawned system lifecycle and its ownership, the camera matrix and both projection modes, the seven post-effects and the boundary of permitted approximation, and the centralized runtime ceilings for the whole engine. Aggregate ceiling *values* are provisional pending the slice 4h GC6 measurement | | Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; full trigger/timeline shapes remain for Phase 6 | **Shared lifecycle contract complete (Rev 0.3 / GC5); Event Model 0.1 complete (Rev 0.9 / Phase 5 in section 20); scenario triggers/timelines Phase 6** | | Generated UI | 103-107 | Widget compatibility, button actions, parameter validation and override display, group/control ordering | Subsystem contract (Phase 7) | | Runtime/library | 108-112, 117-127 | Clock/audio synchronization, stalls, seed/stream derivation complete in GC4; import equality, update compatibility, transactions, failure recovery, persistence schema remain | **Shared clock/PRNG contract complete (Rev 0.3 / GC4); subsystem remainder Phase 1/8** | ## 12. Contract template and conformance artifacts Each construct must record its JSON shape; required and optional fields; types, units, ranges, and defaults; supported ValueSpec fields and evaluation timing; read/write namespaces; lifecycle and ownership; precedence; validation errors; runtime failure behavior; and resource costs or limits. Supply a valid minimal example, a meaningful composition example, invalid cases with expected diagnostics, and expected semantic traces where timing or ordering matters. Two contrasting complete exhibits must exercise parameters, sound, visuals, and a temporary scenario override early in development. ## 13. Library identity, update compatibility, and build boundary ### 13.1 Import identity After successful UTF-8 decoding, remove one leading byte-order mark if present. The resulting source bytes are the import payload. Compute SHA-256 over those bytes; equality requires both the same `meta.id` and the same digest. An identical import is a no-op and does not change cached timestamps, source associations, selected exhibit, or parameter values. The same ID with a different digest is a replacement candidate even if parsed values appear equivalent. Whitespace, property-order, or numeric-spelling changes may therefore require confirmation; this conservative rule preserves document-order semantics and exact provenance. A changed candidate is parsed and fully validated before the user is offered explicit replace/cancel choices. Invalid candidates never replace the cached valid definition. ### 13.2 Parameter reconciliation After an explicit replacement choice, reconcile stored parameters by parameter ID: | Change | Result | | --- | --- | | Same type and still valid | Preserve stored value. | | `number`/`integer` bounds changed | Clamp to the new inclusive range and show a notice; integer values are rounded half away from zero before clamping. | | Enum values changed | Preserve only if the stored token remains declared; otherwise use the new default and show a notice. | | Type changed, including `number` to `integer` | Use the new default and show an incompatibility notice. | | Parameter removed | Delete its stored value after successful activation. | | Parameter added | Initialize from the new default. | Do not mutate the old cached definition or settings while validating or preparing a candidate. Preparation may allocate parsed data and inert compiled plans but must not start timers, render visible output, create audible nodes, or persist replacement state. On successful candidate activation, atomically commit the new definition, reconciled settings, digest, and source metadata, then dispose the old performance. If activation fails after the old performance has stopped, dispose the candidate and attempt a fresh activation of the old cached definition with its unchanged saved settings. Report both failures if recovery fails; never represent the failed candidate as active. ### 13.3 Reproducible standalone build Development uses separate source modules. The build must have pinned tool versions and inputs, stable module/asset order, no wall-clock timestamps or absolute paths in output, and a documented single command that produces `XZBT.html`. Two clean builds from the same revision must have identical SHA-256 digests. The artifact must contain all runtime code/assets/fonts and pass direct-file offline verification with no external runtime requests. Structural JSON Schema does not replace semantic validation. The internal schema and fixtures belong to 0.1 implementation work; public schema distribution and editor integration may follow later. ## 14. Audio Subsystem Contract — Sources and Control Sources (Phase 3a) This section opens the Audio contract required by section 11 (PRD 34-60). It covers the audio pipeline overview, canonical units, the shared audio-graph object shape, node-field resolution scope, the audio frequency ceiling, audio reproducibility scope, and the six source and control-source node types (PRD 36-42). Processing nodes, routing and modulation, graph legality, components, sound definitions, and buses (PRD 43-53, 55, 56, 59, 60) are specified in section 15 (Phase 3b). Automation tracks and precedence (PRD 54), the runtime lifecycle state machine and release (PRD 57), determinable one-shot endings, voice ceilings, master protection (PRD 58), and unlock and pause behavior (PRD 117-118) are specified in section 16 (Phase 3c). ### 14.1 Pipeline and scope The audio pipeline is, in order: audio primitives (14.7-14.12) → audio graphs (14.3) → reusable `components.audio.*` components (15.15) → sound recipes (15.16) → sound instances → declared buses (15.17) → bus processing → engine master protection → output (PRD 34). XZBT never implements a node, field, or function named after a specific exhibit's thematic sound. Every construct in this contract is generic. ### 14.2 Canonical units | Quantity | Unit | Notes | | --- | --- | --- | | frequency | Hz | Bounded above by `audioMaxFrequency` (14.5). | | detune | cents | `-4800` to `+4800` unless a node contract states otherwise. | | gain / amplitude | linear scalar | Not dB unless the field's contract says dB. | | filter and compressor gain | dB | Applies to `filter.gain`, `compressor.threshold`, and `compressor.knee` (15.3, 15.4). | | Q | unitless | Applies to `filter.q` (15.3). | | normalized controls | `0` to `1` | Polarity-free control ranges (impulse `amplitude`, `mix`, `damping`, waveshaper `amount`). | | pan | `-1` to `+1` | `-1` full left, `0` center, `+1` full right (15.8). | | time | DurationSpec 0.1 | Section 6; a procedural `TimeSpec` is permitted wherever a field's contract says DurationSpec. | | modulation depth | target property's unit | Section 15.13 (PRD 53). | ### 14.3 Audio graph objects An **audio graph object** is the shared container for every audio node network in an exhibit: ```json { "nodes": { "tone": { "type": "oscillator", "frequency": 220 }, "level": { "type": "gain", "gain": 0.4 } }, "routes": [ { "from": "tone", "to": "level" }, { "from": "level", "to": "output" } ] } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `nodes` | object keyed by node ID | Yes | Each value is a node object (14.4). An empty `nodes` object is `ERR_SCHEMA_VALIDATION`. | | `routes` | array | No (defaults to `[]`) | Route objects, specified in 15.12-15.13. A graph object with no route reaching `output` is `ERR_NO_AUDIBLE_PATH` unless it is a component graph, which terminates at its declared component output instead (15.15). | | `automation` | array | No (defaults to `[]`) | Automation tracks, specified in 16.1. Declared on the graph object, never on a node. | Audio graph objects are embedded at exactly three places, whose surrounding fields are specified in Phase 3b: * `audio.recipes.` — a named, reusable recipe graph (15.16). * `sounds..recipe` — an inline recipe graph (15.16, PRD 59). * `components.audio.` — a component graph, which adds `parameters` and `input` declarations (15.15, PRD 56). **Node identity.** Nodes are addressed by their key in the `nodes` object, matching how the document keys `parameters`, `state`, `audio.buses`, `sounds`, and `modulators`. A node object therefore carries **no** `id` field; supplying one is `ERR_UNKNOWN_FIELD`. Node keys follow the shared identifier rule (section 1.3) and are unique by construction; a key failing the identifier regex is `ERR_INVALID_ID`. **Reserved key.** `output` is reserved as the graph's audible sink and may not be declared in `nodes` (`ERR_INVALID_ID`). It is sink-only: `output` may not appear as a route `from` (15.14, PRD 55). **Node object fields.** | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `type` | string enum | Yes | A member of Audio Graph Node Set 0.1 (PRD 36): `oscillator`, `noise`, `impulse`, `constant`, `lfo`, `sample-hold`, `gain`, `filter`, `compressor`, `waveshaper`, `delay`, `reverb`, `stereo-pan`, `mixer`, `resonator`, or `component`. Any other value is `ERR_INVALID_NODE_TYPE`. | | *(type-specific fields)* | — | Per node | 14.7-14.12 and 15.1-15.8. | Per the strict unknown-field policy (section 1), any property on a node object that its `type` contract does not declare is `ERR_UNKNOWN_FIELD`. ### 14.4 Node-field resolution scope Numeric and enum fields on node objects are authored with ValueSpec 0.1 (section 4) and DurationSpec 0.1 (section 6) exactly where each field table says so. Every such field is resolved **once**, at the owning sound instance's instantiation boundary — the same boundary section 9.3 already fixes for `random` and `choose` sampling — and is constant for that node instance's lifetime. Nested ValueSpecs within one graph are sampled in depth-first, property-document order from the sound instance's own stream, per section 9.3. Node fields are **not** added to the section 8.1 target-capability table. A `BindingSpec`, `set` action, or `override` action addressing a node field (for example `sounds.hum.recipe.nodes.tone.frequency`) is `ERR_UNSUPPORTED_TARGET`. Section 8.1's existing `audio.buses..gain` row is unaffected and remains the supported external control surface for audio in 0.1. This restriction governs **external** targeting only. Graph-internal modulation — a route whose `to` addresses `node.property` (15.13, PRD 52-53) — is the supported mechanism for varying a node field over time and is fully specified in Phase 3b. Section 15.13 lists which properties accept modulation; a modulation route to any other property is `ERR_UNSUPPORTED_TARGET`. Automation tracks (PRD 54) are specified in sections 16.1 and 16.2. A track is declared on the graph object alongside `nodes` and `routes`, never on a node, so an `automation` field on a *node object* remains `ERR_UNKNOWN_FIELD`. Automation does not widen the external surface of 14.4: a track is authored inside the graph that owns the property, not in the document's binding list. ### 14.5 Audio frequency ceiling and validation staging ```text audioMaxFrequency = min(24000, sampleRate x 0.45) ``` `sampleRate` is the live `AudioContext.sampleRate`, which is a property of the playback device and is unknown while an exhibit is being imported or validated. The ceiling is therefore enforced in two tiers, and an implementation must not conflate them: 1. **Semantic stage (import, refresh, and validation tooling).** A frequency field whose authored value resolves to a literal outside its declared range, checked against the device-independent ceiling `24000`, is `ERR_OUT_OF_BOUNDS`. Validation never opens an `AudioContext` and never depends on audio hardware. 2. **Instantiation stage (node creation on a live context).** The runtime computes `audioMaxFrequency` from the actual `AudioContext.sampleRate` and **clamps** the resolved value into range. Clamping, not failing, is required: a cached exhibit authored on a 48 kHz device must still activate on a 44.1 kHz device. Each clamped node field raises `WARN_AUDIO_RATE_CLAMP` once per node instance. A hardcoded `24000` at instantiation is a contract violation even though it is the correct semantic-stage ceiling. ### 14.6 Reproducibility scope for audio sources Section 9.3 promises identical *procedural decisions*, not identical floating-point audio samples. This contract fixes where that line falls for audio sources: * **`noise` and `impulse` sample generation consumes no XZBT procedural stream.** Their per-sample output comes from the audio implementation's own generator and is not reproducible across runs or devices. It cannot perturb any seeded stream, because it draws from none. * **`sample-hold` does draw from a seeded stream.** Its held values are observable control decisions that can drive audible outcomes, so they are reproducible. It uses domain `sound` with the documented child key defined in 14.12. * **Every other source and control-source field** is a ValueSpec resolved once at instantiation from the sound instance's stream (14.4), and is therefore reproducible. Rendering and audio callbacks consume no procedural stream (section 9.3); a `sample-hold` tick is a logical decision, not a render step, and its draws occur on the node instance's own stream in tick order. ### 14.7 Oscillator (`oscillator`) ```json { "type": "oscillator", "waveform": "sine", "frequency": 440, "detune": 0 } ``` | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `waveform` | enum | `sine`, `triangle`, `square`, `sawtooth`, `custom` | `sine` | No — literal enum only. | | `frequency` | ValueSpec\ | `0.1` Hz to `audioMaxFrequency` (14.5) | `440` | Yes (14.4 scope). | | `detune` | ValueSpec\ | `-4800` to `+4800` cents | `0` | Yes (14.4 scope). | | `harmonics` | array | `1` to `64` entries | — | Required if and only if `waveform` is `custom`. | An `oscillator` is an audible source and may also serve as an audio-rate modulation source (PRD 41). **Custom partials.** Each `harmonics` entry is `{ "ratio": number, "gain": number, "phase": number }`: | Entry field | Range | Default | Meaning | | --- | --- | --- | --- | | `ratio` | `0.001` to `256` | required | Partial frequency as a multiple of the node's resolved `frequency`. | | `gain` | `0` to `1` | required | Partial linear amplitude relative to the fundamental. | | `phase` | `0` to `360` (degrees, `360` excluded) | `0` | Partial starting phase offset. | More than 64 entries is `ERR_NODE_LIMIT_EXCEEDED`. `harmonics` present while `waveform` is not `custom` is `ERR_UNKNOWN_FIELD`. `harmonics` absent while `waveform` is `custom` is `ERR_SCHEMA_VALIDATION`. An empty `harmonics` array is `ERR_SCHEMA_VALIDATION`. Entry values outside their range are `ERR_OUT_OF_BOUNDS`. At instantiation, any partial whose `ratio x frequency` exceeds `audioMaxFrequency` is **omitted** from the realized waveform rather than aliased or clamped. Omission is a routine consequence of legal authoring and raises no diagnostic; it is not the `WARN_AUDIO_RATE_CLAMP` case of 14.5, which applies to the node's own `frequency`. The starting phase of a non-`custom` oscillator is runtime-defined and is not part of this contract; exhibits must not depend on the phase relationship between two independently created oscillators. **Minimal example:** `{ "type": "oscillator" }`. **Composition example:** `{ "type": "oscillator", "waveform": "custom", "frequency": 220, "harmonics": [{ "ratio": 1, "gain": 1 }, { "ratio": 2.7, "gain": 0.35, "phase": 90 }] }`. **Invalid case:** `{ "type": "oscillator", "waveform": "sine", "harmonics": [] }` → `ERR_UNKNOWN_FIELD` on `harmonics`. ### 14.8 Noise (`noise`) ```json { "type": "noise", "color": "pink" } ``` | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `color` | enum | `white`, `pink`, `brown` | `white` | No — literal enum only; `color` selects a fixed spectral shape, not a continuously varying parameter. | Spectral shapes, measured over `20` Hz to `20000` Hz: | `color` | Power spectral slope | Normalization | | --- | --- | --- | | `white` | `0` dB/octave (flat) | Unit RMS | | `pink` | `-3` dB/octave | Unit RMS | | `brown` | `-6` dB/octave | Unit RMS | The filter used to realize `pink` and `brown` is runtime-defined; its response must track the declared slope within `±3` dB across `20` Hz to `20000` Hz. All three colors are normalized to equal RMS so that changing `color` does not change perceived level. A `noise` node has no built-in volume control; author a downstream `gain` node (15.2) for level. **Minimal example:** `{ "type": "noise" }`. **Invalid case:** `{ "type": "noise", "color": "grey" }` → `ERR_TYPE_MISMATCH`. ### 14.9 Impulse (`impulse`) ```json { "type": "impulse", "color": "white", "duration": "10ms", "amplitude": 1, "decay": "exponential" } ``` | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `color` | enum | `white`, `pink`, `brown` | `white` | No. Spectral shapes are those of 14.8. | | `duration` | DurationSpec 0.1 | `1ms` to `500ms` | `10ms` | Sampled once at instantiation (14.4). | | `amplitude` | ValueSpec\ | `0` to `1` | `1` | Yes (14.4 scope). | | `decay` | enum | `flat`, `linear`, `exponential` | `exponential` | No. | An impulse is inherently one-shot: it emits a single finite burst of `duration` and then produces silence. Given resolved amplitude `a`, resolved duration `d`, and normalized progress `p = t / d` over `0 <= p < 1`, the envelope applied to the `color` source is: | `decay` | Envelope | Value as `p → 1⁻` | | --- | --- | --- | | `flat` | `a` | `a` | | `linear` | `a x (1 - p)` | `0` | | `exponential` | `a x e^(-6.907755 x p)` (`-60` dB at `p = 1`) | `a x 0.001` | The envelope is exactly `0` for `p >= 1`. Because `flat` and `exponential` do not reach zero on their own, the runtime applies a terminal linear fade to zero over the final `min(1ms, d x 0.1)` of the burst; this fade is part of the contract, not an optional anti-click measure. A `duration` that resolves outside `[1ms, 500ms]` is `ERR_OUT_OF_BOUNDS`. **Minimal example:** `{ "type": "impulse" }`. **Invalid case:** `{ "type": "impulse", "duration": "1s" }` → `ERR_OUT_OF_BOUNDS`. ### 14.10 Constant (`constant`) ```json { "type": "constant", "value": 1 } ``` Control-only source (PRD 40). A `constant` may modulate numeric properties through a modulation route (15.13) but may never reach `output`, directly or through any chain; such a route is `ERR_INVALID_ROUTE` under graph legality (15.14). | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `value` | ValueSpec\ | `-1000` to `1000` | `1` | Yes (14.4 scope). | **Minimal example:** `{ "type": "constant" }`. **Invalid case:** `{ "type": "constant", "value": 5000 }` → `ERR_OUT_OF_BOUNDS`. ### 14.11 LFO (`lfo`) ```json { "type": "lfo", "waveform": "sine", "frequency": 1, "amplitude": 1, "polarity": "bipolar", "phase": 0 } ``` Control-only source (PRD 41). Audio-rate modulation uses an ordinary `oscillator` instead. An `lfo` reaching `output` through any chain is `ERR_INVALID_ROUTE` (15.14). | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `waveform` | enum | `sine`, `triangle`, `square`, `sawtooth` | `sine` | No. `custom` is oscillator-only and is `ERR_TYPE_MISMATCH` here. | | `frequency` | ValueSpec\ | `0.001` to `40` Hz | `1` | Yes (14.4 scope). | | `amplitude` | ValueSpec\ | `0` to `1000` | `1` | Yes (14.4 scope). Matches the `constant` output range; a modulation route's `depth` scales this into the target's unit (15.13). | | `polarity` | enum | `bipolar`, `unipolar` | `bipolar` | No. `bipolar` spans `[-amplitude, +amplitude]`; `unipolar` spans `[0, amplitude]`. | | `phase` | ValueSpec\ | `0` to `360` (degrees, `360` excluded) | `0` | Yes (14.4 scope). | **Phase origin.** An `lfo` phase is measured from the node instance's own start, not from a global transport: at the instant the node becomes active its waveform is at `phase` degrees. LFOs do not free-run across sound instances, so two instances of the same sound started at different times are phase-independent. This is a deliberate difference from a shared transport and keeps a sound instance's control behavior a function of its own age. A top-level `modulators.` entry may also declare `type: "lfo"` (PRD 33). The two are distinct constructs that merely share mathematics: a document modulator is a read-only shared source addressed as `modulators.*` (section 8.1) and uses `min`/`max` bounds, while this node is a graph-internal control source using `amplitude`/`polarity` and is not externally addressable (14.4). **Minimal example:** `{ "type": "lfo" }`. **Invalid case:** `{ "type": "lfo", "waveform": "custom" }` → `ERR_TYPE_MISMATCH`. ### 14.12 Sample-Hold (`sample-hold`) ```json { "type": "sample-hold", "rate": 2, "min": -1, "max": 1, "slew": "0ms" } ``` Control-only source (PRD 42). A `sample-hold` reaching `output` through any chain is `ERR_INVALID_ROUTE` (15.14). See 14.11 for the distinction from a top-level `modulators.` sample-and-hold. | Field | Type | Range | Default | ValueSpec | | --- | --- | --- | --- | --- | | `rate` | ValueSpec\ | `0.01` to `100` Hz | `2` | Yes (14.4 scope). | | `min` | ValueSpec\ | `-1000` to `1000` | `-1` | Yes (14.4 scope). | | `max` | ValueSpec\ | `-1000` to `1000` | `1` | Yes (14.4 scope). | | `slew` | DurationSpec 0.1 | `0ms` to `1s` | `0ms` | Sampled once at instantiation (14.4). | Literal `min` and `max` that are not strictly increasing are `ERR_INVALID_RANGE_ORDER` at import. Where either bound is a resolved ValueSpec, ordering is checked once at node instantiation after 14.4 resolution and is likewise `ERR_INVALID_RANGE_ORDER`. **Tick schedule.** With resolved rate `r`, tick `k` (`k = 0, 1, 2, ...`) occurs at instance-relative time `k / r` seconds, measured from the node instance's start. Tick `0` draws the node's first held value; there is no pre-roll or default-held value before it. Because `rate` is resolved once (14.4), the tick period is fixed for the node instance's lifetime. **Draw and slew.** At each tick the node draws one uniform sample in `[min, max]`, then approaches it linearly from the previously held value over `slew` and holds until the next tick. A resolved `slew` longer than the tick period `1 / r` is clamped to that period, so the node always reaches its target before the next draw. **Procedural stream.** Per section 9.3, a subsystem may add a documented child key; this is that documentation. A `sample-hold` node instance uses domain `sound` with the stable instance key: ```text |node| ``` `` is the owning sound instance's key formed per section 9.3 from the sound definition ID and its invocation ordinal within the `sound` domain. `` is the node's key at recipe root, or, inside a component instance, the dot-joined chain of enclosing component-instance node keys followed by the node key (for example `chorus.voice-a.step`). The stream is created once when the node instance is created and is never reseeded; ticks consume exactly one sample each, in tick order. Two runs with the same normalized seed, the same exhibit, and the same logical input sequence therefore produce the same held sequence. **Minimal example:** `{ "type": "sample-hold" }`. **Invalid case:** `{ "type": "sample-hold", "min": 1, "max": -1 }` → `ERR_INVALID_RANGE_ORDER`. ### 14.13 Required traces before Phase 3a implementation is accepted 1. Each of the six node types validates its documented minimal example inside a complete graph object with zero diagnostics, and its documented invalid case with exactly the documented code. 2. A node object carrying an `id` field is `ERR_UNKNOWN_FIELD`, and a node keyed `output` is `ERR_INVALID_ID`. 3. Semantic validation rejects a frequency above `24000` without opening an `AudioContext`, and instantiation clamps against `min(24000, sampleRate x 0.45)` computed from the live sample rate, raising `WARN_AUDIO_RATE_CLAMP` rather than failing. 4. A node-field ValueSpec — for example `frequency: { "random": { "min": 220, "max": 440 } }` — samples once at instantiation and is stable for the node instance's lifetime. 5. An external `BindingSpec` targeting a node field fails with `ERR_UNSUPPORTED_TARGET`, confirming the 14.4 scope boundary. 6. Two `sample-hold` instances built from the same seed, exhibit, and invocation ordinal produce identical held sequences, and changing only the node key changes the sequence. ## 15. Audio Subsystem Contract — Processing, Routing, Components, and Buses (Phase 3b) This section completes the authoring surface of the audio subsystem: the processing and routing node types (PRD 43-51), the component instance node, audio routing and modulation (PRD 52-53), graph legality and authoring limits (PRD 55, and the authoring-time subset of PRD 58), reusable components (PRD 56), sound definitions and recipes (PRD 59), and buses (PRD 60). It builds directly on section 14: every node object declared here lives in a `nodes` map inside an audio graph object (14.3), its numeric fields follow the resolve-once scope of 14.4, its frequency fields follow the two-tier ceiling of 14.5, and it is externally unaddressable except through the modulation routes defined in 15.13. Automation tracks and precedence (PRD 54), the runtime lifecycle state machine and release (PRD 57), and the runtime half of the safety-limit and master-protection contract (PRD 58 voice ceilings, peak ceiling, numerical tolerance, release behavior, finite-sample handling) are specified in section 16 (Phase 3c). The master-protection *values* there remain provisional pending GC6 measurement. ### 15.1 Processing and routing node set | `type` | Class | Audio inputs | Audio output | Section | | --- | --- | --- | --- | --- | | `gain` | Processing | Many (summed) | Yes | 15.2 | | `filter` | Processing | Many (summed) | Yes | 15.3 | | `compressor` | Processing | Many (summed) | Yes | 15.4 | | `waveshaper` | Processing | Many (summed) | Yes | 15.5 | | `delay` | Processing | Many (summed) | Yes | 15.6 | | `reverb` | Processing | Many (summed) | Yes | 15.7 | | `stereo-pan` | Processing | Many (summed) | Yes | 15.8 | | `mixer` | Routing | Many (summed) | Yes | 15.9 | | `resonator` | Processing | Many (summed) | Yes | 15.10 | | `component` | Composite | Zero or one, per the component's `input` declaration | Yes | 15.11 | Every node that accepts audio input accepts any number of incoming audio routes and sums them; there is no per-input index in 0.1. Gain staging is explicit and uses `gain` nodes (PRD 50). ### 15.2 Gain (`gain`) ```json { "type": "gain", "gain": 1 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `gain` | ValueSpec\ | `0` to `4` | `1` | Yes (14.4 scope) | Yes, linear gain | Values above `1` are permitted for synthesis workflows and remain subject to master protection (PRD 43). **Minimal example:** `{ "type": "gain" }`. **Invalid case:** `{ "type": "gain", "gain": 8 }` → `ERR_OUT_OF_BOUNDS`. ### 15.3 Filter (`filter`) ```json { "type": "filter", "mode": "lowpass", "frequency": 800, "q": 0.7 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `mode` | enum | `lowpass`, `highpass`, `bandpass`, `notch`, `peaking`, `lowshelf`, `highshelf`, `allpass` | `lowpass` | No | No | | `frequency` | ValueSpec\ | `10` Hz to `audioMaxFrequency` (14.5) | `1000` | Yes | Yes, Hz | | `q` | ValueSpec\ | `0.0001` to `100` | `1` | Yes | Yes, unitless | | `gain` | ValueSpec\ | `-40` to `+40` dB | `0` | Yes | Yes, dB | | `detune` | ValueSpec\ | `-4800` to `+4800` cents | `0` | Yes | Yes, cents | `gain` is meaningful only for `peaking`, `lowshelf`, and `highshelf`; for the other modes it is ignored rather than rejected, so that a mode can be changed without restructuring the node. **Minimal example:** `{ "type": "filter" }`. **Invalid case:** `{ "type": "filter", "mode": "comb" }` → `ERR_TYPE_MISMATCH`. ### 15.4 Compressor (`compressor`) ```json { "type": "compressor", "threshold": -24, "knee": 30, "ratio": 12, "attack": "3ms", "release": "250ms" } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `threshold` | ValueSpec\ | `-100` to `0` dB | `-24` | Yes | No | | `knee` | ValueSpec\ | `0` to `40` dB | `30` | Yes | No | | `ratio` | ValueSpec\ | `1` to `20` | `12` | Yes | No | | `attack` | DurationSpec 0.1 | `0ms` to `1s` | `3ms` | Sampled once | No | | `release` | DurationSpec 0.1 | `10ms` to `1s` | `250ms` | Sampled once | No | Audio-rate modulation of compressor parameters is not required in 0.1 (PRD 45); a modulation route to any compressor property is `ERR_UNSUPPORTED_TARGET`. **Minimal example:** `{ "type": "compressor" }`. **Invalid case:** `{ "type": "compressor", "ratio": 40 }` → `ERR_OUT_OF_BOUNDS`. ### 15.5 Waveshaper (`waveshaper`) ```json { "type": "waveshaper", "shape": "soft-clip", "amount": 0.5, "oversample": "2x" } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `shape` | enum | `soft-clip`, `hard-clip`, `saturation` | `soft-clip` | No | No | | `amount` | ValueSpec\ | `0` to `1` | `0.5` | Yes | No | | `oversample` | enum | `none`, `2x`, `4x` | `none` | No | No | `amount` `0` is unity transfer for every shape, so a waveshaper can be authored inert and driven entirely by its resolved value. The exact transfer curve for each shape is runtime-defined but must be monotonic, odd-symmetric, and bounded to `[-1, 1]` for inputs in `[-1, 1]`. **Minimal example:** `{ "type": "waveshaper" }`. **Invalid case:** `{ "type": "waveshaper", "oversample": "8x" }` → `ERR_TYPE_MISMATCH`. ### 15.6 Delay (`delay`) ```json { "type": "delay", "time": "250ms", "feedback": 0.2, "mix": 0.5 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `time` | DurationSpec 0.1 | `0ms` to `10s` | `250ms` | Sampled once | Yes, milliseconds | | `feedback` | ValueSpec\ | `0` to `0.95` | `0.2` | Yes | No | | `mix` | ValueSpec\ | `0` to `1` | `0.5` | Yes | No | The feedback path is internal to the node and is controlled by the runtime (PRD 47). Authors may not build feedback by routing a node's output back into its own input chain; such a cycle is `ERR_CYCLIC_DEPENDENCY` under 15.14. `mix` is a dry/wet blend where `0` is fully dry and `1` is fully wet. A modulation route to `time` is expressed in milliseconds and is clamped so the resolved delay never leaves `[0ms, 10s]`. **Minimal example:** `{ "type": "delay" }`. **Invalid case:** `{ "type": "delay", "feedback": 1 }` → `ERR_OUT_OF_BOUNDS`. ### 15.7 Reverb (`reverb`) ```json { "type": "reverb", "size": 0.5, "decay": "2s", "damping": 0.5, "predelay": "0ms", "mix": 0.25 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `size` | ValueSpec\ | `0` to `1` | `0.5` | Yes | No | | `decay` | DurationSpec 0.1 | `50ms` to `30s` | `2s` | Sampled once | No | | `damping` | ValueSpec\ | `0` to `1` | `0.5` | Yes | No | | `predelay` | DurationSpec 0.1 | `0ms` to `500ms` | `0ms` | Sampled once | No | | `mix` | ValueSpec\ | `0` to `1` | `0.25` | Yes | No | The implementation is runtime-defined (PRD 48). The exhibit describes desired acoustic behavior, never Web Audio implementation details, and must not depend on a particular impulse response. Reverb properties are not modulatable in 0.1 because changing them requires rebuilding the underlying response. **Minimal example:** `{ "type": "reverb" }`. **Invalid case:** `{ "type": "reverb", "decay": "60s" }` → `ERR_OUT_OF_BOUNDS`. ### 15.8 Stereo pan (`stereo-pan`) ```json { "type": "stereo-pan", "pan": 0 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `pan` | ValueSpec\ | `-1` to `+1` | `0` | Yes | Yes, pan units | `-1` is full left, `0` is center, `+1` is full right (PRD 49). **Minimal example:** `{ "type": "stereo-pan" }`. **Invalid case:** `{ "type": "stereo-pan", "pan": 2 }` → `ERR_OUT_OF_BOUNDS`. ### 15.9 Mixer (`mixer`) ```json { "type": "mixer" } ``` A mixer accepts any number of audio inputs and exposes one audio output. It has no fields and no gain controls in 0.1; gain staging uses explicit `gain` nodes (PRD 50). Any property on a mixer node other than `type` is `ERR_UNKNOWN_FIELD`. A mixer is a convenience for graph readability: because every audio-accepting node already sums its inputs (15.1), a mixer is never required for correctness. ### 15.10 Resonator (`resonator`) ```json { "type": "resonator", "fundamental": 120, "modes": [ { "ratio": 1, "gain": 1, "decay": "1.2s" }, { "ratio": 2.7, "gain": 0.4, "decay": "800ms" } ], "mix": 1 } ``` | Field | Type | Range | Default | ValueSpec | Modulatable | | --- | --- | --- | --- | --- | :---: | | `fundamental` | ValueSpec\ | `0.1` Hz to `audioMaxFrequency` (14.5) | `120` | Yes | Yes, Hz | | `modes` | array | `1` to `16` entries | required | Per entry | No | | `mix` | ValueSpec\ | `0` to `1` | `1` | Yes | No | Each `modes` entry declares exactly one of `ratio` or `frequency`, never both and never neither (PRD 51): | Entry field | Type | Range | Default | Notes | | --- | --- | --- | --- | --- | | `ratio` | ValueSpec\ | `0.001` to `256` | — | Mode frequency as a multiple of the resolved `fundamental`. | | `frequency` | ValueSpec\ | `0.1` Hz to `audioMaxFrequency` | — | Absolute mode frequency; ignores `fundamental`. | | `gain` | ValueSpec\ | `0` to `1` | `1` | Linear amplitude of the mode. | | `decay` | DurationSpec 0.1 | `10ms` to `20s` | `1s` | Mode ring-down time to `-60` dB. | An entry declaring both `ratio` and `frequency`, or neither, is `ERR_SCHEMA_VALIDATION`. More than 16 entries is `ERR_NODE_LIMIT_EXCEEDED`. A mode whose resolved frequency exceeds `audioMaxFrequency` at instantiation is omitted, matching the custom-partial rule of 14.7. A resonator provides generic acoustic resonance and is never named or shaped after a themed sound (PRD 51). **Minimal example:** `{ "type": "resonator", "modes": [{ "ratio": 1 }] }`. **Invalid case:** `{ "type": "resonator", "modes": [{ "ratio": 1, "frequency": 200 }] }` → `ERR_SCHEMA_VALIDATION`. ### 15.11 Component instance (`component`) ```json { "type": "component", "use": "voice", "values": { "pitch": 330 } } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `use` | string | Yes | ID of a declared `components.audio.`. An unknown ID is `ERR_INVALID_REFERENCE`. | | `values` | object | No | Values for the component's exposed parameters, one ValueSpec each, resolved once at instantiation (14.4). | A key in `values` that the component does not expose is `ERR_UNKNOWN_FIELD`. An exposed parameter that has no `default` and receives no value is `ERR_SCHEMA_VALIDATION`. A supplied value outside the exposed parameter's declared range is `ERR_OUT_OF_BOUNDS`. A component instance behaves as an ordinary node in its enclosing graph: it exposes one audio output, and it accepts audio input if and only if the component declares `input: true` (15.15). Routing audio into a component that declares no input is `ERR_INVALID_ROUTE`. Its exposed parameters are modulation targets addressed as `.` (15.13). Nothing else inside the component is reachable from outside (PRD 56). ### 15.12 Audio routing A `routes` entry (14.3) is one of two forms, distinguished by whether `to` names a node or a node property (PRD 52): ```json { "from": "tone", "to": "filter" } ``` ```json { "from": "vibrato", "to": "tone.frequency", "depth": 18 } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `from` | string | Yes | A node key in the same graph, or the reserved `input` inside a component graph that declares `input: true`. `output` may never be a `from` (`ERR_INVALID_ROUTE`). | | `to` | string | Yes | Audio route: a node key in the same graph, or the reserved `output`. Modulation route: `.`. | | `depth` | ValueSpec\ | Modulation only | Required on a modulation route; present on an audio route it is `ERR_UNKNOWN_FIELD`. Resolved once at instantiation (14.4). | `from` or `to` naming a node key that the graph does not declare is `ERR_INVALID_REFERENCE`. A route whose `from` and `to` resolve to the same node is `ERR_INVALID_ROUTE`. Duplicate identical audio routes are collapsed to one and raise no diagnostic; duplicate modulation routes to the same property are summed (15.13), not collapsed. Because every audio-accepting node sums its inputs (15.1), route order within `routes` never affects the audible result. Route order does fix the depth-first ValueSpec sampling order of `depth` values within a graph, per section 9.3. ### 15.13 Modulation semantics Modulation depth is expressed in the target property's own unit (PRD 53). The modulatable property registry for 0.1 is exactly: | Node type | Property | Depth unit | | --- | --- | --- | | `oscillator` | `frequency` | Hz | | `oscillator` | `detune` | cents | | `gain` | `gain` | linear gain | | `filter` | `frequency` | Hz | | `filter` | `q` | unitless | | `filter` | `gain` | dB | | `filter` | `detune` | cents | | `delay` | `time` | milliseconds | | `stereo-pan` | `pan` | pan units | | `resonator` | `fundamental` | Hz | | `component` | `` | the exposed parameter's declared unit | A modulation route to any property absent from this table — including every `compressor`, `reverb`, `waveshaper`, `mixer`, `impulse`, and `noise` property, and every property of a control source — is `ERR_UNSUPPORTED_TARGET`. Merely being numeric does not grant modulation support, matching section 8.1's rule for the shared registry. **Sources.** A modulation route's `from` must be a control source (`constant`, `lfo`, `sample-hold`) or an `oscillator` used at audio rate (PRD 41). Any other node as a modulation source is `ERR_INVALID_ROUTE`. **Summation and clamping.** For a modulated property with resolved base value `b` and modulation routes `1..n` whose source outputs at the current instant are `s_i` with resolved depths `d_i`: ```text value = clamp(b + sum(s_i x d_i), property minimum, property maximum) ``` Multiple legal modulation routes are summed (PRD 53). The clamp uses the property's declared range from its node contract and is the audio application of the safety-clamp stage of section 8.1; it is not a separate precedence system. A control source's output is its own value in its own units before scaling: an `lfo` with `amplitude: 1` and `polarity: "bipolar"` contributes `[-d, +d]` to its target. Automation (PRD 54) sits between binding and override in the shared pipeline and is specified in sections 16.1 and 16.2. With no automation track on a property, its resolution is exactly base, then modulation sum, then safety clamp. ### 15.14 Graph legality and authoring limits The final expanded audio graph of a sound — the recipe graph with every component instance inlined — must satisfy all of the following (PRD 55). Each check names the diagnostic it emits. | # | Rule | Diagnostic | | --- | --- | --- | | 1 | Every node key matches the identifier rule and is not `output` | `ERR_INVALID_ID` | | 2 | Every node `type` is in Audio Graph Node Set 0.1 | `ERR_INVALID_NODE_TYPE` | | 3 | Every route `from` and `to` resolves to a declared node, `output`, or a legal `input` | `ERR_INVALID_REFERENCE` | | 4 | `output` is sink-only and never a route `from` | `ERR_INVALID_ROUTE` | | 5 | A control source (`constant`, `lfo`, `sample-hold`) never reaches `output` through any chain of audio routes | `ERR_INVALID_ROUTE` | | 6 | A source node (`oscillator`, `noise`, `impulse`, and every control source) never receives an audio route | `ERR_INVALID_ROUTE` | | 7 | The audio route graph is acyclic | `ERR_CYCLIC_DEPENDENCY` | | 8 | The modulation dependency graph is acyclic | `ERR_CYCLIC_DEPENDENCY` | | 9 | At least one audio route chain from a non-control source reaches `output` | `ERR_NO_AUDIBLE_PATH` | | 10 | A component never instantiates itself, directly or transitively | `ERR_COMPONENT_RECURSION` | | 11 | Component nesting depth is at most `8` | `ERR_COMPONENT_RECURSION` | | 12 | Graph limits are respected | `ERR_NODE_LIMIT_EXCEEDED` | Rule 5 is the enforcement point for the control-only status asserted in 14.10, 14.11, and 14.12. Rule 6 makes source nodes true graph roots. Rule 7 is what forbids unrestricted author-built feedback (PRD 47); a `delay` node's internal feedback path is not part of the route graph and does not violate it. The authoring-time limits enforced in Phase 3b are the subset of PRD 58 that a document can violate on its own: | Limit | Value | | --- | --- | | Expanded nodes per sound | `128` | | Routes per sound | `256` | | Component nesting depth | `8` | | Resonator modes | `16` | | Custom oscillator partials | `64` | The remaining PRD 58 limits — approximate one-shot voices (`64`) and approximate continuous sounds (`16`) — are runtime ceilings rather than document properties and belong to Phase 3c with the lifecycle contract (16.6). Automation tracks and points are not runtime ceilings: at most `64` tracks and `256` points per expanded sound are decidable after component expansion and are enforced semantically in 16.1 as authoring-time limits. Master protection likewise remains Phase 3c: this section does not establish that the protection contract passes, and the presence of a compressor in a graph never constitutes master protection (PRD 58). ### 15.15 Audio components ```json { "components": { "audio": { "voice": { "parameters": { "pitch": { "type": "number", "default": 220, "min": 20, "max": 2000 } }, "input": false, "nodes": { "tone": { "type": "oscillator", "frequency": { "ref": "inputs.pitch" } }, "level": { "type": "gain", "gain": 0.3 } }, "routes": [ { "from": "tone", "to": "level" }, { "from": "level", "to": "output" } ] } } } } ``` A `components.audio.` entry is an audio graph object (14.3) with two additional fields: | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `parameters` | object keyed by parameter ID | No | Exposed numeric parameters. Each is `{ "type": "number", "default"?: number, "min"?: number, "max"?: number, "unit"?: string }`. Only `number` is permitted in 0.1. | | `input` | boolean | No (default `false`) | When `true`, the reserved node key `input` is available as a route `from` inside the graph and the component instance accepts one audio input. | **Component-local references.** Inside a component graph, and only there, a ValueSpec may use the reference form `{ "ref": "inputs." }` to read the instance's value for an exposed parameter. `inputs.*` is a component-graph-scoped namespace: used anywhere else it is `ERR_INVALID_REFERENCE`, and it is not added to the section 1.3 document namespaces or the section 8.1 target table. A reference to an undeclared parameter is `ERR_INVALID_REFERENCE`. **Encapsulation.** External graphs may access only the component's audio input, its audio output, and its explicitly exposed parameters (PRD 56). A reference or route reaching an internal node key from outside is `ERR_INVALID_REFERENCE`. Node keys inside a component are scoped to that component and may repeat keys used in the enclosing graph without conflict; the expansion path of 14.12 disambiguates them for PRNG stream derivation. Component nesting is limited to `8` levels and recursion is prohibited (15.14, rules 10 and 11). ### 15.16 Sound definitions and recipes ```json { "sounds": { "relay-click": { "name": "Relay Click", "tags": ["mechanical", "electrical"], "usage": ["automatic", "manual", "scenario"], "cadence": { "class": "routine" }, "bus": "effects", "recipe": { "mode": "oneshot", "nodes": { "hit": { "type": "impulse" }, "body": { "type": "resonator", "modes": [{ "ratio": 1 }] } }, "routes": [{ "from": "hit", "to": "body" }, { "from": "body", "to": "output" }] } } } } ``` A `sounds.` entry separates semantic metadata from synthesis (PRD 59): | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `name` | string | Yes | Display name, at most `128` characters. | | `tags` | array of string | No | At most `16` entries of at most `32` characters. | | `usage` | array of enum | No | Any of `automatic`, `manual`, `scenario`; defaults to `["automatic"]`. Usage and cadence are separate concerns (PRD 62). | | `cadence` | object | No | Cadence assignment. Its fields are specified by the Cadence contract in Phase 5; in 0.1 Phase 3b it is validated only as an object. | | `bus` | string | No | A declared `audio.buses.`. An undeclared bus is `ERR_INVALID_REFERENCE`. Omitted, the sound routes to the engine master. | | `recipe` | object | Yes | An audio graph object plus the fields below, or `{ "use": "" }` naming an `audio.recipes.`. | A `name` longer than `128` characters, more than `16` tags, or a tag longer than `32` characters is `ERR_SCHEMA_VALIDATION`. A recipe graph object adds two fields to the graph shape of 14.3: | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `mode` | enum | No (default `oneshot`) | `oneshot` or `continuous` (PRD 57). A `oneshot` recipe must have a determinable ending; the runtime state machine that enforces it is Phase 3c. | | `release` | DurationSpec 0.1 | No (default `50ms`) | `0ms` to `10s` (16.4, PRD 57). Instance release duration. | `audio.recipes.` holds the same shape and exists so several sounds can share one graph. A `recipe` object containing both `use` and graph fields is `ERR_SCHEMA_VALIDATION`, and a `use` naming an undeclared recipe is `ERR_INVALID_REFERENCE`. ### 15.17 Audio buses and master ```json { "audio": { "buses": { "ambient": { "gain": 1 }, "effects": { "gain": 1 } } } } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `gain` | ValueSpec\ | No (default `1`) | Range `[0, 4]`, matching the section 8.1 safety clamp for `audio.buses..gain`. | A bus is a named summing point with one gain stage. `audio.buses..gain` is the one audio target in the section 8.1 shared registry, and it remains the supported surface for binding, automation, override, and additive modulation of audio level from outside a graph. Bus processing beyond gain is not part of 0.1. `master` is engine-provided and may not be declared as a bus ID (`ERR_INVALID_ID`). The `audio.master` object is reserved for the Phase 3c master-protection contract and must be absent from a 0.1 document; present, it is `ERR_UNKNOWN_FIELD`. ### 15.18 New diagnostic codes The following codes are added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_INVALID_ROUTE` | Semantic | An audio or modulation route is structurally resolvable but illegal under 15.14. | | `ERR_NO_AUDIBLE_PATH` | Semantic | No chain of audio routes from a non-control source reaches `output`. | | `ERR_COMPONENT_RECURSION` | Semantic | A component instantiates itself transitively, or component nesting exceeds `8` levels. | | `WARN_AUDIO_RATE_CLAMP` | Runtime | A frequency field was clamped to the device's `audioMaxFrequency` at node instantiation. | ### 15.19 Required traces before Phase 3b implementation is accepted 1. Each processing, routing, and composite node type validates its documented minimal example inside a complete graph with zero diagnostics, and its documented invalid case with exactly the documented code. 2. Every rule in the 15.14 table is exercised by at least one fixture that emits exactly the named diagnostic, and by one passing fixture that does not. 3. A graph whose only path to `output` originates at an `lfo`, `constant`, or `sample-hold` fails rule 5, and the same graph with an `oscillator` source passes. 4. A component instance expands correctly: `inputs.*` resolves to the instance's supplied values, internal node keys are unreachable from outside, an exposed parameter is a legal modulation target, and a self-referencing component is `ERR_COMPONENT_RECURSION`. 5. Two modulation routes onto one property sum and then clamp to the property's declared range. 6. A graph exceeding `128` expanded nodes or `256` routes after component expansion is `ERR_NODE_LIMIT_EXCEEDED`, while a graph at exactly those counts passes. 7. Expansion and validation run with no `AudioContext`, confirming the 14.5 separation between semantic validation and instantiation. ## 16. Audio Subsystem Contract — Automation, Lifecycle, and Protection (Phase 3c) This section closes the audio subsystem. It covers automation tracks and their place in the shared resolution pipeline (PRD 54), the runtime lifecycle state machine and release behavior (PRD 57), determinable one-shot endings, voice ceilings and eviction, master output protection (PRD 58, 120), and unlock and pause behavior for audio (PRD 117-118). Sections 14 and 15 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time. Where an earlier section deferred a rule to "Phase 3c", this section is the referent. **Implementation status.** Phase 3c slices 1–3 and the slice-4 protection implementation pass automated checks. The runtime accepts graph-local `automation` and recipe `release`, implements lifecycle/voice management, resolves automation before override and modulation, and routes audio through an engine-owned finite-sample guard and final limiter. Slice 4's real-browser measurement and user-observed listening gates remain open. See the implementation status and slice evidence; the implemented limiter is not proof of the measured protection contract. ### 16.1 Automation tracks An automation track drives one node property along an authored curve measured from the owning sound instance's start. Tracks are declared in the `automation` array of a graph object (14.3), alongside `nodes` and `routes`, and are therefore available in recipe graphs and component graphs alike: ```json { "automation": [ { "target": "level.gain", "mode": "absolute", "interpolation": "smooth", "points": [ { "at": "0ms", "value": 0 }, { "at": "800ms", "value": 1 }, { "at": "4s", "value": 0.6 } ] } ] } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `target` | string | Yes | `.`, resolved in the same graph. Follows the encapsulation rules of 15.15: a component's internals are unreachable, and a component instance exposes only its declared parameters. | | `mode` | enum | No (default `absolute`) | `absolute`, `offset`, or `scale`. | | `interpolation` | enum | No (default `linear`) | `step`, `linear`, `exponential`, or `smooth`. | | `points` | array | Yes | `2` to `256` breakpoints, each `{ "at": , "value": ValueSpec }`. | `automation` is an array rather than a keyed map because a track has no identity an author needs to reference. It defaults to `[]`. **Targets.** A track may address any property in the 15.13 modulatable registry, and no other. A track targeting a property absent from that registry is `ERR_UNSUPPORTED_TARGET`; the registry is deliberately the same one modulation uses, so a property is either time-varying or it is not, and the two mechanisms never disagree about which. A `target` naming an undeclared node is `ERR_INVALID_REFERENCE`. **Exclusivity.** Only one automation track may directly control a property in a recipe instance (PRD 54). Two tracks addressing the same expanded target are `ERR_AUTOMATION_CONFLICT`. This is the automation counterpart of the `ERR_CONFLICTING_BINDING` rule in section 8.2, and for the same reason: two writers to one scalar has no defined answer. Modulation is unaffected — multiple modulation routes onto one property still sum (15.13), because summation *is* their defined answer. **Points.** `at` is measured from the node instance's start and is a **duration literal only** (section 6.1). Unlike every other duration in this contract it may *not* be a procedural `TimeSpec` (section 6.2), and a `TimeSpec` there is `ERR_TYPE_MISMATCH`. The reason is the staging rule of 14.5: points must be in strictly increasing `at` order, and an order over values that resolve randomly at instantiation could not be checked at the semantic stage at all. Fixing `at` as a literal keeps the ordering rule decidable at import, where a malformed curve should be caught. A track's *values* remain full ValueSpecs and may be random; it is only the curve's shape in time that is authored, not sampled. A track's point values are resolved once at the owning sound instance's instantiation boundary, sampled from that instance's stream in depth-first, property-document order, with `automation` taken after `nodes` and `routes`. Equal or decreasing times are `ERR_INVALID_RANGE_ORDER`. Fewer than two points is `ERR_SCHEMA_VALIDATION` — a single point is a constant and belongs in the property's base value. Before the first point the track holds the first point's value; after the last point it holds the last point's value and does not loop. **Modes.** Given the property's resolved base value `b` and the track's current curve value `a`, the track contributes: | `mode` | Contribution | Use | | --- | --- | --- | | `absolute` | `a` | The curve *is* the value; the base is ignored. | | `offset` | `b + a` | The curve displaces the authored base in the property's own unit. | | `scale` | `b x a` | The curve multiplies the authored base. | **Interpolation.** Between two points with values `v0` and `v1` and normalized progress `t` in `[0, 1)`: | `interpolation` | Value | Notes | | --- | --- | --- | | `step` | `v0` | Holds until the next point, then jumps. | | `linear` | `v0 + (v1 - v0) x t` | — | | `exponential` | `v0 x (v1 / v0)^t` | Requires both endpoints strictly positive and same-signed; a segment with a zero or sign-crossing endpoint falls back to `linear` and raises `WARN_AUTOMATION_FALLBACK` once per track. A fallback is preferable to failing, because a legal base value can resolve to zero at instantiation. | | `smooth` | `v0 + (v1 - v0) x (3t^2 - 2t^3)` | Smoothstep; zero first derivative at both endpoints. | **Limits.** At most `64` automation tracks and `256` total automation points per expanded sound (PRD 58). Exceeding either is `ERR_NODE_LIMIT_EXCEEDED`. ### 16.2 Audio automation precedence Section 8.1 fixes the shared resolution pipeline. This section states its audio application; it is not a competing system (PRD 54). For a node property: ```text base ValueSpec -> binding (where supported) -> automation -> winning override -> modulation sum -> safety clamp -> engine parameter ``` Three consequences follow, and they are the whole content of this subsection. **Binding remains unsupported for node properties.** Section 14.4 stands: an external `BindingSpec` targeting a node field is `ERR_UNSUPPORTED_TARGET`, and an `override` action addressing one is barred the same way. The `winning override` stage of the pipeline above is therefore always absent for node properties — absent, not an identity hook (8.1). Automation does not change this, because a track is declared *inside* the graph that owns the property, not in the document's binding list. Adding automation to a subsystem never widens that subsystem's external surface. `audio.buses..gain` remains the one audio target in the shared registry, and it takes binding, automation, override, and modulation as section 8.1 already states. **Underlying automation continues while overridden** (PRD 54). An override masks the automation stage's output for as long as it wins; it does not pause, reset, or rewind the track. When the override releases, the property returns to whatever the track has reached by then, not to the value it held when the override took hold. This mirrors the masked-override rules of sections 8.3 and 8.4, where underlying stages keep evaluating while masked (8.3) and release resolves against the current lower value recomputed without the releasing override (8.4) — never an obsolete snapshot. **Every stage a target does not expose is absent, not an identity hook.** A property outside the 15.13 registry has no automation stage and no modulation stage. Being numeric grants nothing. ### 16.3 Lifecycle states A sound instance occupies exactly one state (PRD 57): | State | Meaning | Audio produced | | --- | --- | :---: | | `CREATED` | Graph expanded, values resolved, nodes constructed, nothing connected to a bus. | No | | `SCHEDULED` | Start time fixed on the audio clock; awaiting it. | No | | `ACTIVE` | Running and audible. | Yes | | `RELEASING` | Release envelope running; no new work accepted. | Yes, decaying | | `FINISHED` | Release complete; output silent; resources not yet reclaimed. | No | | `DISPOSED` | Every node, connection, automation track, buffer, and subscription released. Terminal. | No | | `FAILED` | Construction or activation raised; resources reclaimed as for `DISPOSED`. Terminal. | No | Permitted transitions, and nothing else: ```text CREATED -> SCHEDULED | FINISHED | FAILED SCHEDULED -> ACTIVE | RELEASING | FAILED ACTIVE -> RELEASING | FINISHED | FAILED RELEASING -> FINISHED | FAILED FINISHED -> DISPOSED ``` A stop request in `SCHEDULED` goes to `RELEASING` rather than straight to `FINISHED`, so a single code path handles teardown whether or not the voice ever sounded: a scheduled instance is already connected to its bus and may begin sounding at any moment on the audio clock. A stop request in `CREATED` goes straight to `FINISHED` instead, because a created instance is not connected to anything and no signal can escape it; ramping a gain that reaches no bus would be ceremony, not safety. `ACTIVE -> FINISHED` without a release is reserved for a one-shot that has reached its determinable ending, where the envelope has already returned to zero and a further release would be redundant. `DISPOSED` and `FAILED` are terminal; a second stop on a disposed instance is a no-op, never an error. Every state change is observable to the runtime's own diagnostics, but state names are not exposed to exhibits. An exhibit describes desired behavior, not runtime bookkeeping. ### 16.4 Release and the internal release gain The runtime provides an internal release gain for every sound instance, whether or not the exhibit authored one (PRD 57). It is the last node before the instance's bus, it is engine-owned, and no route can bypass or address it. The default release is `50ms`. A recipe graph object (15.16) may declare one further field, `release`, a DurationSpec in `0ms` to `10s`; `0ms` is permitted and means an immediate cut, which is an author's explicit choice rather than a default. `release` is a recipe field, not a graph-object field: a component graph does not declare it, because release belongs to the sound instance and not to any part of it. Entering `RELEASING` ramps the internal release gain linearly from its current value to zero over the release duration, and `FINISHED` follows at the end of that ramp. Release is unconditional. It runs on exhibit deactivation, on voice eviction, on pause-induced teardown, and on failure, so no path exists that stops a voice by disconnecting an already-running source. This is what keeps the click-and-pop surface to a single, testable code path. Disposal must release nodes, connections, automation tracks, buffers, and subscriptions (PRD 57). A `FINISHED` instance that has not reached `DISPOSED` still holds resources and still counts against the voice ceilings of 16.6. ### 16.5 Determinable one-shot endings A `oneshot` recipe must have a determinable ending (PRD 57). "Determinable" means the runtime can compute a finite upper bound on the instance's audible duration at instantiation, from the resolved graph alone, without observing output. The bound is computed as the longest path from any source to `output`, where each node contributes: | Node | Contribution to the bound | | --- | --- | | `impulse` | its resolved `duration` | | `oscillator`, `noise` | **unbounded** — these run until stopped | | `constant`, `lfo`, `sample-hold` | zero; control sources are not on an audible path (15.14 rule 5) | | `delay` | `time x ceil(log(1/1000) / log(feedback))` for `feedback > 0`, else `time`; the time for the internal feedback path to fall `60` dB | | `reverb` | `predelay + decay` | | `resonator` | the longest `decay` among its retained modes | | `component` | the bound of the component's internal graph: from `input` to `output` for an incoming route, or from internal sources to `output` | | every other node | zero; they colour a signal without extending it | The instance's ending is the maximum over all source-to-`output` paths of the sum of contributions along that path on the expanded audio graph, plus the release duration of 16.4. Because component expansion inlines internal subgraphs into an acyclic graph (15.14 rule 7) bounded by the nesting limit of `8` (rule 11), longest-path computation terminates deterministically for any legal graph topology. A component whose internal graph contains an audible path from an `oscillator` or `noise` makes every path through it unbounded, exactly as an unbounded source does at recipe root. A `oneshot` recipe whose bound is unbounded — that is, one whose audible path begins at an `oscillator` or `noise` source — is `ERR_INDETERMINATE_ONESHOT` at validation. The author's remedy is to declare `mode: "continuous"` and stop the sound explicitly. This is a semantic error rather than a runtime one because it is decidable from the document, and catching it at import is the difference between a rejected exhibit and a voice that never frees itself. A `continuous` recipe has no ending requirement and runs until explicitly stopped. ### 16.6 Voice ceilings and eviction Approximate ceilings (PRD 58): | Ceiling | Value | | --- | --- | | One-shot voices | `64` | | Continuous sounds | `16` | These are approximate and may be lowered by the runtime on a weaker device (PRD 58, 120). They are never raised above these values by a document; an exhibit cannot request a larger budget. A voice counts against its ceiling from `CREATED` until `DISPOSED`, so instances lingering in `FINISHED` still occupy budget — which is why disposal is part of the contract rather than an optimization. When a new instance would exceed its ceiling, the runtime applies this policy in order and stops at the first candidate: 1. Dispose the oldest instance already in `FINISHED`. 2. Evict the oldest instance in `RELEASING` by advancing its release ramp to immediate completion and disposing it. 3. For a one-shot request only: evict the oldest `ACTIVE` one-shot by starting its release. 4. Otherwise refuse the new instance. Eviction always releases (16.4); it never hard-stops an active voice. A voice in `RELEASING` is already decaying to zero; evicting it completes that release immediately. A one-shot request never evicts a continuous sound, and a continuous request never evicts a one-shot — the two budgets are independent, because a bed of ambience and a burst of transients fail differently and stealing across the boundary produces the worse failure in both directions. Every eviction and every refusal raises `WARN_VOICE_LIMIT` once, naming the sound and the ceiling. A refusal is not an error: an exhibit that asks for a sixty-fifth simultaneous transient is behaving legally, and the runtime's job is to stay stable and say so. Resource ceilings are centralized rather than scattered through subsystems (PRD 120). Every limit in sections 14-16 lives in one runtime table. ### 16.7 Master output protection Every audible signal passes through engine-controlled master protection, and an exhibit cannot bypass it (PRD 58). The chain is engine-owned, is the only path from any bus to the output device, and is not addressable from a document: no route, binding, override, automation, or action can reach it. `audio.master` remains absent from a 0.1 document (15.17). The protection contract has four parts. Their *shape* is normative now; their *values* are provisional and are confirmed by measurement under GC6 before audio acceptance (PRD 58). | Part | Contract | Status | | --- | --- | --- | | Digital output peak ceiling | The absolute sample value at the output device never exceeds the ceiling. Provisional ceiling `-1.0` dBFS (`0.891` linear). | Provisional; pending GC6 | | Numerical tolerance | Measured peak may exceed the ceiling by no more than the tolerance across a full measurement window. Provisional tolerance `0.1` dB. | Provisional; pending GC6 | | Release behavior | Protection gain reduction recovers smoothly and never produces an audible pump on a sustained bed or a click on a transient. Verified by listening, not by a number alone. | Provisional; pending GC6 | | Finite-sample handling | A non-finite sample (`NaN` or infinity) reaching the master chain is replaced with silence for that sample, the containing block is muted, and `WARN_AUDIO_NONFINITE` is raised once per instance. The count of affected blocks is recorded for the acceptance run. | Normative | Only finite-sample handling is settled, because it is a correctness rule rather than a measured threshold: a `NaN` in the output buffer is never acceptable at any ceiling, and silencing it is strictly better than propagating it. The other three require real output on real hardware. **The presence of a compressor does not establish that this contract passes** (PRD 58). A runtime may implement the chain with a limiter, a compressor, a soft clipper, or any combination; what it may not do is claim the contract on the strength of having built one. Acceptance requires the measured peak, the measured tolerance across worst-case overlapping recipes, and listening observations for clicks, clipping, and pumping. ### 16.8 Unlock behavior Browsers may require user interaction before producing audio (PRD 118). The runtime treats the first user interaction that engages the exhibit as the point at which audio is initialized or resumed. Visual rendering may begin before audio authorization, and the UI must clearly represent muted or unavailable audio state. When audio unlocks after visual startup, it aligns to the *current* logical position rather than replaying the interval it missed: - **Expired one-shot invocations are not replayed.** Every one-shot whose invocation time has already passed is discarded, and `INFO_AUDIO_UNLOCK_SKIP` is raised once for the whole batch, carrying the count. One diagnostic for the batch, not one per skipped sound: a long pre-unlock interval would otherwise flood the panel with entries describing a single condition. - **Continuous sounds start partially elapsed.** A continuous instance that should have begun at logical time `t0` and unlocks at `t1` starts at `ACTIVE` with its automation tracks and sample-hold ticks advanced to `t1 - t0`, not from zero. Its procedural draws are consumed in order to reach that position, so the seeded sequence is identical to an unbroken run — reproducibility does not depend on when the listener clicked. - **The logical clock is not rewound.** Audio never causes the performance to replay elapsed time. If audio is unavailable entirely — no `AudioContext`, or unlock refused — the performance continues silently. `WARN_AUDIO_UNAVAILABLE` is raised once, the UI shows the muted state, and no sound instance is created. A missing audio device degrades the exhibit; it does not fail it. ### 16.9 Pause and resume Explicit pause and document visibility loss suspend audio along with the rest of the performance (PRD 117). Audio-specific rules: - Suspending holds every voice in place. It does not release them, so resuming does not restart a bed of ambience that never stopped being wanted. - No catch-up bursts. One-shots that would have been invoked while paused are discarded on resume, and report once for the paused batch through `INFO_AUDIO_PAUSE_SKIP` carrying the count. - Automation tracks and sample-hold ticks resume from their held logical position; they do not fast-forward through the paused interval. - Visibility restoration must not undo an explicit user pause. - A pause longer than the runtime's stall bound releases all voices rather than holding them indefinitely, and resuming re-creates continuous sounds at the current logical position by the 16.8 rule. The exact bound is fixed with the GC4 audio lookahead and long-stall policy and is **Not yet specified** here. ### 16.10 New diagnostic codes Added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `INFO_AUDIO_PAUSE_SKIP` | Runtime | One or more one-shots invoked while paused were intentionally not replayed. | | `ERR_AUTOMATION_CONFLICT` | Semantic | More than one automation track directly controls one property in a recipe instance. | | `ERR_INDETERMINATE_ONESHOT` | Semantic | A `oneshot` recipe has no computable finite ending; its audible path begins at an unbounded source. | | `WARN_AUTOMATION_FALLBACK` | Runtime | An `exponential` automation segment had a zero or sign-crossing endpoint and fell back to linear interpolation. | | `WARN_VOICE_LIMIT` | Runtime | A voice ceiling was reached; an instance was evicted or a request refused. | | `WARN_AUDIO_NONFINITE` | Runtime | A non-finite sample reached the master chain and the containing block was muted. | | `WARN_AUDIO_UNAVAILABLE` | Runtime | No audio device is available; the performance continues silently. | ### 16.11 Required traces before Phase 3c implementation is accepted Automated, and executable without an audio device: 1. Each automation mode and each interpolation curve produces its documented value at segment start, midpoint, and end; `step` holds; `smooth` has zero slope at both endpoints. 2. A track holds the first point's value before the first point and the last point's value after the last, and does not loop. 3. Two tracks on one expanded target are `ERR_AUTOMATION_CONFLICT`; two modulation routes on that same target still sum. 4. An `exponential` segment with a zero endpoint falls back to linear and raises `WARN_AUTOMATION_FALLBACK` exactly once. 5. An automation track targeting a property outside the 15.13 registry is `ERR_UNSUPPORTED_TARGET`; one naming an undeclared node is `ERR_INVALID_REFERENCE`; one reaching a component's internals is rejected by the 15.15 encapsulation rule. 6. `65` tracks or `257` total points is `ERR_NODE_LIMIT_EXCEEDED`; `64` and `256` pass. 7. Every permitted lifecycle transition is exercised and every forbidden one is rejected; a second stop on a `DISPOSED` instance is a no-op. 8. An override masking an automated property releases to the track's *current* value, not the value held when the override took hold. 9. The determinable-ending bound matches the 16.5 table for a graph combining `impulse`, `delay`, `reverb`, and `resonator`; a `oneshot` fed by an `oscillator` is `ERR_INDETERMINATE_ONESHOT`; the same recipe as `continuous` passes. 10. At the one-shot ceiling the eviction order of 16.6 is followed, `WARN_VOICE_LIMIT` is raised, a continuous sound is never evicted by a one-shot request, and a refused request leaves the runtime stable. 11. Disposal releases every node, connection, automation track, buffer, and subscription; a `FINISHED` instance still counts against its ceiling until `DISPOSED`. 12. A non-finite sample injected at the master chain mutes its block and raises `WARN_AUDIO_NONFINITE` once. 13. Unlocking after a delay skips expired one-shots with a single counted `INFO_AUDIO_UNLOCK_SKIP`, and starts a continuous sound partially elapsed with a procedural sequence identical to an unbroken run. 14. With no `AudioContext` available the performance runs silently, raises `WARN_AUDIO_UNAVAILABLE` once, and creates no instance. User-observed, and **not** satisfiable by the above: 15. The measured digital output peak across worst-case overlapping recipes sits within the ceiling and tolerance of 16.7, on recorded hardware, with the environment details GC6 requires. 16. Listening observations across the reference exhibits report no clicks, clipping, or pumping on release, eviction, unlock, or pause. 17. The PRD 129 audio acceptance challenge passes in full. Traces 15 through 17 close Phase 3 slice 3c-4. Until they do, Phase 3 is not accepted no matter how many automated traces pass. --- ## 17. Visual Subsystem Contract — Scene, Primitives, Transforms, and Appearance (Phase 4a) This section opens the Visual contract required by section 11 (PRD 69-89). It covers the visual pipeline overview, canonical units, the `visuals` container and layer set, the scene model and coordinate spaces (PRD 70), 2.5D depth (PRD 71), the fourteen geometry primitives (PRD 72), common visual properties (PRD 73), the transform model (PRD 74), appearance (PRD 75), and paths and splines (PRD 76). Visual components, particle systems, placement distributions, emitters, repeaters, behaviors, procedural fields, and trails/ribbons/links (PRD 77-84) are specified in section 18 (Phase 4b). Visual automation (PRD 85), visual lifecycle and ownership (PRD 86), camera and projection (PRD 87), post-processing (PRD 88), and visual safety limits together with the centralized runtime ceilings (PRD 89, 119-120) are specified in section 19 (Phase 4c). ### 17.1 Pipeline and scope The visual pipeline is, in order: visual primitives (17.9-17.13) → reusable `components.visual.*` components (18.1) → procedural systems (18.2-18.8) → layers (17.5) → camera (19.3) → post effects (19.4) → display (PRD 69). The schema is renderer-neutral: nothing in this section names a drawing API, and every construct is expressible by any renderer that can composite transformed, styled 2D geometry. XZBT 0.1 realizes it with Canvas 2D (PRD 69). Where a declared appearance feature is unavailable on the active renderer, this contract fixes the fallback and the runtime raises `WARN_VISUAL_APPROXIMATION`; it never silently omits the feature and never substitutes a materially different one. XZBT never implements a primitive, field, or system named after a specific exhibit's subject. There is no `planet`, `tree`, `ship`, or `machine` primitive (PRD 72). Every construct in this contract is generic, and the fourteen PRD 130 challenge items must be reachable by composition alone. ### 17.2 Canonical units | Quantity | Unit | Notes | | --- | --- | --- | | position, size, length | scene units of the declared coordinate space (17.4) | Not CSS pixels unless `coordinateSpace` is `viewport`. | | depth | scene units on the `z` axis | Increasing `z` is **farther from the camera** (17.6). | | angle | degrees | `0` points along `+x`; positive angles turn toward `+y`. Applies to `rotation`, `skew`, `arc` bounds, and conic gradient angles. | | scale | unitless multiplier | `1` is unscaled. Negative values mirror. | | opacity and normalized controls | `0` to `1` | Polarity-free control ranges (`opacity`, glow `strength`, filter `amount`, `tension`). | | color | `color` (section 2) | Alpha may be carried in `#rrggbbaa` and multiplies the applicable opacity. | | stroke width, blur radius, glow radius, shadow offset | scene units | Measured before camera scaling (19.3), so a zoomed camera scales them with the geometry. Having no axis, they take the single uniform factor `g` of 19.3 rather than a nonuniform fit matrix. | | time | DurationSpec 0.1 | Section 6; a procedural `TimeSpec` is permitted wherever a field's contract says DurationSpec. | | rate | occurrences per logical second | Section 9.1 logical time, never wall time or frame count. | Angles are degrees throughout the visual contract. The audio contract's `phase` field (14.7) is already degrees; no visual field is authored in radians or turns. ### 17.3 The `visuals` block ```json { "visuals": { "scene": { "coordinateSpace": "virtual", "width": 1600, "height": 900, "background": "#020308" }, "layers": { "far": { "parallax": 0.2 }, "near": {} }, "systems": { "horizon": { "type": "graphic", "layer": "far", "content": { "band": { "type": "rectangle", "position": { "x": 0, "y": 620 }, "size": { "width": 1600, "height": 4 }, "style": { "fill": "#1d2b3a" } } } } } } } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `scene` | object | Yes when `visuals` is present | Scene model (17.4). | | `layers` | object keyed by layer ID | No | Layer set (17.5). Absent means one implicit layer. | | `systems` | object keyed by system ID | No (defaults to `{}`) | Visual system objects (17.7). An exhibit with no systems renders only `scene.background`. | | `fields` | object keyed by field ID | No (defaults to `{}`) | Procedural fields (18.7). | | `camera` | object | No | Camera (19.3). | | `effects` | array | No (defaults to `[]`) | Post-effect chain (19.4). | | `automation` | array | No (defaults to `[]`) | Exhibit-scoped automation tracks (19.1), with `at` measured from exhibit activation. The system-scoped array is a field of the system (17.7), not of this container. | Per the strict unknown-field policy (section 1), any other property of `visuals` is `ERR_UNKNOWN_FIELD`. Layer and system keys follow the shared identifier rule (section 1.3); a key failing the regex is `ERR_INVALID_ID`. **Two authoring bounds on declared size.** `visuals.systems` holds at most `64` entries, spawned templates counted once each. The whole document holds at most `16384` **expanded static visual objects**, counted after component and repeater expansion by the rule 19.5 states. Exceeding either is `ERR_VISUAL_LIMIT_EXCEEDED`. These bound the work an exhibit declares outright, which no runtime ceiling covers: 19.5's frame-time governance never sheds an authored persistent object or a declared system, so a document that declares too much of either has to be rejected rather than degraded. ### 17.4 Scene model (`visuals.scene`) | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `coordinateSpace` | enum | No | `virtual` | `normalized`, `viewport`, or `virtual` (PRD 70). | | `width` | number | Conditional | — | Required if and only if `coordinateSpace` is `virtual`. `1` to `16384`. | | `height` | number | Conditional | — | Required if and only if `coordinateSpace` is `virtual`. `1` to `16384`. | | `fit` | enum | No | `contain` | `contain`, `cover`, or `stretch`. The default applies in `normalized` and `virtual`; in `viewport` the field has no mapping effect and no default is taken. | | `background` | `color` | No | `#000000` | Cleared to this color before every frame. | | `depthFog` | object | No | — | Depth fog (17.6). | `width` or `height` present while `coordinateSpace` is not `virtual` is `ERR_UNKNOWN_FIELD`; either absent while it is `virtual` is `ERR_SCHEMA_VALIDATION`. The three coordinate spaces: * **`normalized`** — the scene is the unit square. `x` and `y` run `0` to `1` from the top-left. The square is mapped into the display surface under `fit`. * **`viewport`** — scene units are CSS pixels of the display surface, origin at its top-left. The scene has no intrinsic size, so there is nothing to fit: an **absent** `fit` is accepted and has no mapping effect, and an **explicitly authored** `fit` other than `stretch` is `ERR_SCHEMA_VALIDATION`. The `contain` default of the `fit` row is not taken in this space, so absent and `stretch` behave identically and neither scales anything. Content is responsible for its own responsiveness; the runtime does not scale it. * **`virtual`** — the scene is a fixed `width` x `height` design space with the origin at its top-left, mapped into the display surface under `fit`. This is the recommended space for reproducible composition, and the one the reference exhibits use. `fit` resolves the scene rectangle against the display rectangle: `contain` scales uniformly until the scene fits entirely inside, letterboxing the remainder with `background`; `cover` scales uniformly until the scene covers the display, cropping the overflow; `stretch` scales each axis independently, changing the aspect ratio. Coordinates outside the scene rectangle are legal and are simply outside the visible area under `contain` and `stretch`. Scene-to-device resolution — the device-pixel-ratio multiplier and its ceiling — is a runtime resource concern and is fixed in 19.5, not by the exhibit. An exhibit never declares a device resolution. ### 17.5 Layers (`visuals.layers`) Layers are the compositing groups of PRD 69. Each `visuals.layers.` is: | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `opacity` | ValueSpec\ | No | `1` | `0` to `1`. Applied to the composited layer, not per object. | | `blend` | enum | No | `normal` | Safe blend set (17.12). | | `visible` | ValueSpec\ | No | `true` | An invisible layer is composited out entirely and its systems still advance. | | `parallax` | number | No | `1` | Multiplier on camera translation for this layer (17.6, 19.3). `0` pins the layer to the display. | Layers composite in document key order, back to front. A system's `layer` must name a declared layer or `ERR_INVALID_REFERENCE`. **Presence of the layer map decides whether `layer` is required.** When `visuals.layers` is absent, one implicit layer with every default applies and a system's `layer` field is `ERR_INVALID_REFERENCE`, because there is no layer to name. When `visuals.layers` is present, every system must name one of its layers: an omitted `layer` is `ERR_INVALID_REFERENCE`, the same code a misspelled one raises. The runtime does not fall back to the first declared layer, because that would make a typo indistinguishable from a deliberate omission and would silently re-compose the exhibit when an unrelated layer is later declared ahead of it. `visuals.layers` present but empty (`{}`) is `ERR_SCHEMA_VALIDATION`; an exhibit that wants the implicit layer omits the key entirely. More than `16` layers is `ERR_VISUAL_LIMIT_EXCEEDED`. A layer whose `opacity` or `blend` is not the default requires an offscreen compositing buffer; those buffers count against the pass budget of 19.5. ### 17.6 Visual depth XZBT 0.1 supports 2.5D depth through an optional `z` on any visual object (PRD 71). It has no meshes, no lighting model, and no occlusion beyond draw order. **Sign.** Increasing `z` is farther from the camera. `z` defaults to `0`. **Effective depth.** An object's **effective depth** `zEffective` is the sum, over the object and every ancestor `group` or `component` up to the system root, of that node's resolved `z` and its `transform.translate.z`: ```text zEffective(object) = sum over the object and its ancestors of ( z + transform.translate.z ) ``` This one quantity drives depth sorting, perspective, parallax participation, fog, and eye culling. Nothing else contributes to it: `position` carries no `z`, and a point's own `z` (17.9) is not part of it. **Depth sorting, and what a sortable unit is.** Within one layer the frame is drawn farthest first: sortable units are ordered by representative depth descending, and the sort is stable, so equal depths always draw in document order — the same document-order rule sections 14.4 and 9.3 already use for sampling. A **sortable unit** is one of exactly two things: | Unit | Representative depth | Internal order | | --- | --- | --- | | A top-level visual object of a `graphic` system's `content` (17.8) — including a `group` or `component` and its whole subtree | Its own `zEffective` | Its descendants sort among themselves by the same rule, within the unit | | An entire `particles`, `emitter`, or `repeater` system (18.2, 18.4, 18.5) | The **lowest** `zEffective` among its live items — the nearest one | Its items sort among themselves by `zEffective` descending, then by creation ordinal ascending | A procedural system is therefore **atomic**: it never interleaves item-by-item with objects of another system. Interleaving deep and near content is what layers (17.5) are for. The rule 18.2 states for particles is the general rule, and emitters and repeaters follow it unchanged rather than each restating it. Ties are broken in this order: greater representative depth first; then by the owning system's key order in `visuals.systems`; then, within one `graphic` system, by object key order in `content`; then, within one procedural system, by creation ordinal ascending. A procedural system with no live items draws nothing and its place in the order is unobservable; for the tie-break it is treated as having representative depth `0`. **Objects whose points carry `z`.** A `points` entry's optional `z` (17.9) does **not** make that point a sortable unit. It has exactly one effect: under `perspective`, each point is projected with its own factor computed from `zEffective(object) + point.z`, so a polygon whose corners carry different `z` foreshortens as one piece of geometry rather than snapping between two flat depths. Sorting and depth fog use the object's `zEffective` alone, with the point offsets ignored, which is what keeps fog exact and per object (below). Under `orthographic` a point's `z` has no effect at all. **Perspective scaling.** Under the `perspective` camera projection (19.3), an object receives the uniform factor `focalLength / (focalLength + zEffective)` about the projection center, applied as one stage of the normative composition chain of 19.3 — that subsection, not this sentence, fixes where in the chain it sits and in which coordinate space. The factor scales the object's geometry and, with it, every scene-unit appearance dimension: `strokeWidth`, `strokeDash` lengths, `pointSize`, `blur`, `glow.radius`, and `shadow` offsets and `blurRadius` all shrink with a receding object, which is what makes a receding object look receding rather than look near and small. `zEffective <= -focalLength` places an object at or behind the eye; such an object is culled for that frame and raises no diagnostic. Under `orthographic`, `zEffective` affects sorting, parallax, and fog but never scale. **Parallax.** A layer's `parallax` multiplies the camera's translation before it is applied to that layer, giving the deep parallax field of PRD 130 item 1 without any subject-specific renderer. **Depth fog.** `visuals.scene.depthFog` is optional: | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `color` | `color` | Yes | — | Color the object is blended toward. | | `near` | number | No | `0` | `z` at which fogging begins. | | `far` | number | Yes | — | `z` at which fogging reaches full `density`. `far` must exceed `near` or `ERR_INVALID_RANGE_ORDER`. | | `density` | number | No | `1` | `0` to `1`. Maximum blend fraction. | The fog fraction for an object is `f = density * clamp((zEffective - near) / (far - near), 0, 1)`, and its resolved colors are blended toward `color` by that fraction before drawing. **Fog is exact, and its arithmetic is fixed.** Blending is per component in **non-premultiplied sRGB**, on the `0`-to-`1` component scale, with no linearization step: ```text rgb' = rgb + (fogColor.rgb - rgb) * f ``` Alpha is never fogged: the object's own alpha survives unchanged, and the `color` field's alpha, if it carries one, is ignored. The colors fogged are the object's resolved `style.fill`, `style.stroke`, `glow.color`, and `shadow.color`. Where `fill` or `stroke` is a paint object, **each stop's color is fogged individually before the paint is constructed**, so a fogged gradient keeps its stop offsets and its shape and loses only its contrast — constructing the paint first and fogging the rasterized result would be a per-pixel operation and would not be renderer-independent. Post-effect `color` parameters (19.4) are not fogged; the effect chain runs after compositing and has no object depth. Fog applies per object, not per pixel, so it is exact and renderer-independent; it is not an approximation and raises no diagnostic. ### 17.7 Visual system objects (`visuals.systems.`) A **visual system** is the addressable unit of the visual subsystem and the resource whose namespace section 1.3 reserves as `visuals.systems.*`. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `type` | string enum | Yes | — | `graphic` in this section. Section 18 adds `particles`, `emitter`, and `repeater`. Any other value is `ERR_INVALID_SYSTEM_TYPE`. | | `layer` | string | Conditional | implicit layer | A declared layer ID (17.5). Required when `visuals.layers` is present; omitted there it is `ERR_INVALID_REFERENCE`. | | `visible` | ValueSpec\ | No | `true` | A hidden system is not drawn; its behaviors and automation still advance. It is an external target by the section 8.1 rows 19.1 adds. | | `lifecycle` | string enum | No | `persistent` | `persistent` or `spawned` (19.2). A `spawned` system is a template: declared and validated at import, instantiated only by a `spawn` action. | | `automation` | array | No | `[]` | System-scoped automation tracks (19.1), with `at` measured from this system's instantiation boundary. | | `spawn` | object | Conditional | — | Spawned-instance lifecycle configuration (19.2). Legal only when `lifecycle` is `spawned`. | | *(type-specific fields)* | — | Per type | — | 17.8 for `graphic`; 18.2-18.5 for the others. | **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`. 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 ```json { "type": "graphic", "content": { "hull": { "type": "polygon", "points": [{ "x": 0, "y": 0 }, { "x": 90, "y": 0 }, { "x": 45, "y": 70 }] } } } ``` | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `content` | object keyed by visual object ID | Yes | Visual objects (17.9). An empty `content` object is `ERR_SCHEMA_VALIDATION`. | **Object identity.** Visual objects are addressed by their key in the containing `content` or `children` object, matching how the document keys `parameters`, `state`, `audio` nodes (14.3), buses, and systems. A visual object therefore carries **no** `id` field; supplying one is `ERR_UNKNOWN_FIELD`. Keys follow the shared identifier rule (section 1.3) and are unique within their container by construction. Two objects in different containers may share a key; a full object path is the system ID followed by each container key in turn. Document key order within a container is draw order for equal `z` (17.6), so a renderer must preserve the source key order of `content` and `children` rather than reordering them. ### 17.9 Visual objects and Visual Primitive Set 0.1 Every value in a `content` or `children` container is a **visual object**: a `type` from Visual Primitive Set 0.1, its type-specific geometry fields, and the common properties of 17.10. | `type` | Geometry fields | Notes | | --- | --- | --- | | `point` | — | A zero-extent mark drawn as a filled dot of `style.pointSize` (17.12). The cheapest primitive; the starfield of PRD 130 item 1 is points under a repeater. | | `line` | `to` (`{x, y, z?}`) | From the object's origin to `to`. Stroke only; `style.fill` on a `line` is `ERR_UNKNOWN_FIELD`. | | `polyline` | `points` (array, `2` to `512`) | Open chain. Stroke only. | | `polygon` | `points` (array, `3` to `512`) | Implicitly closed. Fill and stroke. | | `rectangle` | `size` (`{width, height}`) | Axis-aligned in local space before `transform`. | | `rounded-rectangle` | `size`, `radius` | `radius` is a number or `{topLeft, topRight, bottomRight, bottomLeft}`. Each radius clamps to half the shorter side. | | `ellipse` | `radius` (number or `{x, y}`) | A single number is a circle. | | `arc` | `radius`, `startAngle`, `endAngle`, `direction` | `direction` is `clockwise` (default) or `counter-clockwise`. Stroke only. Sweep computation, its `360`-degree bound, and the zero and full-turn cases are fixed below. | | `ring` | `radius`, `innerRadius`, `startAngle`, `endAngle`, `direction` | An annulus or annular sector. `innerRadius` must be less than `radius` or `ERR_INVALID_RANGE_ORDER`. The angle defaults below make an omitted pair a full ring. Fill and stroke. | | `path` | `commands` | 17.13. | | `bezier` | `c1`, `c2`, `to` | One cubic segment from the object's origin. Stroke only. | | `spline` | `points`, `mode`, `closed`, `tension` | 17.13. | | `text` | `text`, `font`, `size`, `align`, `baseline`, `maxWidth` | 17.13. | | `group` | `children` | A transform and style scope. Fills and strokes are not drawn for the group itself. | A `type` outside this set is `ERR_INVALID_PRIMITIVE_TYPE`. Per the strict unknown-field policy, any property a primitive's contract does not declare — including a geometry field belonging to another primitive — is `ERR_UNKNOWN_FIELD`. Visual Primitive Set 0.1 is exactly these fourteen geometry primitives and is closed. Section 18.1 adds one further **visual object type**, `component`, which declares no geometry of its own and instantiates a `components.visual.*` sub-assembly in place; the same `ERR_INVALID_PRIMITIVE_TYPE` covers a `type` outside the fifteen. This mirrors the audio contract, where 15.11 added a `component` node to the node set 14.3 opened. `points` entries are `{ "x": number, "y": number, "z"?: number }`. A point's `z` offsets the object's `z` for depth purposes but does not sort points independently; an object is sorted and drawn as one unit. **Local geometry, extents, and anchors.** Every primitive's geometry is expressed in the object's **local space**, whose origin is the object's `position` (17.10) in its parent's space. This table fixes where each primitive sits relative to that origin, so that a transform `origin`, a clip rectangle, and a gradient axis all have one unambiguous frame. Geometry field requiredness and defaults are fixed here rather than inferred from examples. | `type` | Geometry fields, requiredness, defaults | Local extent relative to the origin | | --- | --- | --- | | `point` | — | The origin itself; zero extent. The drawn disc has diameter `style.pointSize` centered on it. | | `line` | `to` required | Origin to `to`. | | `polyline` | `points` required, `2` to `512` | The points as written. | | `polygon` | `points` required, `3` to `512` | The points as written, implicitly closed. | | `rectangle` | `size` required | **Top-left corner at the origin**, extending to `(width, height)`. This matches the top-left origin of every coordinate space (17.4) and the corner-anchored rectangle of `clip` (17.12). | | `rounded-rectangle` | `size` required; `radius` required | As `rectangle`. Each corner radius clamps to half the shorter side. | | `ellipse` | `radius` required | **Centered on the origin**, half-extent `radius.x` by `radius.y`. | | `arc` | `radius` required; `startAngle` default `0`; `endAngle` default `0`; `direction` default `clockwise` | Centered on the origin. | | `ring` | `radius` required; `innerRadius` default `0`; `startAngle` default `0`; `endAngle` default `360`; `direction` default `clockwise` | Centered on the origin. The angle defaults are what make an omitted pair a full ring. | | `path` | `commands` required, `1` to `512`; `fillRule` default `nonzero` | The commands' own coordinates, which are local coordinates; the first `move` is not required to be the origin. | | `bezier` | `c1`, `c2`, `to` all required | Origin to `to` through the two control points. | | `spline` | `points` required, `2` to `256`; `mode` default `catmull-rom`; `closed` default `false`; `tension` default `0.5`; `fillRule` default `nonzero` | The points as written. | | `text` | `text` required; every other field defaults per 17.13 | The origin is the anchor selected by `align` and `baseline`. | | `group` | `children` required, non-empty | The union of its children's extents. | A primitive whose required geometry field is absent is `ERR_SCHEMA_VALIDATION`; a geometry field with no listed default has no default and must be authored. **Angular sweep is directed, and its bound is checked before normalization.** For `arc` and `ring`, let ```text d = endAngle - startAngle when direction is clockwise d = startAngle - endAngle when direction is counter-clockwise ``` `|d| > 360` is `ERR_OUT_OF_BOUNDS`, and that check runs on the authored numbers **before** any normalization, so `startAngle: 0, endAngle: 720` is rejected rather than silently folded to a full turn. Otherwise the drawn sweep is `((d mod 360) + 360) mod 360` degrees from `startAngle` in `direction`, with two boundary cases stated rather than left to the modulo: `|d| == 360` draws a complete turn, and `d == 0` draws nothing and raises no diagnostic. Normalizing the **signed** delta is what makes wrapping across `0` need no special case — `startAngle: 350, endAngle: 10` clockwise is a `20`-degree sweep through zero, not a `340`-degree one the long way round — and it is why `direction`, not the sign of `d`, decides which way the pen travels. **Groups.** A `group` composes a transform (17.11), a style scope (17.12), and an optional clip or mask over its `children`. Group nesting deeper than `8` levels is `ERR_VISUAL_LIMIT_EXCEEDED`, matching the audio component nesting bound of 15.15. A `group` with an empty `children` object is `ERR_SCHEMA_VALIDATION`. ### 17.10 Common visual properties Where applicable to the primitive (PRD 73): | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `position` | `{x, y}`, each ValueSpec\ | No | `{0, 0}` | The object's origin in its parent's local space. | | `z` | ValueSpec\ | No | `0` | Depth coordinate (17.6). PRD 73 names this property "depth"; it is spelled `z` throughout this contract, matching PRD 71. | | `size` | `{width, height}`, each ValueSpec\ | Per primitive | — | Only where the primitive's row declares it. | | `transform` | object | No | identity | Transform model (17.11). | | `style` | object | No | inherited | Appearance (17.12). | | `behaviors` | array | No (defaults to `[]`) | — | Behavior instances (18.6), `0` to `8` entries. | | `visible` | ValueSpec\ | No | `true` | A hidden object and its descendants are not drawn; their behaviors and automation still advance. | | `lifetime` | DurationSpec | No | — | Logical duration after which the object is removed from its container. Absent means it lives as long as its system. | | `layer` | — | — | — | Declared on the *system* (17.7), not on an object. `layer` on a visual object is `ERR_UNKNOWN_FIELD`. | `layer` is deliberately a system-level property: layers are compositing groups, and allowing sibling objects within one transform hierarchy to composite into different layers would make the hierarchical transform of 17.11 meaningless. ### 17.11 Transform model ```json { "transform": { "translate": { "x": 12, "y": 0, "z": -40 }, "rotation": 30, "scale": { "x": 1.5, "y": 1.5 }, "skew": { "x": 0, "y": 0 }, "origin": { "x": 45, "y": 35 } } } ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `translate` | `{x, y, z}`, each ValueSpec\ | `{0, 0, 0}` | Added to `position` and `z`. | | `rotation` | ValueSpec\ | `0` | Degrees (17.2). | | `scale` | `{x, y}`, each ValueSpec\ | `{1, 1}` | Negative values mirror. `0` collapses the object; this is legal and draws nothing. | | `skew` | `{x, y}`, each ValueSpec\ | `{0, 0}` | Degrees. Each magnitude must be below `90` or `ERR_OUT_OF_BOUNDS`. | | `origin` | `{x, y}`, each ValueSpec\ | `{0, 0}` | Pivot for rotation, scale, and skew, in the object's own local space. | **Composition order is normative.** An object's local matrix is, applied right to left to a local-space point: ```text M_local = T(position + translate) x T(origin) x R(rotation) x K(skew) x S(scale) x T(-origin) ``` so a point is scaled, then skewed, then rotated about `origin`, then translated. The `z` component of `translate` adds to the object's `z` and takes part in depth sorting and perspective (17.6); it is not part of `M_local`. **Hierarchy.** For an object inside a `group`, the effective matrix is `M_parent x M_local`, and effective `z` is `z_parent + z_local`. Transforms therefore compose down the tree exactly as PRD 74 requires, and a group's `origin` pivots its whole subtree. A renderer must not fold the order differently even where the result would coincide for a particular object; exhibits depend on the stated order wherever rotation and non-uniform scale are combined. ### 17.12 Appearance `style` is an object whose fields are inherited by a `group`'s descendants unless the descendant redeclares them. Inheritance is per field, not per object. | Field | Type | Default | Notes | | --- | --- | --- | --- | | `fill` | ValueSpec\, paint object, or `null` | `null` | `null` draws no fill. | | `stroke` | ValueSpec\, paint object, or `null` | `null` | `null` draws no stroke. | | `strokeWidth` | ValueSpec\ | `1` | Scene units. `0` draws no stroke. | | `strokeCap` | enum | `butt` | `butt`, `round`, `square`. | | `strokeJoin` | enum | `miter` | `miter`, `round`, `bevel`. | | `strokeDash` | array of numbers | — | Alternating on/off lengths in scene units, `1` to `8` entries. | | `strokeDashOffset` | ValueSpec\ | `0` | Scene units. | | `pointSize` | ValueSpec\ | `1` | Diameter of a `point`, in scene units. | | `opacity` | ValueSpec\ | `1` | `0` to `1`. Multiplies with any inherited group opacity and with the owning instance's release factor (19.2). Layer `opacity` is **not** a second per-object multiplication; see the compositing order below. | | `blend` | enum | `normal` | Safe blend set below. | | `glow` | object or `null` | `null` | `{ "color": color, "radius": number, "strength": number }`. `strength` is `0` to `1`. | | `shadow` | object or `null` | `null` | `{ "color": color, "blurRadius": number, "offsetX": number, "offsetY": number }`. | | `blur` | ValueSpec\ | `0` | Object blur radius in scene units. | | `filters` | array | `[]` | Ordered filter entries, `0` to `4`. | | `clip` | object or `null` | `null` | Clip region; see below. | | `mask` | string or `null` | `null` | `group` only; see below. | **Color leaves and ValueSpecs.** A `color`-typed leaf in this table is a `ValueSpec` (section 4): a literal, a `ref`, a `choose` over color literals, or a `random` over none — `random` has no color form and is `ERR_TYPE_MISMATCH` on a color leaf. The color leaves that accept a ValueSpec are exactly `fill`, `stroke`, `glow.color`, `shadow.color`, and each entry's `color` inside a paint object's `stops`. This is what makes the component example of 18.1 legal: `{ "fill": { "ref": "inputs.tint" } }` reads the instance's exposed color parameter. Three limits keep that from widening the contract. A `ValueSpec` resolves to a **color**, never to a paint object: a gradient is authored structurally or not at all, and a ValueSpec that resolved to an object is `ERR_TYPE_MISMATCH`. The `type`, `stops[].offset`, `from`/`to`, `center`, `radius`, `innerRadius`, and `angle` fields of a paint object are authored literals, not ValueSpecs, so a paint's *shape* is fixed at import while its *colors* may be resolved. And system-level colors outside this table — `visuals.scene.background`, `visuals.scene.depthFog.color`, and every `color` parameter of a post-effect (19.4) — are literals, matching 19.4's statement that effect `color` parameters are authored once. Every color ValueSpec resolves once at its owning object's instantiation boundary, in the same depth-first document order as every other field (17.14), and is constant for that object's life. Resolving a color consumes procedural samples exactly as resolving a number does, and it consumes none per frame. **Safe blend set (PRD 75).** `normal`, `add`, `screen`, `multiply`, `overlay`, `lighten`, `darken`, `difference`. Any other token is `ERR_SCHEMA_VALIDATION`. This is the set every target renderer supports identically; no other blend mode is authorable in 0.1. **Paint objects.** A `fill` or `stroke` may be a gradient instead of a flat `color`: | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `type` | enum | Yes | `linear-gradient`, `radial-gradient`, `conic-gradient`. | | `stops` | array | Yes | `2` to `16` entries of `{ "offset": number, "color": color }`. | | `from`, `to` | `{x, y}` | `linear-gradient` only | Gradient axis in the object's local space. | | `center`, `radius` | `{x, y}`, number | `radial-gradient` only | `innerRadius` optional, default `0`. | | `center`, `angle` | `{x, y}`, number | `conic-gradient` only | `angle` is the start angle in degrees, default `0`. | Stop `offset` values are `0` to `1` and must be strictly increasing; a non-increasing pair is `ERR_INVALID_RANGE_ORDER`, the same code section 14 uses for inverted bounds. More than `16` stops is `ERR_VISUAL_LIMIT_EXCEEDED`. **Conic gradient fallback.** PRD 75 makes conic gradients conditional on renderer support. Where the active renderer has no conic gradient, the runtime substitutes a `linear-gradient` with the **same stops at the same offsets** along a documented axis, and raises `WARN_VISUAL_APPROXIMATION` once per paint instance, not once per frame. This is the only appearance fallback in 0.1; every other field in this table is exact on every target renderer. The fallback axis is fixed so that two conforming renderers substitute the same gradient: * The reference box is the **local-space axis-aligned bounding box of the owning object's geometry**, taken before `transform` and before any camera stage, using the extents of the primitive's row above. * The axis runs from `center` in the direction `(cos angle, sin angle)` (17.2) to the first intersection with that box's boundary. * **`center` outside the box, or a ray that never meets it**, is not an error and needs no special renderer behavior: the axis then runs from `center` to `center + d * (cos angle, sin angle)`, where `d` is the distance from `center` to the farthest corner of the box. This reduces to the intersection case whenever the intersection exists, so a renderer may implement only this form. * A **degenerate box** — zero width and zero height, as on a `point` — has no axis. The fallback then paints the first stop's color flat and still raises the warning once, because a flat fill is a materially milder substitution than omitting the paint. Nothing in this fallback re-orders, re-offsets, or re-colors the stops. Substituting a *different* set of stops would be the "materially different" behavior PRD 89 forbids, and the point of fixing the axis is to make the approximation reproducible rather than to make it good. **Filters.** Each entry is `{ "type": enum, "amount": ValueSpec }` with `type` in `brightness`, `contrast`, `saturate`, `hue-rotate`, `grayscale`, `sepia`, `invert`. `amount` is `0` to `4` for `brightness`, `contrast`, and `saturate`; `0` to `1` for `grayscale`, `sepia`, and `invert`; and degrees for `hue-rotate`. Filters apply in array order, after the object is drawn and before it composites into its layer. More than `4` entries is `ERR_VISUAL_LIMIT_EXCEEDED`. **Clipping.** `clip` is `{ "shape": "rectangle" | "ellipse", "x": number, "y": number, "width": number, "height": number }`, expressed in the object's local space and intersected with any clip inherited from an ancestor. A clip on a non-`group` primitive clips only that primitive. **Masking.** `mask` is legal only on a `group` and names one key in that group's own `children`. The named child is not drawn; its rendered alpha multiplies the alpha of the group's remaining children. A `mask` naming a missing key is `ERR_INVALID_REFERENCE`; a `mask` on a non-`group` object is `ERR_UNKNOWN_FIELD`. **Compositing order is normative.** An object's contribution to its parent is computed in this order, and a renderer must not fold two stages together where the result would differ: ```text 1 shadow drawn behind the geometry, at shadow.offsetX/offsetY, blurred by shadow.blurRadius 2 geometry fill, then stroke, in the object's own resolved (and fogged, 17.6) colors 3 glow added around the result at glow.radius, scaled by glow.strength 4 blur style.blur applied over stages 1-3 5 filters array order (17.12) 6 alpha multiply by the effective alpha a, defined below 7 clip and mask intersect with any inherited clip; multiply by the group mask's rendered alpha 8 blend composite into the parent under style.blend ``` The **effective alpha** at stage 6 is ```text a = style.opacity (own, resolved) x the product of style.opacity over every ancestor group and component x the release factor of the owning system instance (19.2) ``` and nothing else. In particular **layer `opacity` is not in it.** A layer's `opacity` (17.5) applies exactly once, to the composited layer, at stage 9 below; multiplying it in per object as well would darken overlapping children twice and would make `layers..opacity` behave differently for a single object than for two that overlap. The **release factor** is a separate multiplier owned by the system instance and initialized to `1`; it is untouched for the instance's whole life until release begins, so releasing never rewrites an authored `opacity` and never pops a partially transparent object back to opaque (19.2). A layer is then composited as: ```text 9 layer draw its sortable units in the order of 17.6, multiply by the layer's opacity, composite into the frame under the layer's blend, in layer document key order ``` **Buffers, and what happens when they run out.** A buffer is an offscreen surface the renderer must allocate to compute a stage correctly. Two kinds compete for one pool: | Allocation | Cause | | --- | --- | | Layer buffer | A layer whose `opacity` or `blend` is not the default (17.5) | | Object buffer | An object or group with a non-default `blend`, a `mask`, a `blur`, a `glow`, or any `filters` entry | The budget is `16` buffer allocations **per frame** (19.5). That is the per-frame reading the phrase "pass budget" has carried since 17.5 and 17.12 first used it, and it is the one worth bounding: a buffer costs an allocation, a clear, and a composite every frame it is used, so counting allocations bounds the work, where counting only the peak — the nesting depth of buffered groups, which 17.9 already caps at `8` — would bound the memory and almost nothing else. A buffer's storage may of course be reused once its owner has composited into its parent; the ceiling counts allocations, not surfaces. Allocation order is fixed: every layer buffer first, in layer document key order, then object buffers in **ascending representative depth** (17.6) — nearest first, so that what runs out is the far content. When an allocation would exceed the pool, it is **refused** and the owner is drawn without its buffer-requiring features: a non-default `blend` composites as `normal`, a `mask` is ignored, and `blur`, `glow`, and `filters` are omitted. Refusals are taken **farthest first** — among the objects competing for the last buffers, the greatest representative depth (17.6) loses first, ties broken by draw order — so what degrades is the content furthest from the viewer. Layer buffers are never refused; a layer set is at most `16` (17.5) and layers are the structural allocation the rest is composed into. This is a **diagnosed exception** to 17.1's guarantee that a declared appearance feature is never silently omitted, and it is stated here rather than discovered: buffer refusal raises `WARN_VISUAL_APPROXIMATION` under the cadence 19.5 fixes, so it is not silent, and it is a runtime resource ceiling rather than a renderer capability gap — the same feature on the same renderer draws exactly when the frame is simpler. **Cost.** This subsection declares the cost; 19.5 sets the ceiling, and the frame-wide post-effect passes of 19.4 are a separate budget counted separately. **Stroke-only primitives.** `line`, `polyline`, `arc`, and `bezier` have no interior. A `fill` *declared* on one of them is `ERR_UNKNOWN_FIELD`; a `fill` *inherited* from an ancestor group is ignored for those primitives and still applies to their fillable siblings. Inheritance never turns into a validation error. ### 17.13 Paths, splines, and text **Path (`path`).** `commands` is an ordered array of `1` to `512` command objects (PRD 76): | `op` | Fields | Notes | | --- | --- | --- | | `move` | `to` | Begins a subpath at `to`. | | `line` | `to` | Straight segment. | | `quadratic` | `c`, `to` | One quadratic segment with control point `c`. | | `cubic` | `c1`, `c2`, `to` | One cubic segment. | | `arc` | `radius`, `rotation`, `largeArc`, `sweep`, `to` | Endpoint-parameterized elliptical arc. `radius` is a number or `{x, y}`; `rotation` is degrees; `largeArc` and `sweep` are booleans defaulting to `false`. | | `close` | — | Closes the current subpath back to its `move` point. | A `path` also accepts `fillRule`, an enum of `nonzero` (default) or `evenodd`. Path legality: * A `commands` array whose first entry is not `move` is `ERR_INVALID_PATH`. * A `close` with no open subpath, or two consecutive `close` commands, is `ERR_INVALID_PATH`. * An `arc` with a zero or negative radius component is `ERR_OUT_OF_BOUNDS`. It is **not** silently degraded to a `line`: PRD 89 requires clearly excessive or degenerate values to fail validation rather than become something materially different. * More than `512` commands is `ERR_VISUAL_LIMIT_EXCEEDED`. **Spline (`spline`).** | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `points` | array of `{x, y, z?}` | Yes | — | `2` to `256` entries. | | `mode` | enum | No | `catmull-rom` | `catmull-rom`, `bezier`, `linear` (PRD 76). | | `closed` | boolean | No | `false` | Joins the last point back to the first. | | `tension` | ValueSpec\ | No | `0.5` | `0` to `1`. `catmull-rom` only; present under another mode is `ERR_UNKNOWN_FIELD`. | | `fillRule` | enum | No | `nonzero` | Applies only when the spline is `closed` and has a fill. | In `bezier` mode the points are read as `p0, c1, c2, p1, c1, c2, p2, ...`, so the entry count must satisfy `count = 3n + 1` for an integer `n >= 1`; any other count is `ERR_SCHEMA_VALIDATION`. **A `closed` `bezier` spline closes with a straight line** from the last on-curve point back to the first: the point list carries no control points for a closing segment, and inventing two would be authoring geometry the author did not write. In `linear` mode the spline is a polyline over its points, differing from `polyline` only in that its points are individually addressable, and `closed` joins last to first with a straight segment. **`catmull-rom` is a uniform cardinal spline, and its equation is normative.** For the segment between `p1` and `p2`, with neighbours `p0` and `p3`, parameter `u` from `0` to `1`, and `tension` `t`: ```text m1 = t * (p2 - p0) m2 = t * (p3 - p1) q(u) = (2u^3 - 3u^2 + 1) * p1 + (u^3 - 2u^2 + u) * m1 + (-2u^3 + 3u^2) * p2 + (u^3 - u^2) * m2 ``` Parameterization is **uniform**, not chordal or centripetal: `u` advances at the same rate on every segment regardless of its length. `tension` `0.5` is the classical Catmull-Rom, `0` gives straight segments between the points, and `1` gives the widest overshoot; the curve passes through every point at every tension. Index selection at the ends is fixed too: for an **open** spline the first and last points are duplicated as the phantom neighbours (`p0 = points[0]` for the first segment, `p3 = points[n-1]` for the last), and for a **closed** spline every index wraps modulo `n`, so a closed spline has `n` segments and an open one has `n - 1`. **Open geometry that carries a fill.** A `path` with more than one subpath, an open `path`, and an open `spline` may all declare a `fill`. Each open subpath is closed for the fill computation only, with a straight segment from its last point to its own `move` point; the stroke is drawn open, exactly as authored. `fillRule` then applies over the closed subpaths. This is a fill rule, not a geometry change: nothing about the stroked outline moves. **A `point`'s stroke.** A `point` draws a filled disc of diameter `style.pointSize` centered on its origin, painted with `fill`. A declared `stroke` outlines that disc with `strokeWidth`, centered on the disc's edge as every other stroke is. A `point` with `fill: null` and a `stroke` draws a ring; a `point` with both `null` draws nothing and raises no diagnostic. **Independently mutable points.** PRD 76 requires spline points to be independently mutable so that slow point wandering and morphing can build evolving organic geometry. This contract fixes what that means: a spline's point *count* is fixed at instantiation (17.14) and its point *indices* are stable for the object's lifetime, so index `i` always denotes the same point. Individual coordinates are addressable as `points[].x`, `.y`, and `.z` by the behaviors of 18.6 and by the visual automation of 19.1. More than `256` points is `ERR_VISUAL_LIMIT_EXCEEDED`. Morphing between two splines requires equal point counts and equal `mode`; section 18.6 fixes that rule and its diagnostic. **Text (`text`).** | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `text` | ValueSpec\ | Yes | — | Max `256` characters after resolution. | | `font` | enum | No | `sans-serif` | `sans-serif`, `serif`, `monospace` only. | | `size` | ValueSpec\ | No | `16` | Scene units, not CSS pixels. | | `weight` | enum | No | `normal` | `normal`, `bold`. | | `italic` | boolean | No | `false` | — | | `align` | enum | No | `left` | `left`, `center`, `right`, relative to the object's origin. | | `baseline` | enum | No | `alphabetic` | `top`, `middle`, `alphabetic`, `bottom`. | | `letterSpacing` | ValueSpec\ | No | `0` | Scene units. | | `maxWidth` | ValueSpec\ | No | — | When set, the rendered run is condensed horizontally to fit; it is never wrapped or truncated. Condensation is fixed below. | **`maxWidth` condensation.** Let `w` be the run's measured advance width in scene units, after `letterSpacing` and before any transform. When `maxWidth` is absent, or when `w <= maxWidth`, the run is drawn unmodified. Otherwise the run is scaled horizontally by `maxWidth / w` about the anchor selected by `align`, with the vertical scale unchanged; glyph shapes condense, the baseline does not move, and no character is dropped or wrapped. `maxWidth <= 0` is `ERR_OUT_OF_BOUNDS`. Because `w` comes from the viewer's own generic font, the condensation factor is a device-dependent number. That is not a reproducibility defect: section 9.3 promises identical procedural *decisions*, never identical pixels, and text metrics feed no decision — no ValueSpec reads them, no behavior branches on them, and no procedural sample is consumed by measuring. This subsection needs no new exemption for it, and 19.4's `grain` remains the only stated exemption in the visual contract. `font` is restricted to the three generic CSS families because section 13.3 requires the standalone artifact to carry every asset it needs and make no external request. A named family would resolve differently on each viewer's machine and could not be embedded without shipping a font file; 0.1 does not ship one. **Live numeric readouts are not in 0.1.** `text` resolves once at instantiation (17.14), so it renders authored strings and static labels, not values that change while the exhibit runs. The instrument and radar displays of PRD 130 item 7 and Exhibit D are built from generic geometry — `arc`, `ring`, `path`, and `polyline` driven by state through the mechanisms of section 19 — with `text` supplying fixed labels. This is a deliberate scope decision, not an oversight: a live readout needs a number-to-string formatting contract (precision, rounding, locale, unit suffixes) that PRD 72 does not specify and that would be the first subject-shaped construct in the visual set. Slice 4g revisits it if the Exhibit D visuals cannot be built without it. ### 17.14 Visual field resolution scope Numeric, boolean, string, and color fields on visual objects are authored with ValueSpec 0.1 (section 4) and DurationSpec 0.1 (section 6) exactly where each field table says so. Every such field is resolved **once**, at its owning object's instantiation boundary, producing that field's **sampled base**, which is constant for the object's lifetime. The base is not necessarily the value the frame draws: a behavior (18.6) or an automation track (19.1) composes over it, and for the four external target families of 8.1 the shared pipeline does. What is fixed at instantiation is the base — the sampling, and therefore every procedural decision — not the effective value. Nested ValueSpecs within one system are sampled in depth-first, property-document order from the system instance's own stream, per section 9.3 — the same rule section 14.4 fixes for audio node fields. One tie-break completes that rule for containers: an object's **own** fields are sampled in document order, and its `children` (or, for a `component`, the expanded `content`) are sampled **after** all of them, wherever the container key sits in the object. A group's style scope has to exist before its descendants can inherit it (17.12), so the two orders cannot be interleaved, and stating the tie-break is what keeps two renderers at the same stream position. The instantiation boundary is: activation, for a persistent system declared in `visuals.systems`; the spawn moment, for a spawned system (19.2); and the emission or repetition moment, for an object created by an emitter or repeater (18.4, 18.5). **Where a resolved value is checked, and with which code.** A ValueSpec resolves at an instantiation boundary, so a value the author never wrote can still be wrong. Three cases recur across sections 17-19 and are fixed here once rather than in each field table: | Case | Stage and diagnostic | | --- | --- | | An **integer-typed** field resolves to a non-integer — `count`, `octaves`, a burst `count`, a link `count` | `ERR_TYPE_MISMATCH` at the instantiation boundary. There is **no** rounding rule: section 2's type discipline is strict, and a field being integer-valued is not a licence to reshape a resolved number. An author who wants rounding writes it with an `op` (4.5). | | A **`text`** field resolves longer than `256` characters | `ERR_OUT_OF_BOUNDS` at the instantiation boundary, matching the literal case at import | | A **component input value** (18.1) falls outside the parameter's declared `min`/`max` | `ERR_OUT_OF_BOUNDS`: at import where the supplied value is a literal, at the instantiation boundary where it resolves | Numeric fields with a declared range that are *not* structural in this way — a camera `focalLength`, an effect `amount` — are clamped rather than rejected when they resolve out of range, which is the staging split 14.5 fixes for audio and 19.3 and 19.4 restate. The difference is whether the out-of-range value would change the *shape* of what is built (a count, a string length, a declared input contract) or only its magnitude. **Rendering consumes no procedural stream.** Section 9.3 already fixes this. A frame is not a decision point, so no visual field is re-sampled per frame, and an exhibit's procedural decisions are identical across render rates and machines. Visual draws use the `visual` stream domain. The reproducibility scope of section 14.6 has no visual analogue for anything an exhibit *declares*, because no declared visual construct generates output outside the seeded streams. There is exactly one exemption in the whole visual contract, and it is not this one: the `grain` post-effect of 19.4 draws per-pixel noise from a frame counter rather than from a seeded stream, is stated there, and consumes no procedural stream — so it shifts no later sample and breaks no decision-level guarantee. Time variation therefore comes from exactly three mechanisms, all specified later in this contract: 1. **Behaviors** (18.6) — drift, orbit, wander, point-wander, morph, and the rest of the PRD 82 vocabulary, attached to an object and advanced on the logical clock. 2. **Visual automation** (19.1) — declared tracks over numeric properties, with the `step`, `linear`, `exponential`, and `smooth` curves and the `repeat` and `ping-pong` loop modes of PRD 85. 3. **External control** — bindings, overrides, and modulation, for exactly those visual properties that section 19.1 adds to the section 8.1 target-capability table. Mechanisms 1 and 2 are internal writers declared inside the visual subsystem; mechanism 3 comes from outside it. Adding the first two never widens the third (16.2). Section 19.1 adds exactly four visual rows to that table, all system-level: `visuals.camera.`, `visuals.layers..opacity`, `visuals.systems..visible`, and `visuals.effects[].`. No per-object property is among them. A `BindingSpec`, `set` action, or `override` action addressing one — for example `visuals.systems.horizon.content.band.style.opacity` — is `ERR_UNSUPPORTED_TARGET`, and remains so in 0.1. Section 1.3 reserves `visuals.systems.*` as a *reference path* namespace; reserving a namespace does not grant a capability, and as section 8.1 states, merely being numeric does not grant automation, override, or modulation support. ### 17.15 New diagnostic codes Added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_INVALID_SYSTEM_TYPE` | Semantic | A `visuals.systems.` `type` is not a member of the Visual System Set 0.1. | | `ERR_INVALID_PRIMITIVE_TYPE` | Semantic | A visual object `type` is neither a member of Visual Primitive Set 0.1 (the fourteen geometry primitives of 17.9) nor the `component` object type 18.1 adds beside it. | | `ERR_INVALID_PATH` | Semantic | A path command sequence is structurally valid but illegal: it does not begin with `move`, or it closes a subpath that is not open. | | `ERR_VISUAL_LIMIT_EXCEEDED` | Semantic / Runtime | A visual authoring or runtime ceiling is exceeded (layers, group nesting, vertices, path commands, spline points, gradient stops, filters; section 18 adds the procedural-system ceilings and 19.5 adds the runtime ceilings). | | `WARN_VISUAL_APPROXIMATION` | Runtime | A declared appearance feature is unavailable on the active renderer and this contract's documented fallback was used. | ### 17.16 Required traces before slice 4d implementation is accepted Automated, and executable without a display measurement: 1. Each of the three coordinate spaces maps a known scene point to the expected display point under each `fit` mode, including the letterbox offsets of `contain` and the crop of `cover`; `width` or `height` outside `virtual` is `ERR_UNKNOWN_FIELD`, and either missing inside it is `ERR_SCHEMA_VALIDATION`. 2. Every primitive in Visual Primitive Set 0.1 instantiates from a minimal example, and an unknown `type` is `ERR_INVALID_PRIMITIVE_TYPE` while an unknown *property* on a known type is `ERR_UNKNOWN_FIELD`. 3. The transform composition order of 17.11 is verified by a case where rotation and non-uniform scale do not commute: a known local point maps to the documented device point, and reversing any two stages changes the result. 4. A nested `group` composes `M_parent x M_local` and `z_parent + z_local`; nesting `9` levels deep is `ERR_VISUAL_LIMIT_EXCEEDED` and `8` passes. 5. Depth sorting draws greater `z` first and preserves document key order for equal `z`; the sort is stable across repeated frames with unchanged input. 6. Perspective scaling matches `focalLength / (focalLength + z)` at three depths; an object at `z = -focalLength` is culled without a diagnostic; under `orthographic` the same object is unscaled. 7. Depth fog blends an object's colors by `density * clamp((z - near) / (far - near), 0, 1)` at `near`, midpoint, and beyond `far`; `far <= near` is `ERR_INVALID_RANGE_ORDER`. 8. Style inheritance is per field: a child redeclaring `fill` keeps its ancestor's `stroke`; a `fill` inherited by a `line` is ignored while a `fill` declared on a `line` is `ERR_UNKNOWN_FIELD`. 9. Gradient stops out of increasing order are `ERR_INVALID_RANGE_ORDER`; `17` stops, `5` filters, `17` layers, `513` path commands, `257` spline points, and `513` polygon vertices are each `ERR_VISUAL_LIMIT_EXCEEDED`, and each limit's maximum legal value passes. Group nesting depth is trace 4's `9`-versus-`8` case and is not restated here. 10. A renderer reporting no conic-gradient support falls back to the documented linear gradient and raises `WARN_VISUAL_APPROXIMATION` exactly once per paint instance across many frames, never once per frame. 11. Path legality: a first command other than `move` and an unopened `close` are `ERR_INVALID_PATH`; a zero-radius `arc` is `ERR_OUT_OF_BOUNDS` rather than a line; a `bezier`-mode spline whose point count is not `3n + 1` is `ERR_SCHEMA_VALIDATION`. 12. A `mask` naming a missing child is `ERR_INVALID_REFERENCE`; a `mask` on a non-`group` object is `ERR_UNKNOWN_FIELD`; the masked child is not itself drawn. 13. Visual ValueSpec fields resolve once: a `random` position sampled at instantiation holds its value across many frames, two runs of the same seed produce identical resolved values, and rendering `600` frames consumes no procedural stream. 14. Visual target capability is exactly the four families of the section 8.1 table (19.1), and no wider: a binding, `set`, or `override` addressing a per-object property — `visuals.systems.horizon.content.band.style.opacity` — is `ERR_UNSUPPORTED_TARGET`, as is one addressing a procedural-system field, a behavior field, a field `strength`, or an emitter `rate`. Within the four families, each operation is accepted only where that family's row permits it: `visuals.systems..visible` accepts a binding and an `override` and rejects an automation `target` naming it, `visuals.layers..opacity` accepts no modulation entry, and a value whose type does not match the row's type is `ERR_TYPE_MISMATCH`. An undeclared layer or system, or an `effects` index outside the authored array, is `ERR_INVALID_REFERENCE`. This trace verifies acceptance and rejection of the *targets*; whether an accepted binding then runs through the shared pipeline is trace 7 of 19.7, and a schema that parses is never evidence that a runtime stage executed. User-observed, and **not** satisfiable by the above: 15. The fourteen PRD 130 challenge items are reachable by composition of this primitive, transform, and appearance set with sections 18 and 19, judged visually on a real display, with no subject-specific renderer code. 16. The early combined GC6 benchmark of slice 4h, on recorded hardware at `1920 x 1080` with the environment details GC6 requires. Traces 15 and 16 close Phase 4 slice 4h. Until they do, Phase 4 is not accepted no matter how many automated traces pass. --- ## 18. Visual Subsystem Contract — Components, Procedural Systems, Behaviors, and Fields (Phase 4b) This section continues the Visual contract opened in section 17. It covers reusable visual components (PRD 77), particle systems (PRD 78), placement distributions (PRD 79), emitters (PRD 80), repeaters (PRD 81), the behavior vocabulary (PRD 82), procedural fields (PRD 83), and trails, ribbons, and links (PRD 84). Section 17 remains the owner of the scene, the primitive set, the transform model, and appearance; every construct here composes those and adds none of its own geometry. Visual automation (PRD 85), visual lifecycle and ownership (PRD 86), camera and projection (PRD 87), post-processing (PRD 88), and visual safety limits together with the centralized runtime ceilings (PRD 89, 119-120) are specified in section 19 (Phase 4c), which resolves every reference to 19.1, 19.2, and 19.5 made here. The subject-neutrality rule of 17.1 governs this section unchanged. No component, distribution, behavior, or field is named after an exhibit's subject: there is no `snow`, `rain`, `flock`, or `traffic` construct. The fourteen PRD 130 challenge items must be reachable by composing this generic vocabulary. ### 18.1 Visual components (`components.visual.`) ```json { "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" } } } } } } } ``` A `components.visual.` entry is a reusable sub-assembly of visual objects (PRD 77). It may combine primitives, groups, styles, transforms, and behaviors, exactly as an inline `content` container may. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `parameters` | object keyed by parameter ID | No | `{}` | Exposed inputs. Each is `{ "type": enum, "default"?: value, "min"?: number, "max"?: number, "unit"?: string }`. | | `content` | object keyed by visual object ID | Yes | — | Visual objects (17.9). An empty `content` object is `ERR_SCHEMA_VALIDATION`. | | `transform` | object | No | identity | Transform model (17.11), applied to the whole component instance. | | `style` | object | No | inherited | Appearance (17.12); the root style scope for the component's content. | | `behaviors` | array | No | `[]` | Behavior instances (18.6) attached to the instance as a whole. | `parameters..type` is one of `number`, `boolean`, `string`, or `color` — the shared scalar types of section 2. `min`, `max`, and `unit` are permitted only where `type` is `number`; elsewhere they are `ERR_UNKNOWN_FIELD`. A `default` whose type does not match `type` is `ERR_TYPE_MISMATCH`; a `default` outside a declared `min`/`max` is `ERR_OUT_OF_BOUNDS`. The audio contract admits only `number` (15.15) because an audio node field is always numeric; a visual field may be a color, a label, or a visibility flag, so the visual component set is the four shared scalar types and no more. Neither list is a ValueSpec type: `parameters` declares an input's *type*, and the instantiating site supplies a ValueSpec for it. **Component-local references.** Inside a component's `content`, and only there, a ValueSpec may use the reference form `{ "ref": "inputs." }` to read the instance's value for an exposed parameter. This is the same component-graph-scoped namespace rule 15.15 fixes for audio: `inputs.*` used anywhere else is `ERR_INVALID_REFERENCE`, it is not added to the section 1.3 document namespaces, and it grants no row in the section 8.1 target table. A reference to an undeclared parameter is `ERR_INVALID_REFERENCE`. A parameter with no `default` and no value supplied at the instantiating site is `ERR_INVALID_REFERENCE` at instantiation. **Encapsulation.** External documents may address a component only through its exposed parameters. A reference or a `mask` reaching an object key inside a component from outside it is `ERR_INVALID_REFERENCE`. Object keys inside a component are scoped to that component and may repeat keys used by the instantiating container without conflict; the expansion path of 18.1 disambiguates them for PRNG stream derivation, as 14.12 does for audio. **The `component` visual object type.** Section 17.9 fixes Visual Primitive Set 0.1 at fourteen geometry primitives. This section adds one further **visual object type** that is not a geometry primitive: | `type` | Fields | Notes | | --- | --- | --- | | `component` | `component`, `inputs` | Instantiates `components.visual.` in place. `inputs` is an object of ValueSpecs keyed by the component's exposed parameter IDs. | This mirrors 15.11, where the audio contract added a `component` node to the node set defined in 14.3. A `component` object accepts the common visual properties of 17.10 — `position`, `z`, `transform`, `style`, `behaviors`, `visible`, `lifetime` — and behaves as a `group` whose children are the component's `content`. It declares no geometry of its own; `size`, `radius`, `points`, and every other geometry field are `ERR_UNKNOWN_FIELD` on it. An `inputs` key naming an undeclared parameter is `ERR_INVALID_REFERENCE`. A `type` outside the fourteen primitives and `component` remains `ERR_INVALID_PRIMITIVE_TYPE`. **Nesting and recursion.** A component's `content` may contain `component` objects. Component nesting deeper than `8` levels, and any component that instantiates itself transitively, is `ERR_COMPONENT_RECURSION` — the same code and the same bound section 15.14 fixes for audio, reused rather than duplicated under a visual name. Component nesting and `group` nesting (17.9) are counted independently; each is bounded at `8`. **Expansion path and stream derivation.** A component instance expands into the containing tree at a **stable expansion path**: the instantiating object's full path (17.8) followed by each container key inside the component in turn. That path, not the component ID, is the stable instance key contributed to section 9.3 stream derivation, so two instances of one component sample independently and reproducibly, and reordering unrelated siblings does not perturb either. ### 18.2 Particle systems (`particles`) ```json { "type": "particles", "layer": "near", "capacity": 400, "count": 400, "lifetime": "12s", "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, "size": { "from": 2.4, "to": 0.6, "curve": "linear" }, "opacity": { "from": 0, "to": 0.85, "curve": "smooth" }, "render": { "type": "point", "style": { "fill": "#cfe4ff" } } } ``` A `particles` system maintains a bounded pool of lightweight items that share one appearance definition and one motion model. It is the cheapest way to place and move many objects, and it is what PRD 130 items 1, 3, and 9 are built from. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `render` | visual object or `component` object | Yes | — | The object drawn for each particle (17.9, 18.1). Its own `position`, `z`, and `visible` are `ERR_UNKNOWN_FIELD`; the particle owns those. | | `capacity` | number | No | `256` | Integer, `1` to `4096`. Maximum concurrent particles in the pool. | | `count` | ValueSpec\ | No | `0` | Integer, `0` through `capacity`; a larger resolved initial population is `ERR_VISUAL_LIMIT_EXCEEDED`. Particles created at the system's instantiation boundary (17.14). | | `rate` | ValueSpec\ | No | `0` | Particles per logical second, continuous. Emission timing is 18.4. | | `burst` | array | No | `[]` | Burst entries (18.4), `0` to `16`. | | `limit` | number | No | — | Integer. Total particles this system may ever create. Absent is unbounded. | | `lifetime` | DurationSpec | Conditional | — | Per-particle logical lifetime. Required when the system can create particles without bound; see below. | | `distribution` | object | No | `{ "type": "point" }` | Initial placement (18.3), in the system's local space. | | `position` | `{x, y}`, each ValueSpec\ | No | `{0, 0}` | Offset added to every distributed position. | | `velocity` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second. | | `acceleration` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second squared. | | `drag` | ValueSpec\ | No | `0` | `0` to `1`. Fraction of velocity lost per logical second; see below. | | `size` | ValueSpec\ or life ramp | No | `1` | Uniform local geometry scale on the `render` object; see below. | | `rotation` | ValueSpec\ | No | `0` | Degrees at creation. | | `angularVelocity` | ValueSpec\ | No | `0` | Degrees per logical second. | | `opacity` | ValueSpec\ or life ramp | No | `1` | `0` to `1`. Multiplies `render`'s style opacity. | | `color` | `color` or life ramp | No | — | When present, replaces `render`'s resolved `style.fill`, and its `style.stroke` where that is non-`null`. | | `z` | ValueSpec\ | No | `0` | Depth (17.6). A `depth` distribution (18.3) writes this instead. | | `behaviors` | array | No | `[]` | Behavior instances (18.6), applied per particle. | | `fields` | array of strings | No | `[]` | Field IDs (18.7) this system's particles respond to, `0` to `4`. | | `trail` | object | No | — | Trail block (18.8). | | `links` | object | No | — | Link block (18.8). | **Resolution boundaries: which fields belong to the system and which to the item.** "Every ValueSpec resolves per particle" cannot be literally true — `count` and `rate` decide *how many* particles to create and must be known before any of them exists. The fields divide into three classes, and this table is the normative division for `particles` and, with the noted substitutions, for `emitter` (18.4): | Class | Fields | Resolved | | --- | --- | --- | | **System-instantiation** | `count`, `rate`, `burst[].at`, `burst[].count`, `limit`, `capacity`, the `distribution`'s own configuration fields, `fields`, `trail`, `links` | Once, at the system's instantiation boundary (17.14), before any item is created. `capacity` and `limit` are plain numbers, not ValueSpecs, because a pool bound that could differ per item is not a bound. | | **System channels** | `position.x`, `position.y`, `acceleration.x`, `acceleration.y`, `acceleration.z`, `drag`, `visible` | Base resolved once at the system's instantiation boundary; these are the properties automation may drive live (19.1). | | **Per-item** | `velocity`, `size`, `rotation`, `angularVelocity`, `opacity`, `color`, `z`, `lifetime`, each life ramp's `from` and `to`, every ValueSpec inside `render` (including a `component` object's `inputs`), every behavior's fields, and the distribution's per-item samples | Once per item, at that item's creation, which is an instantiation boundary in the sense of 17.14 | Creation counts are resolved **before** allocation: a burst's `count` is a system-instantiation value precisely so that the number of items is known when the burst fires, and an integer field whose resolved value is not an integer is `ERR_TYPE_MISMATCH` at that boundary rather than being rounded (section 2). **What a live system channel does to items that already exist.** The distinction is whether the field is read at creation or every tick, and it is not a new rule — it follows from the integrator above: | Channel | Effect of a live change | | --- | --- | | `rate` | Future emission only. The fractional accumulator is **not** reset by the change (18.4), so cumulative counts stay continuous across it. | | `position.x`, `position.y` | Future items only: `position` is an offset added to a *distributed* position, which is read at creation. | | `acceleration.*`, `drag` | **Every live item**, from the next tick, because both are read by the integrator every tick. | | `visible` | The whole system's compositing, immediately; items continue to advance (17.7). | An automation track composes against the **sampled base** — the value the channel resolved to at the system's instantiation boundary — under the track's own `mode`, exactly as 16.1 fixes for audio: `absolute` replaces it, `offset` adds to it, `scale` multiplies it. The base is a fixed number for the system's life; the effective value is what the pipeline produces from it each tick. **Streams and ordinals.** Per-item fields are sampled from the system's stream in depth-first, property-document order (9.3), with the documented child key `#`, where the ordinal is monotonic within the system and starts at `0`. When a burst and continuous emission land on the same tick, ordinals are assigned **burst entries first, in `burst` array order, then the continuous emissions**, and each item's samples are drawn in that same order, so one seed creates identical particles in identical order across runs and across tick alignments. **What `size` scales, and where it sits.** `size` is a **uniform scale on the `render` object's local geometry**, applied innermost — before the `render` object's own `transform`, so that `transform.rotation` still turns a scaled shape rather than the reverse. It multiplies: | `render` type | What `size` multiplies | | --- | --- | | `point` | `style.pointSize` | | `ellipse`, `arc`, `ring` | `radius` and `innerRadius` | | `rectangle`, `rounded-rectangle` | `size.width`, `size.height`, and every corner `radius` | | `line`, `polyline`, `polygon`, `spline`, `bezier`, `path` | Every geometry coordinate, including each command's `to`, `c`, `c1`, `c2`, and `radius` | | `text` | `size` and `letterSpacing` | | `group`, `component` | The whole expanded sub-assembly, as one scale about its origin | It does **not** multiply `strokeWidth`, `style.blur`, `glow.radius`, `shadow` offsets, or `strokeDash` lengths: those are appearance dimensions, not geometry, and a particle system that shrinks its dots is not asking for hairline strokes. A particle whose `size` resolves to `0` draws nothing and raises no diagnostic, exactly as a `transform.scale` of `0` does (17.11). **Velocity alignment uses the XY plane.** Wherever this contract aligns an item to its motion — `emitter.align` (18.4), the `face-motion` behavior (18.6), and a `ribbon` trail's perpendicular (18.8) — the direction is `atan2(vy, vx)` in degrees over the **XY components of velocity only**. The model is 2.5D (17.6): `z` is a depth coordinate, not a third axis of a rotation an object could take. An item whose XY speed is below `1e-6` scene units per second — including one moving purely in `z` — holds its previous rotation, and at creation holds its authored `rotation`. **Life ramps.** `size`, `opacity`, and `color` may be a **life ramp** instead of a ValueSpec: ```json { "from": 0, "to": 1, "curve": "smooth" } ``` | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `from` | ValueSpec of the field's type | Yes | — | Value at normalized age `0`. | | `to` | ValueSpec of the field's type | Yes | — | Value at normalized age `1`. | | `curve` | enum | No | `linear` | `step`, `linear`, `exponential`, `smooth` — the curve set PRD 85 fixes for automation. | Normalized age is `clamp(age / lifetime, 0, 1)` on the logical clock, and is the `age` property PRD 78 requires. A ramp on a particle with no `lifetime` is `ERR_SCHEMA_VALIDATION`, because normalized age is undefined without one. `exponential` on a `from` or `to` of `0`, or on a pair that crosses zero, falls back to linear interpolation and raises `WARN_AUTOMATION_FALLBACK`, matching 16.1. A `color` ramp interpolates in sRGB component space including alpha. A life ramp is an **interpolation of already-resolved endpoints**, not a re-sampling. `from` and `to` resolve once at particle creation and the curve between them is a pure function of age. Rendering therefore still consumes no procedural stream (9.3, 17.14). **Motion integration is normative.** Particle motion advances on the fixed logical tick of 9.1 and never on the frame. For a tick of `dt` logical seconds, in this order: ```text v <- (v + a * dt) * (1 - drag)^dt p <- p + v * dt theta <- theta + angularVelocity * dt ``` Field forces (18.7) are summed into `a` before the velocity update; behaviors (18.6) apply after the position update, in array order. Semi-implicit Euler in this order is the normative integrator: a renderer that integrates position before velocity, or applies drag as a per-tick multiplier independent of `dt`, produces visibly different motion at a different tick rate and does not conform. A frame between two ticks draws the most recent tick's state; it does not advance the simulation. **Unbounded emission.** A system whose `rate` resolves above `0` or whose `burst` array is non-empty, with no `lifetime` and no `limit`, would grow until it hit its `capacity` and then churn forever with no authored intent. That combination is `ERR_UNBOUNDED_EMISSION` at semantic validation. `count` alone needs no `lifetime`: a fixed initial population that never expires is a legal and common starfield. **Pool exhaustion.** When a creation would exceed `capacity`, the oldest living particle is evicted and replaced, mirroring the oldest-first voice eviction of 16.6. Eviction is silent at the system level; the aggregate runtime ceiling across all systems, and its diagnostic, are fixed in 19.5. `capacity` above `4096` is `ERR_VISUAL_LIMIT_EXCEEDED`. **Drawing.** Particles of one system draw as one unit within their layer, sorted among themselves by effective `z` descending and then by creation ordinal ascending, under the same stable rule as 17.6. The system's own position in its layer's depth sort is its lowest-`z` particle, so a particle system never interleaves with unrelated objects on a per-particle basis; interleaving deep and near content is what layers (17.5) are for. ### 18.3 Placement distributions A **distribution** answers one question: where does an item start? It is used by `particles` (18.2), `emitter` (18.4), and `repeater` (18.5), and by the `path` and `grid` placements PRD 79 requires. A distribution never animates anything; motion is behaviors (18.6) and fields (18.7). All coordinates are in the owning system's local space, in scene units (17.2). A distribution is `{ "type": enum, ...type-specific fields }`. A `type` outside the nine below is `ERR_INVALID_DISTRIBUTION_TYPE`. | `type` | Fields | Placement | | --- | --- | --- | | `point` | `at` (`{x, y, z?}`, default origin) | Every item at one position. Consumes no procedural sample. | | `uniform` | `min`, `max` (`{x, y, z?}`) | Uniform inside the axis-aligned box. `max` component below `min` is `ERR_INVALID_RANGE_ORDER`. | | `line` | `from`, `to` (`{x, y, z?}`), `mode` | `random` (default) samples uniformly along the segment; `even` places item `i` at `i / (count - 1)`. | | `rectangle` | `center`, `size`, `fill` | `fill` is `area` (default) or `perimeter`. `perimeter` samples by edge length, so a long edge receives proportionally more items. | | `ellipse` | `center`, `radius` (number or `{x, y}`), `fill` | `area` (default) is uniform by area, not by radius: sample `r = radius * sqrt(u)`. `perimeter` places on the boundary. | | `ring` | `center`, `radius`, `innerRadius`, `startAngle`, `endAngle`, `direction`, `mode` | Uniform by area within the annulus or annular sector. `innerRadius` defaults to `0`; not below `radius` is `ERR_INVALID_RANGE_ORDER`. `mode` `even` distributes angle evenly by index. | | `path` | `path`, `mode`, `align` | `path` is an inline `commands` array (17.13) — see the reference-scope note below. `mode` is `random` (default, uniform by arc length) or `even`. `align` (default `false`) sets the item's initial `rotation` to the curve tangent. | | `grid` | `origin`, `columns`, `rows`, `spacing`, `jitter` | Deterministic, row-major from `origin`. `columns` and `rows` are `1` to `256`. `jitter` (`{x, y}`, default `{0, 0}`) offsets each cell by a uniform sample in `[-jitter, +jitter]`. | | `depth` | `near`, `far`, `curve` | Assigns `z` only; `x` and `y` stay at the item's `position`. `curve` is `uniform` (default), `linear`, or `exponential`, biasing items toward `near`. `far` not above `near` is `ERR_INVALID_RANGE_ORDER`. | **Depth composition.** Any distribution except `depth` accepts an optional `depth` sub-block with the `depth` type's own fields, so a `grid` of panels can also be spread in `z` without a second distribution. A `depth` sub-block on a `depth` distribution is `ERR_UNKNOWN_FIELD`. A distribution whose fields carry a `z` component and that also declares a `depth` sub-block is `ERR_UNKNOWN_FIELD`: `z` comes from one source. **Index-driven modes need a known count.** `even` on `line`, `ring`, or `path`, and the `grid` type itself, place item `i` of `n` by index. They are legal in a `repeater` (whose `count` is fixed) and in a `particles` system's `count` population and its `burst` entries (each burst has its own `n`), and they are `ERR_INVALID_DISTRIBUTION` on a continuous `rate` emission, where `n` is unknown at creation time. **A distribution's `path` is inline only.** A distribution belongs to a *system*, and a system's siblings in `visuals.systems` are systems, not visual objects, so a "sibling `path` object in the same container" names a container that does not exist at this scope. The shorthand is therefore removed rather than given an invented scope: a `path` distribution carries its `commands` inline, under the full command contract of 17.13 including the `1`-to-`512` bound, the `move`-first rule, and `ERR_INVALID_PATH`. The sibling-key form survives exactly where it does have a container — the `follow-path` behavior of 18.6, which is attached to an *object* and whose siblings are objects — and a key naming an object inside a component from outside it remains `ERR_INVALID_REFERENCE` (18.1). **Index-driven placement, written out.** For item `i` of `n`, with `frac(i) = i / (n - 1)` and `frac(i) = 0` when `n == 1` — the same `n == 1` convention `repeat.fraction` uses (18.5): | Mode | Placement | | --- | --- | | `line` `even` | `from + (to - from) * frac(i)` | | `ring` `even` | Angle `startAngle + sweep * (i / n)` when the sector is a full turn, and `startAngle + sweep * frac(i)` otherwise, with `sweep` computed as 17.9 fixes for `arc` and `ring`. The radius is `(innerRadius + radius) / 2`, the mid-annulus, because an even angular placement with a random radius is neither even nor reproducible without a sample. | | `path` `even` | Arc-length position `i / n` on a closed path and `frac(i)` on an open one | | `grid` | Row-major from `origin`: cell `c = i mod (columns * rows)`, column `c mod columns`, row `floor(c / columns)`, at `origin + (column * spacing.x, row * spacing.y)`. When `n` is less than `columns * rows` the first `n` cells are used; when it is greater the placement wraps, so a count and a grid shape never have to agree. | **Continuous forms, written out.** With `u`, `u1`, `u2` uniform samples in `[0, 1)`: | Form | Mapping | | --- | --- | | `ellipse` `area` | `angle = 360 * u1`, `r = sqrt(u2)`, point `center + (radius.x * r * cos angle, radius.y * r * sin angle)` | | `ellipse` `perimeter` | `angle = 360 * u`, point `center + (radius.x * cos angle, radius.y * sin angle)`. This is uniform in the **parameter**, not in arc length, so an eccentric ellipse is denser at its ends. That is the chosen method and it costs one sample; arc-length uniformity would cost an inversion table for a difference no exhibit in 0.1 can observe. | | `rectangle` `perimeter` | One sample `u` mapped to the distance `u * P` around the perimeter, starting at the top-left corner and proceeding clockwise, where `P = 2 * (width + height)` | | `ring` `random` | `angle` uniform within the sector, then `r = sqrt(innerRadius^2 + u * (radius^2 - innerRadius^2))`, which is uniform by area | | `path` `random` | Uniform by arc length. The curve is flattened to a polyline with a maximum deviation of `0.1` scene units, segment lengths are accumulated in command order, and one sample selects a distance along the total. The tolerance is normative so that two renderers place items at the same distance. | | `depth`, `uniform` curve | `z = near + (far - near) * u` | | `depth`, `linear` curve | `z = near + (far - near) * u^2` | | `depth`, `exponential` curve | `z = near + (far - near) * (1 - exp(-3u)) / (1 - exp(-3))` | Both non-`uniform` depth curves are monotonic in `u` and bias items toward `near`, which is what the row promises; naming a bias without an inverse function would leave every renderer to invent its own. **Sample consumption is an explicit list, not a reading of the field order.** Parameter *declaration* order and random *variate* order are different things, and this table fixes the second. Samples are drawn from the owning system's stream once per item, at that item's creation, in exactly this order: | Distribution and mode | Samples drawn, in order | | --- | --- | | `point` | none | | `uniform` | `x`, `y`, then `z` when `min`/`max` carry a `z` component | | `line` `random` | `t` | | `line` `even` | none | | `rectangle` `area` | `x`, `y` | | `rectangle` `perimeter` | `u` (one sample, not two) | | `ellipse` `area` | `angle`, then `radius` | | `ellipse` `perimeter` | `angle` | | `ring` `random` | `angle`, then `radius` | | `ring` `even` | none | | `path` `random` | `t` | | `path` `even` | none | | `grid`, `jitter` absent or `{0, 0}` | none | | `grid`, `jitter` non-zero | `jx`, then `jy` | | `depth` (as a type or as a sub-block) | `u`, except under an `even`-style curve, of which there is none | Angle before radius is locked, and `x` before `y` before `z` is locked. An optional `depth` sub-block draws its `u` **after** the host distribution's own samples. A `grid` with non-zero `jitter` therefore consumes two samples per item: the "consumes no samples" property belongs to `grid` with absent or zero `jitter` and to every `even` mode, not to `grid` unconditionally. **Creation index for bursts.** `even` modes and `grid` place item `i` of `n`. In a `repeater`, `n` is the resolved `count` and `i` is the copy index. In a `particles` system's initial population, `n` is the resolved `count` and `i` is the creation ordinal. In a **burst**, `n` is that burst entry's own resolved `count` and `i` restarts at `0` within the burst, so each burst lays out a complete figure rather than continuing the previous one's indices. ### 18.4 Emitters (`emitter`) ```json { "type": "emitter", "layer": "near", "emit": { "type": "component", "component": "spark", "inputs": { "tint": "#ffcf9b" } }, "rate": 6, "burst": [{ "at": "0s", "count": 24 }], "capacity": 120, "lifetime": { "random": { "min": "1.2s", "max": "3.4s" } }, "distribution": { "type": "ellipse", "center": { "x": 400, "y": 300 }, "radius": 40 }, "velocity": { "x": { "random": { "min": -30, "max": 30 } }, "y": -80 } } ``` An `emitter` creates full visual objects or component instances over time (PRD 80). It is heavier per item than a particle — each emitted item is an independent object with its own transform, style, behaviors, and children — and its ceilings are correspondingly lower. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `emit` | visual object or `component` object | Yes | — | The object created per emission (17.9, 18.1). Its `position`, `z`, and `visible` are `ERR_UNKNOWN_FIELD`; the emitter owns those. | | `rate` | ValueSpec\ | No | `0` | Emissions per logical second. | | `burst` | array | No | `[]` | `0` to `16` entries of `{ "at": DurationSpec, "count": ValueSpec }`, `at` measured from the emitter's instantiation boundary. | | `limit` | number | No | — | Integer. Total emissions this emitter may ever make. | | `capacity` | number | No | `64` | Integer, `1` to `512`. Maximum concurrent live items. | | `lifetime` | DurationSpec | Conditional | — | Per-item logical lifetime; see the unbounded-emission rule below. | | `distribution` | object | No | `{ "type": "point" }` | Initial placement (18.3). | | `position` | `{x, y}`, each ValueSpec\ | No | `{0, 0}` | Offset added to every distributed position. | | `velocity` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second. | | `acceleration` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second squared. | | `drag` | ValueSpec\ | No | `0` | `0` to `1` per logical second, integrated as in 18.2. | | `align` | boolean | No | `false` | When `true`, the item's `rotation` is set to its velocity direction at creation. | | `behaviors` | array | No | `[]` | Behavior instances (18.6), applied per item. | | `fields` | array of strings | No | `[]` | Field IDs (18.7), `0` to `4`. | | `trail` | object | No | — | Trail block (18.8). | An `inputs` key on the **emitter** is `ERR_UNKNOWN_FIELD`: component inputs have exactly one location, and it is the `component` object's own `inputs` inside `emit`, as the example above shows and as 18.1 fixes for every `component` object anywhere in the document. **Emission timing is normative.** The emitter keeps a fractional accumulator, initially `0`. On each logical tick of `dt` seconds it adds `rate * dt`, then emits `floor(accumulator)` items and subtracts that integer, so the cumulative emission count after `t` seconds at a constant rate is `floor(rate * t)` exactly, with no drift and no dependence on tick alignment. When `rate` changes under automation (19.1), the accumulator is not reset. A burst entry emits its whole `count` on the first tick at or after its `at` offset. Items emitted on one tick are created in ordinal order and are indistinguishable in ordering from items emitted one per tick. **Per-item resolution.** The three-class division of 18.2 governs an emitter unchanged, with `emit` substituted for `render`: `rate`, `burst[].at`, `burst[].count`, `limit`, `capacity`, the `distribution`'s configuration, `fields`, and `trail` are **system-instantiation** values; `position`, `acceleration`, and `drag` are **system channels** with a base resolved there; and `velocity`, `align`, `lifetime`, every ValueSpec inside `emit` (including a `component` object's own `inputs`), and every behavior field are **per-item**, resolved at emission from the emitter's stream with the child key `#`. A burst's `count` resolves at the emitter's instantiation boundary so that the number of items is known before the burst fires, and a burst and a continuous emission on one tick order burst-first exactly as 18.2 fixes. **Unbounded emission.** An emitter with a non-zero `rate` or a non-empty `burst`, and neither `lifetime` nor `limit`, is `ERR_UNBOUNDED_EMISSION`, for the reason given in 18.2. An emitter with a `limit` and no `lifetime` is legal: it creates a bounded number of persistent items. **Capacity.** When an emission would exceed `capacity`, the oldest live item is removed and replaced. Removal runs the item's own removal path — its `trail` history is discarded and its behaviors stop — and is not a scenario failure. `capacity` above `512` is `ERR_VISUAL_LIMIT_EXCEEDED`. The aggregate ceiling over all emitters and its diagnostic are 19.5. **Ownership.** Items an emitter creates are owned by the emitter, and the emitter is owned by whatever owns the system (section 10.1). Ownership of *spawned systems* — an emitter created by an action rather than declared in `visuals.systems` — is 19.2, not this section. ### 18.5 Repeaters (`repeater`) ```json { "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 } } } ``` A `repeater` creates a fixed number of **persistent** copies of an object or component at its instantiation boundary and never creates another (PRD 81). Panel arrays, building-like structures, instrument grids, repeating indicators, abstract cells, windows, and machinery patterns are all one repeater over a distribution. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `repeat` | visual object or `component` object | Yes | — | The object copied. Its `position` and `z` are `ERR_UNKNOWN_FIELD`; the repeater owns those. | | `count` | ValueSpec\ | Yes | — | Integer, `1` to `1024`. Resolved once, at the repeater's instantiation boundary. | | `distribution` | object | No | `{ "type": "point" }` | Placement (18.3). | | `position` | `{x, y}`, each ValueSpec\ | No | `{0, 0}` | Offset added to every distributed position. | | `behaviors` | array | No | `[]` | Behavior instances (18.6), applied per copy. | | `fields` | array of strings | No | `[]` | Field IDs (18.7), `0` to `4`. | | `links` | object | No | — | Link block (18.8). | A `repeater` has no `rate`, `burst`, `limit`, `capacity`, `lifetime`, or `inputs`; each is `ERR_UNKNOWN_FIELD`. Copies live as long as the repeater. `count` above `1024` is `ERR_VISUAL_LIMIT_EXCEEDED`. A spawned repeater still takes `spawn.lifetime` for the *instance*, which is a different field in a different container (17.7, 19.2). **One location for component inputs.** As on an emitter, a `component` object inside `repeat` carries its own `inputs` (18.1) and that is the only place per-copy component parameters are supplied — the example above shows exactly that shape. There is no second, system-level `inputs` to reconcile it with, so there is no precedence rule, no duplicate-key case, and no question about which of two locations a `repeat.*` reference is sampled in. **Per-copy resolution and the `repeat.*` namespace.** Each copy is an instantiation boundary (17.14): a `random` or `choose` anywhere inside `repeat`, including inside a `component` object's `inputs`, is sampled once per copy, in ascending copy index, from the repeater's stream with the child key `#`. Inside a repeater's `repeat`, and only there, a ValueSpec may read three copy-scoped values: | Reference | Type | Value | | --- | --- | --- | | `repeat.index` | number | The copy's `0`-based index. | | `repeat.count` | number | The resolved `count`. | | `repeat.fraction` | number | `index / (count - 1)`, or `0` when `count` is `1`. | `repeat.*` is scoped exactly as `inputs.*` is (18.1, 15.15): used outside a repeater it is `ERR_INVALID_REFERENCE`, it is not a section 1.3 document namespace, and it grants no section 8.1 capability. It is what makes an index-driven array — a gradient of tints across a panel wall, a ring of ticks at increasing angles — expressible without an authored list of ninety-six objects. **Copies are not addressable.** A copy has no document key, so nothing outside the repeater can reference one. A reference path naming a copy is `ERR_INVALID_REFERENCE`. Per-copy variation is authored through `repeat.*` and `inputs`, which is the whole point of the construct. ### 18.6 Visual behaviors A **behavior** is a declared, generic motion or appearance rule attached to a visual object, a system, a particle, or an emitted item. Behaviors are the first of the three time-variation mechanisms 17.14 names, and the only one available before section 19 lands. `behaviors` is an array of `0` to `8` entries; more is `ERR_VISUAL_LIMIT_EXCEEDED`. Each entry is `{ "type": enum, ...type-specific fields }`, and a `type` outside the seventeen below is `ERR_INVALID_BEHAVIOR_TYPE`. Every behavior field is a ValueSpec resolved once at its owning object's instantiation boundary (17.14); a behavior's *effect* varies with logical time, its *configuration* does not. Behaviors advance on the fixed logical tick of 9.1, after the particle integration of 18.2 and in array order. Each writes to one or more **channels** — `position`, `z`, `transform.rotation`, `transform.scale`, `style.opacity`, and the geometry channels named below. Within one object, behaviors compose by accumulation on `position`, `z`, and `transform.rotation`, by multiplication on `transform.scale` and `style.opacity`, and last-writer-wins on everything else. Composition against visual automation and against external control is fixed in 19.1; until then no external writer exists, so array order is the whole story. | `type` | Fields | Effect | | --- | --- | --- | | `drift` | `velocity` (`{x, y, z}`), `damping` | Adds a constant velocity, optionally decaying by `(1 - damping)^dt` per logical second. | | `rotate` | `speed`, `origin` | Adds `speed` degrees per logical second to `transform.rotation` about `origin` (default the object's own `origin`). | | `oscillate` | `property`, `amplitude`, `frequency`, `phase`, `waveform`, `center` | Drives one channel as `center + amplitude * w(frequency * t + phase / 360)`. `waveform` is `sine` (default), `triangle`, `square`, or `sawtooth`. | | `orbit` | `center`, `radius` (number or `{x, y}`), `speed`, `phase` | Moves the object around `center` at `speed` degrees per logical second. A `{x, y}` radius gives an elliptical orbit. | | `wander` | `strength`, `rate`, `maxSpeed` | A coherent two-dimensional random walk: the wander direction turns by a value-noise sample (18.7) advanced at `rate` per logical second, and `strength` scales the resulting acceleration. `maxSpeed` clamps the accumulated wander velocity. | | `follow-path` | `path`, `speed` or `duration`, `loop`, `align`, `offset` | Moves the object along a curve. `path` is a `commands` array or a sibling object key, as in 18.3. Exactly one of `speed` (scene units per logical second) and `duration` is required; both or neither is `ERR_SCHEMA_VALIDATION`. `loop` is `once` (default), `repeat`, or `ping-pong`. `align` (default `false`) sets `transform.rotation` to the tangent. `offset` is a `0`-to-`1` starting position along the curve. | | `point-wander` | `amplitude`, `rate`, `indices` | Moves the individual points of the owning object by independent coherent noise, giving the slow organic deformation PRD 76 asks for. `indices` optionally restricts the effect to listed point indices. | | `pulse` | `property`, `amplitude`, `frequency`, `curve`, `duty` | A periodic one-shot envelope on one channel: each period rises and falls over `duty` (`0` to `1`, default `0.5`) of the period under `curve`, and holds at the base value for the rest. | | `twinkle` | `property`, `min`, `max`, `rate` | Independent per-object flicker between `min` and `max`, driven by a value-noise stream advanced at `rate` per logical second. Its phase offset is drawn once from the owning stream, so siblings twinkle out of step without any authored jitter. | | `noise-displace` | `amplitude` (`{x, y, z}`), `scale`, `speed`, `octaves`, `persistence` | Offsets `position` by coherent noise (18.7) sampled at the object's own position, giving flow-like collective motion without a declared field. | | `face-motion` | `offset`, `smoothing` | Sets `transform.rotation` to the direction of the object's current velocity, plus `offset` degrees. `smoothing` (`0` to `1`, default `0`) is an exponential follow. An object with zero velocity holds its previous rotation. | | `wrap` | `bounds`, `margin` | When the object leaves `bounds`, it re-enters from the opposite side. `bounds` is `scene` (default) or `{x, y, width, height}`. `margin` (default `0`) delays the wrap until the object is fully outside. | | `bounce` | `bounds`, `restitution`, `axes` | Reflects velocity at the `bounds` edges. `restitution` is `0` to `1` (default `1`). `axes` is `both` (default), `x`, or `y`. | | `attract` | `target`, `strength`, `falloff`, `minDistance`, `maxDistance` | Accelerates the object toward `target` — a `{x, y}` point or a sibling object key. | | `repel` | same as `attract` | Accelerates the object away from `target`. | | `field-follow` | `field`, `strength`, `mode` | Couples the object to a declared field (18.7). `mode` is `force` (default, the field is added to acceleration), `velocity` (the field sets velocity directly), or `direct` (the field displaces position). | | `morph` | `to`, `duration`, `loop`, `curve` | Interpolates the owning object's geometry toward the geometry of the object named by `to`. | **Shared numeric conventions.** `frequency` and `rate` are in occurrences per logical second (17.2); `speed` is degrees per logical second for angular behaviors and scene units per logical second for linear ones, as each row states; `phase` and every angle are degrees (17.2). `falloff` on `attract` and `repel` is `none`, `linear` (default), `inverse`, or `inverse-square`; `minDistance` (default `1`) clamps the denominator so the force is finite at the target, and `maxDistance` (absent by default) is the radius outside which the behavior contributes nothing. **Field contracts.** Every field below is a ValueSpec of the stated type unless the row says enum, boolean, or array, and every one resolves once at the owning object's instantiation boundary. A required field that is absent is `ERR_SCHEMA_VALIDATION`; a value outside a stated range is `ERR_OUT_OF_BOUNDS` as a literal at import and is clamped when it resolves at instantiation, per 14.5's staging rule. | `type` | Field | Required | Default | Range | | --- | --- | :---: | --- | --- | | `drift` | `velocity` (`{x, y, z}`) | Yes | — | — | | | `damping` | No | `0` | `0` to `1` | | `rotate` | `speed` | Yes | — | — | | | `origin` (`{x, y}`) | No | the object's `transform.origin` | — | | `oscillate` | `property` (enum) | Yes | — | The channel set below | | | `amplitude` | Yes | — | — | | | `frequency` | No | `1` | `0` or above | | | `phase` | No | `0` | Degrees | | | `waveform` (enum) | No | `sine` | `sine`, `triangle`, `square`, `sawtooth` | | | `center` | No | `0` | — | | `orbit` | `center` (`{x, y}`) | Yes | — | — | | | `radius` (number or `{x, y}`) | Yes | — | — | | | `speed` | Yes | — | — | | | `phase` | No | `0` | Degrees | | `wander` | `strength` | Yes | — | `0` or above | | | `rate` | No | `1` | `0` or above | | | `maxSpeed` | No | — | Above `0`; absent means no clamp | | `follow-path` | `path` (array) | Yes | — | Inline `commands` (17.13) or a sibling object key | | | `speed` or `duration` | Exactly one | — | `duration` is a DurationSpec | | | `loop` (enum) | No | `once` | `once`, `repeat`, `ping-pong` | | | `align` (boolean) | No | `false` | — | | | `offset` | No | `0` | `0` to `1` | | `point-wander` | `amplitude` (`{x, y, z}`) | Yes | `z` defaults `0` | — | | | `rate` | No | `1` | `0` or above | | | `indices` (array of integers) | No | every point | `0` to `count - 1`, else `ERR_OUT_OF_BOUNDS` | | `pulse` | `property` (enum) | Yes | — | The channel set below | | | `amplitude` | Yes | — | — | | | `frequency` | No | `1` | `0` or above | | | `curve` (enum) | No | `smooth` | `step`, `linear`, `exponential`, `smooth` | | | `duty` | No | `0.5` | Above `0` to `1` | | `twinkle` | `property` (enum) | No | `style.opacity` | The channel set below | | | `min` | No | `0` | — | | | `max` | No | `1` | — | | | `rate` | No | `1` | `0` or above | | `noise-displace` | `amplitude` (`{x, y, z}`) | Yes | `z` defaults `0` | — | | | `scale` | No | `100` | Above `0`, else `ERR_OUT_OF_BOUNDS` | | | `speed` | No | `0` | — | | | `octaves` (integer) | No | `1` | `1` to `4` | | | `persistence` | No | `0.5` | `0` to `1` | | `face-motion` | `offset` | No | `0` | Degrees | | | `smoothing` | No | `0` | `0` to `1` | | `wrap` | `bounds` | No | `scene` | `scene` or `{x, y, width, height}` | | | `margin` | No | `0` | `0` or above | | `bounce` | `bounds` | No | `scene` | As `wrap` | | | `restitution` | No | `1` | `0` to `1` | | | `axes` (enum) | No | `both` | `both`, `x`, `y` | | `attract`, `repel` | `target` (`{x, y}` or sibling key) | Yes | — | — | | | `strength` | Yes | — | — | | | `falloff` (enum) | No | `linear` | `none`, `linear`, `inverse`, `inverse-square` | | | `minDistance` | No | `1` | Above `0` | | | `maxDistance` | No | — | Above `minDistance`, else `ERR_INVALID_RANGE_ORDER` | | `field-follow` | `field` (string) | Yes | — | A declared field ID (18.7) | | | `strength` | No | `1` | — | | | `mode` (enum) | No | `force` | `force`, `velocity`, `direct` | | `morph` | `to` (string) | Yes | — | A sibling object key | | | `duration` (DurationSpec) | Yes | — | — | | | `loop` (enum) | No | `once` | `once`, `repeat`, `ping-pong` | | | `curve` (enum) | No | `linear` | The four-curve set of 18.2 | **Waveforms are equations in cycles.** For `oscillate`, let the cycle position be ```text phi = frac(frequency * t + phase / 360) ``` where `t` is logical seconds since the owning object's instantiation boundary — the same origin every behavior uses — and `frac(v) = v - floor(v)`. The four waveforms are: ```text sine(phi) = sin(2 * pi * phi) triangle(phi) = 1 - 4 * | frac(phi + 0.25) - 0.5 | square(phi) = +1 when phi < 0.5, otherwise -1 sawtooth(phi) = 2 * frac(phi + 0.5) - 1 ``` All four are `0` at `phi = 0` except `square`, which has no zero, and all four have range `[-1, 1]`. The behavior's contribution to its channel is `center + amplitude * w(phi)`. `pulse` uses the same `phi` and a one-shot envelope inside each period, rising over the first half of `duty` and falling over the second: ```text e(phi) = curve(2 * phi / duty) when phi < duty / 2 = curve(2 - 2 * phi / duty) when duty / 2 <= phi < duty = 0 otherwise ``` with `curve` the four-curve set of 18.2 evaluated from `0` to `1`. The contribution is `amplitude * e(phi)`, so the channel rests at its base value for `1 - duty` of every period. `twinkle` reads one coherent-noise stream (18.7) and maps it to `min + (max - min) * (n + 1) / 2`, where `n` is the noise value in `[-1, 1]`. **Accumulated offsets versus fresh evaluation.** A behavior is one or the other, never both, and confusing them turns an orbit into a spiral: | Behavior | Kind | | --- | --- | | `drift` | **Accumulating.** Its contribution grows by `velocity * dt` each tick, with `velocity` scaled by `(1 - damping)^dt` first. | | `rotate` | **Accumulating** on `transform.rotation`, by `speed * dt` each tick. | | `wander`, `bounce`, `attract`, `repel`, `field-follow` in `force` or `velocity` mode | **Accumulating** through velocity: they write the item's velocity, which the integrator of 18.2 turns into displacement. | | `wrap` | **Accumulating.** A wrap is a permanent relocation, not a per-tick displacement: crossing an edge adds the bounds extent to the object's accumulated offset once and it stays added. | | `oscillate`, `pulse`, `twinkle`, `orbit`, `follow-path`, `noise-displace`, `point-wander`, `face-motion`, `field-follow` in `direct` mode, `morph` | **Fresh.** The contribution is a pure function of `t` and the resolved configuration, recomputed from scratch every tick and never integrated. `field-follow` in `direct` mode displaces by `field x strength` scene units with **no** `dt` factor — an instantaneous displacement, which is what makes it the mode that does not integrate. | For `orbit` in particular the contribution is an absolute placement expressed as an offset from the object's resolved base position `p0`: ```text theta = speed * t + phase contribution = center + (radius.x * cos theta, radius.y * sin theta) - p0 ``` Recomputed each tick from `t`, this traces the ellipse exactly. Added as a fresh displacement each tick — the mistake the distinction exists to prevent — it would integrate into an outward spiral. `face-motion` turns toward its target angle by the fraction `1 - smoothing^dt` per tick, taking the shorter way round: `smoothing: 0` snaps immediately, `smoothing: 1` never turns, and intermediate values are frame-rate independent. Its target angle is the XY velocity direction plus `offset`, and the zero-velocity rule is the one 18.2 fixes for velocity alignment generally. **Channel write sets, for conflict detection.** 19.1 makes a behavior and an automation track on one object channel `ERR_AUTOMATION_CONFLICT`. Detecting that needs each behavior's write set stated as *scalar* channels: | Behavior | Channels written | | --- | --- | | `drift`, `noise-displace` | `position.x`, `position.y`, `z` | | `orbit`, `wander`, `wrap` | `position.x`, `position.y` | | `rotate`, `face-motion` | `transform.rotation` | | `follow-path` | `position.x`, `position.y`; and `transform.rotation` when `align` is `true` | | `oscillate`, `pulse`, `twinkle` | The single channel named by `property` | | `field-follow` in `direct` mode | `position.x`, `position.y`, `z` | | `bounce`, `attract`, `repel`, `field-follow` in `force` or `velocity` mode | `velocity.x`, `velocity.y`, `velocity.z` | | `point-wander`, `morph` | `points[*].x`, `points[*].y`, `points[*].z` | `velocity.*` and `points[*].*` are **not** in the automatable registry of 19.1, so behaviors writing only those can never collide with a track and are never `ERR_AUTOMATION_CONFLICT`. A vector target is likewise outside the numeric-only registry: a track addresses `position.x`, not `position`, so the conflict test is always a scalar-to-scalar comparison and no vector-track counterexample arises. **The `property` channel set.** `oscillate`, `pulse`, and `twinkle` name one channel in `property`: ```text position.x position.y z transform.rotation transform.scale.x transform.scale.y style.opacity style.strokeWidth style.pointSize size.width size.height radius ``` `twinkle` defaults `property` to `style.opacity`. A `property` outside this set, or one the owning primitive does not have — `size.width` on an `ellipse`, `radius` on a `rectangle` — is `ERR_INVALID_BEHAVIOR_TARGET`. This set is **not** the section 8.1 target-capability table and does not extend it: a behavior is an internal, declared writer inside its own object, whereas 8.1 governs external bindings, `set`, and `override`. Section 19.1 decides, separately, which visual properties become externally addressable. **`point-wander` targets.** `point-wander` requires an owning object with an addressable point list: `spline`, `polyline`, `polygon`, or a `path` whose commands carry explicit endpoints. On any other primitive it is `ERR_INVALID_BEHAVIOR_TARGET`. Point indices are stable for the object's lifetime, exactly as 17.13 fixes, so index `i` always denotes the same point; an `indices` entry outside `0` to `count - 1` is `ERR_OUT_OF_BOUNDS`. **Morph compatibility.** Section 17.13 defers the morph rule to this section; it is: the source and target objects must have the same `type`, the same point count, and — for `spline` — the same `mode`. Any mismatch is `ERR_MORPH_INCOMPATIBLE` at semantic validation, not a silent resample, because resampling one shape onto another's point count would change the geometry the author wrote. `to` naming a missing sibling key is `ERR_INVALID_REFERENCE`. **Which geometry can morph.** Morphing interpolates a point list, so it is defined only on primitives that *have* one: | `type` | Morphs | Point representation | | --- | --- | --- | | `polyline`, `polygon` | Yes | `points` | | `spline` | Yes, when `mode` matches | `points` | | `path` | Yes, only when **every** command of both objects is `move`, `line`, or `close`, and the two command sequences are op-for-op identical | The command endpoints in order | | Every other type, including `point`, `line`, `rectangle`, `rounded-rectangle`, `ellipse`, `arc`, `ring`, `bezier`, `text`, `group`, and `component` | No | — | A `morph` whose source or target is outside the first three rows, or whose two `path` command sequences differ, is `ERR_MORPH_INCOMPATIBLE`. Interpolating a rectangle's `size` into an ellipse's `radius`, or a group's subtree into a text run, would be inventing a geometry correspondence the author never wrote, and a curved `path` command carries control points whose count is not the endpoint count — which is why curved paths are excluded rather than partially supported. **Target points are read live, not snapshotted.** Morph interpolates toward the target's points **as they are on the current tick**, so a morph onto a moving or `point-wander`ing target follows it; snapshotting at instantiation would silently freeze the target and make one of the two obvious readings wrong. Evaluation order makes that decidable: within a tick, objects advance in document key order (17.8), and a morph reads the target's post-behavior local points from the same tick — the target's own value if it has already advanced, and its previous-tick value if it has not, exactly as the document order says. A `morph` whose target itself carries a `morph` naming the source, directly or through a chain, is `ERR_CYCLIC_DEPENDENCY` at semantic validation, the same code section 8.1 uses for a reference cycle. Morphing does not suppress the target: it draws normally unless the author also hides it with `visible` or uses it as the group's `mask` (17.12). `duration` is a DurationSpec, `loop` is `once` (default), `repeat`, or `ping-pong`, and `curve` is the four-curve set of 18.2. **Behaviors and reproducibility.** `wander`, `twinkle`, `point-wander`, and `noise-displace` are the only behaviors that consume the procedural stream, and each consumes it exactly twice, at instantiation, to derive the noise offset pair 18.7 fixes. Their subsequent motion is a pure function of logical time and those offsets. No behavior samples per frame or per tick, so 9.3's guarantee holds: rendering consumes no procedural stream, and identical seeds produce identical motion. ### 18.7 Procedural fields (`visuals.fields.`) A **field** is a named vector function over scene space that other systems read. Fields draw nothing themselves; they are referenced by a system's `fields` array (18.2, 18.4, 18.5) or by a `field-follow` behavior (18.6). This is how one declared force acts on several unrelated systems at once, which is what PRD 83 asks for. `visuals.fields` is an object keyed by field ID, `0` to `8` entries; more is `ERR_VISUAL_LIMIT_EXCEEDED`. A system or behavior may reference at most `4` fields. A `fields` entry naming an undeclared field is `ERR_INVALID_REFERENCE`. ```json { "visuals": { "fields": { "current": { "type": "noise", "scale": 220, "speed": 0.08, "octaves": 3, "persistence": 0.5, "amplitude": 40, "mode": "curl" }, "sink": { "type": "attractor", "center": { "x": 800, "y": 450 }, "strength": 900, "falloff": "inverse-square", "minDistance": 30 } } } } ``` | `type` | Fields | Vector at a point | | --- | --- | --- | | `directional` | `direction`, `strength` | Constant, `strength` scene units per second squared along `direction` degrees. Gravity, wind, and current are all this one field. | | `radial` | `center`, `strength`, `falloff`, `minDistance`, `maxDistance` | Along the outward ray from `center`. Negative `strength` points inward. | | `vortex` | `center`, `strength`, `falloff`, `minDistance`, `maxDistance` | Perpendicular to the outward ray. For an outward unit ray `r`, the vector is `strength * (r.y, -r.x)` — counter-clockwise **as seen on the display** for positive `strength`, in the y-down space of 17.2. A force `strength` is not an angle and shares no sign convention with the clockwise-positive `rotation` of 17.2; the formula, not the adjective, is normative. | | `attractor` | `center`, `strength`, `falloff`, `minDistance`, `maxDistance` | Toward `center`. | | `repulsor` | `center`, `strength`, `falloff`, `minDistance`, `maxDistance` | Away from `center`. | | `noise` | `scale`, `speed`, `octaves`, `persistence`, `amplitude`, `mode`, `direction`, `center`, `size` | Coherent noise; see below. | `falloff`, `minDistance`, and `maxDistance` carry the meanings fixed in 18.6. Every field also accepts an optional `bounds` (`{x, y, width, height}`) outside which it contributes nothing, and an optional `enabled` ValueSpec\ defaulting to `true`. A `type` outside the six is `ERR_INVALID_FIELD_TYPE`. **Coherent noise is normative, and it is written out.** PRD 83 requires seeded coherent noise with configurable `scale`, `speed`, `octaves`, and `persistence`. Reproducibility (9.3) promises identical procedural decisions, not identical pixels — but a field that drove motion differently on two conforming renderers would make a fixture untestable, so the noise function is fixed here as an algorithm, not as a description of one. `octaves` is an integer `1` to `4`; `persistence` is `0` to `1` (default `0.5`); `scale` must be above `0` or `ERR_OUT_OF_BOUNDS`; `speed` defaults to `0`, which freezes the field; `amplitude` defaults to `1`. **Gradients.** The twelve edge-midpoint vectors of a cube, in exactly this order, used **unnormalized**: ```text G[ 0..3 ] = ( 1, 1, 0) (-1, 1, 0) ( 1,-1, 0) (-1,-1, 0) G[ 4..7 ] = ( 1, 0, 1) (-1, 0, 1) ( 1, 0,-1) (-1, 0,-1) G[ 8..11] = ( 0, 1, 1) ( 0,-1, 1) ( 0, 1,-1) ( 0,-1,-1) ``` **Permutation table.** At the field's instantiation boundary, from the field's own stream (9.3, `visual` domain, child key ``): ```text p[i] = i for i in 0..255 for i from 255 down to 1: j = floor(rng.nextFloat() * (i + 1)) # one sample per swap swap p[i], p[j] p[256 + i] = p[i] for i in 0..255 # duplicated tail, no wrap arithmetic ``` The draw count is exactly **255** samples — one per swap, not one per entry — and `nextFloat()` returns `[0, 1)`, so `floor` yields `0..i` without a boundary case. This is the integer-from-uniform construction 9.3 already requires; no other integer sampling policy is introduced. **Single-octave value.** For a sample point `(x, y, z)` in noise space: ```text X = floor(x) & 255 ; fx = x - floor(x) ; u = fade(fx) # and likewise Y/fy/v, Z/fz/w fade(a) = 6a^5 - 15a^4 + 10a^3 A = p[X] + Y ; AA = p[A] + Z ; AB = p[A+1] + Z B = p[X + 1] + Y ; BA = p[B] + Z ; BB = p[B+1] + Z grad(h, dx, dy, dz) = dot( G[h mod 12], (dx, dy, dz) ) lerp(s, a, b) = a + s * (b - a) n = lerp(w, lerp(v, lerp(u, grad(p[AA ], fx, fy, fz ), grad(p[BA ], fx-1, fy, fz )), lerp(u, grad(p[AB ], fx, fy-1, fz ), grad(p[BB ], fx-1, fy-1, fz ))), lerp(v, lerp(u, grad(p[AA+1], fx, fy, fz-1), grad(p[BA+1], fx-1, fy, fz-1)), lerp(u, grad(p[AB+1], fx, fy-1, fz-1), grad(p[BB+1], fx-1, fy-1, fz-1)))) ``` `h mod 12` maps `256` table values onto `12` gradients with a small, fixed bias. Bias is not nonconformance: the mapping is deterministic and identical on every renderer, which is the only property a fixture can test. The result is clamped to `[-1, 1]` as a guard; with these gradients the unclamped magnitude does not exceed `1`, and the clamp is never a shaping step. **Octaves.** With `s1(x, y, z)` the single-octave value above: ```text total = 0 ; amp = 1 ; freq = 1 ; norm = 0 repeat octaves times: total += amp * s1(x * freq, y * freq, z * freq) norm += amp amp *= persistence freq *= 2 s = total / norm # in [-1, 1] ``` Dividing by the accumulated amplitude, rather than by a closed form, keeps `persistence: 0` well defined (`norm = 1`, one octave contributing). **Sample coordinates.** A field sampled at scene point `(px, py)` at logical time `t` (9.1) evaluates `s` at ```text ( px / scale , py / scale , t * speed ) ``` **Scalar to vector.** Let `h = 1e-3` be the central-difference step, taken in noise-space coordinates: ```text ds/dx = ( s(x + h, y, z) - s(x - h, y, z) ) / (2h) # and likewise ds/dy gradient : amplitude * ( ds/dx , ds/dy ) curl : amplitude * ( ds/dy , -ds/dx ) value : amplitude * s * ( cos(direction) , sin(direction) ) ``` `curl` is the explicit perpendicular of the gradient of the scalar potential `s`, and it is divergence-free by construction — not "the curl of the noise potential" left to the implementer to build. Both derivative modes cost four extra scalar evaluations per sample. Tolerances for the traces of 18.10: a scalar sample matches its published oracle to `1e-9`, and the numerically estimated divergence of a `curl` field is within `1e-4 * amplitude / scale` of zero at both lattice and non-lattice positions. **Behaviors that use coherent noise** — `wander`, `twinkle`, `point-wander`, and `noise-displace` (18.6), whose rows say "value-noise" and mean this function — share **one permutation table per exhibit**, derived at activation from the `visual` domain with child key `behavior-noise`. Per-object independence comes from offsets, not from per-object tables: deriving 255 samples for every particle would make a large system's instantiation cost quadratic in nothing an author asked for. Each such behavior draws an **offset pair** `(o1, o2)`, two samples uniform in `[0, 1024)`, once at its owning object's instantiation boundary, and samples as: | Behavior | Noise-space coordinates | Use | | --- | --- | --- | | `wander` | `(o1, o2, t * rate)`, one octave | Direction `theta = 360 * s`; acceleration `strength * (cos theta, sin theta)`, with accumulated wander velocity clamped to `maxSpeed` | | `twinkle` | `(o1, o2, t * rate)`, one octave | The scalar `s`, mapped as 18.6 fixes | | `point-wander` | Point `i` uses `(o1 + 64 * i, o2, t * rate)` and `(o1, o2 + 64 * i, t * rate)` | The two scalars scale `amplitude.x` and `amplitude.y`; a third at `(o1 + 64 * i, o2 + 64 * i, t * rate)` scales `amplitude.z` | | `noise-displace` | `(px / scale, py / scale, t * speed)`, and the same point offset by `(137, 71, 0)` and by `(271, 193, 0)`, with the behavior's own `octaves` and `persistence` | The three scalars scale `amplitude.x`, `amplitude.y`, and `amplitude.z` | `mode` selects how the scalar noise becomes a vector: `curl` (default) takes the curl of the noise potential, giving divergence-free flow that never piles items into a point; `gradient` takes its gradient; `value` returns the scalar along `direction` degrees. The exact vector construction for each mode is fixed below. `direction` is a number in degrees (17.2) with no default: it is **required** when `mode` is `value` and is `ERR_UNKNOWN_FIELD` under `curl` and `gradient`, which derive their direction from the noise itself. `center` and `size` optionally restrict the sampled region; outside it the field contributes nothing. **Fields do not read each other.** A field's vector depends only on position and logical time. Fields never reference other fields, systems, or objects, so there is no evaluation order to fix and no cycle to detect. Fields are summed into a system's acceleration in the order of that system's `fields` array; addition is commutative, so the order is fixed for traceability rather than for correctness. **Cost.** A field costs one evaluation per affected item per logical tick, not per frame. The aggregate ceiling on field evaluations, like every other runtime ceiling, is 19.5. ### 18.8 Trails, ribbons, and links **Trails and ribbons.** A `trail` block records an item's recent positions and draws them (PRD 84). It is declared in exactly one place: the `trail` field of a `particles` system (18.2) or of an `emitter` (18.4). It applies per item. A `trail` key on a particle's `render` object, on an emitter's `emit` object, or on any visual object is `ERR_UNKNOWN_FIELD` — a trail is a property of the *system* that owns the item's history, not of the geometry drawn at the head of it, and admitting a second placement would require a precedence rule between the two for no expressive gain. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `length` | number | No | `16` | Integer, `2` to `128`. History samples retained per item. | | `interval` | DurationSpec | No | one logical tick | Logical interval between history samples. | | `mode` | enum | No | `line` | `line`, `ribbon`, or `points`. | | `width` | ValueSpec\ | No | inherits `style.strokeWidth` | Scene units at the head of the trail. | | `taper` | ValueSpec\ | No | `1` | `0` to `1`. Fraction of `width` remaining at the tail. | | `fade` | ValueSpec\ | No | `1` | `0` to `1`. Opacity multiplier at the tail; the head keeps the item's own opacity. | | `style` | object | No | the item's own style | Appearance (17.12) for the trail geometry. | | `curve` | enum | No | `linear` | `linear` or `catmull-rom` smoothing through the history samples. | `line` draws the history as a `polyline` of `length` samples. `ribbon` draws it as a continuous strip whose half-width at sample `i` interpolates from `width` at the head to `width * taper` at the tail, oriented perpendicular to the local direction of travel — this is the ribbon system PRD 84 asks for, expressed as a trail render mode rather than as a separate system type, because a ribbon is the same history buffer drawn with area instead of a stroke. `points` draws one `point` per sample at `style.pointSize`. History is sampled on the logical clock, never on the frame, so a trail has the same shape at any render rate. A newly created item draws no trail until it has two samples. An item removed by capacity, `lifetime`, or system disposal discards its history immediately; trails do not outlive their owner. `length` above `128` is `ERR_VISUAL_LIMIT_EXCEEDED`, and the aggregate history ceiling across all systems is 19.5. **Links.** A `links` block draws connecting geometry between the items of one system according to a generic rule (PRD 84). It is legal on a `particles` system (18.2) and on a `repeater` (18.5); on an `emitter` it is `ERR_UNKNOWN_FIELD`, because an emitter's population changes continuously and its link set would have to be rebuilt every tick at the cost the ceiling below exists to prevent. | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `rule` | enum | No | `distance` | `distance`, `nearest`, or `index`. | | `maxDistance` | ValueSpec\ | Conditional | — | Required for `distance`, and for any rule when `fadeWithDistance` is `true`. Optional for `nearest`. Scene units, above `0`. | | `count` | number | Conditional | — | Required for `nearest`. Integer, `1` to `8`. Links per item. | | `stride` | number | No | `1` | `index` only. Links item `i` to item `i + stride`. | | `closed` | boolean | No | `false` | `index` only. Links the last item back to the first. | | `maxLinks` | number | No | `256` | Integer, `1` to `1024`. Links drawn per system per tick. | | `style` | object | No | see below | Appearance (17.12) for the link geometry. | | `fadeWithDistance` | boolean | No | `false` | When `true`, a link's opacity scales linearly from full at distance `0` to zero at `maxDistance`, which is then required. | `distance` links every pair closer than `maxDistance`; it is the rule 0.1 requires at minimum, and it is what PRD 130 item 12's constellation is built from. `nearest` links each item to its `count` nearest neighbours, ties broken by **ascending creation ordinal**, which is decidable and stable where "nearest" alone is not. `index` links items by position in the system's live ordering and needs no distance search at all. **Where the default link style comes from.** No visual system has a general `style` field, so "the system's style" names nothing. Absent, link geometry uses the **resolved `style` of the system's own item template** — a `particles` system's `render` object, or a `repeater`'s `repeat` object — including any style that object inherits from an enclosing group. A link is drawn between two items, so borrowing the appearance those items already carry is the answer that needs no new field; an author who wants links to differ declares `links.style`, which replaces it per field exactly as style inheritance does elsewhere (17.12). **Distance fade needs a distance.** `fadeWithDistance: true` with no `maxDistance` has no normalization and is `ERR_SCHEMA_VALIDATION`; a `maxDistance` at or below `0` is `ERR_OUT_OF_BOUNDS`. There is no alternative normalization — falling back to the largest live pair distance would make a link's opacity depend on an unrelated item across the scene and change every frame. **Indexing over a population with gaps.** Items die and are evicted, so creation ordinals are not contiguous. The `index` rule, `stride`, `closed`, and the tie-break above all operate on the system's **live ordering**: its live items sorted by ascending creation ordinal and re-indexed densely from `0` on each tick. A death therefore closes the gap rather than breaking the chain into pieces, which is what an author asking for a chain means, and the re-indexing is deterministic because the ordinals are. **Determinism and cost.** Link pairs are enumerated in ascending `(lower index, higher index)` order and de-duplicated, so a pair is drawn once regardless of rule. Links draw before their system's items, at the system's own depth position (18.2). When the enumerated pair count exceeds `maxLinks`, the excess is dropped in that same ascending order — deterministically, not arbitrarily — and the aggregate link ceiling and its diagnostic are 19.5. **The `256` pairwise-population bound, and where it is checked.** A system carrying `links` with rule `distance` or `nearest` may not have a population above `256`, because a pairwise search over more items than that cannot be made to fit the per-tick budget PRD 120 sets. The bound is **deliberately conservative**: it is checked against the population's *upper* bound, not against the live count, so a `particles` system with `capacity: 400` is rejected even if it never holds more than ten particles at once. That is the intended behavior — a ceiling that only failed once the scene was already too heavy would be a ceiling that fires in front of the viewer — and it is stated here rather than left to look like an over-eager check. It is enforced at two stages, and at both it is `ERR_VISUAL_LIMIT_EXCEEDED`: * **Import**, where the bound is a literal: `particles.capacity`, and a `repeater.count` that is a literal number. * **Instantiation**, where it is not: a `repeater.count` authored as a ValueSpec is checked the moment it resolves, at the repeater's instantiation boundary (17.14), before any copy is created. A spawned system's is checked per spawn. `index` has no such limit; it is linear in the item count. ### 18.9 New diagnostic codes Added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_INVALID_DISTRIBUTION_TYPE` | Semantic | A placement distribution `type` is not one of the nine of 18.3. | | `ERR_INVALID_DISTRIBUTION` | Semantic | A distribution is structurally valid but illegal in its context — an index-driven placement on a continuous-rate emission. | | `ERR_INVALID_BEHAVIOR_TYPE` | Semantic | A behavior `type` is not a member of the Visual Behavior Set 0.1 of 18.6. | | `ERR_INVALID_BEHAVIOR_TARGET` | Semantic | A behavior addresses a channel outside its permitted set, or one the owning primitive does not have. | | `ERR_INVALID_FIELD_TYPE` | Semantic | A `visuals.fields.` `type` is not one of the six of 18.7. | | `ERR_MORPH_INCOMPATIBLE` | Semantic | A `morph` behavior's source and target differ in `type`, point count, or spline `mode`. | | `ERR_UNBOUNDED_EMISSION` | Semantic | A particle system or emitter can create items without bound: it declares emission but neither `lifetime` nor `limit`. | `ERR_COMPONENT_RECURSION`, `ERR_VISUAL_LIMIT_EXCEEDED`, `ERR_INVALID_SYSTEM_TYPE`, `ERR_INVALID_PRIMITIVE_TYPE`, `ERR_INVALID_REFERENCE`, `ERR_INVALID_RANGE_ORDER`, `ERR_OUT_OF_BOUNDS`, `ERR_TYPE_MISMATCH`, `ERR_UNKNOWN_FIELD`, `ERR_SCHEMA_VALIDATION`, and `WARN_AUTOMATION_FALLBACK` are reused rather than duplicated under visual-specific names, on the same principle 17.15 states. ### 18.10 Required traces before slice 4e implementation is accepted Automated, and executable without a display measurement: 1. A visual component instantiates with defaults, with supplied `inputs`, and with a `component` object nested inside another component; `inputs.` resolves inside the component and is `ERR_INVALID_REFERENCE` outside it; an undeclared parameter key in `inputs` is `ERR_INVALID_REFERENCE`; a `default` of the wrong type is `ERR_TYPE_MISMATCH`. 2. Component nesting `9` levels deep and a component instantiating itself are each `ERR_COMPONENT_RECURSION`, and `8` levels pass; a reference from outside a component to an object key inside it is `ERR_INVALID_REFERENCE`. 3. Two instances of one component sample independently from their expansion paths, and reordering unrelated siblings leaves both instances' resolved values unchanged. 4. Particle fields resolve once per particle: a `random` size holds its value for that particle's whole life across many frames, two runs of one seed produce identical particles in identical creation order, and rendering `600` frames consumes no procedural stream. 5. The integrator of 18.2 is verified against a closed-form case: constant acceleration with zero drag reaches the documented position after `n` ticks, and halving the tick length leaves the trajectory within the documented tolerance, while integrating position before velocity does not. 6. `drag` is `dt`-correct: one tick of `1s` and ten ticks of `0.1s` leave the same velocity to within floating-point tolerance. 7. A life ramp interpolates `from` to `to` under each of the four curves at age `0`, midpoint, and `1`; a ramp without `lifetime` is `ERR_SCHEMA_VALIDATION`; an `exponential` ramp through zero falls back to linear and raises `WARN_AUTOMATION_FALLBACK`. 8. `rate` emission is exact: at a constant rate the cumulative count after `t` logical seconds is `floor(rate * t)` under three different tick lengths, and a burst emits its whole `count` on the first tick at or after its `at`. 9. Emission with neither `lifetime` nor `limit` is `ERR_UNBOUNDED_EMISSION`; `count` alone without `lifetime` passes; exceeding `capacity` evicts the oldest item and leaves the live count at `capacity`. 10. Each of the nine distributions places a known item set at the documented positions from a fixed seed, and the final PRNG position after the placement matches the draw-count table of 18.3 exactly; every `even` mode and a `grid` with absent or zero `jitter` consume no procedural samples, while a `grid` with non-zero `jitter` consumes two per item and an added `depth` sub-block consumes one more, drawn last; a `depth` sub-block's three curves each place a known item at the documented `z`; an index-driven placement on a continuous `rate` emission is `ERR_INVALID_DISTRIBUTION`; a `path` distribution naming a sibling key rather than carrying inline `commands` is `ERR_INVALID_REFERENCE`; an unknown distribution `type` is `ERR_INVALID_DISTRIBUTION_TYPE`. 11. A repeater resolves `repeat.index`, `repeat.count`, and `repeat.fraction` per copy, samples a `choose` once per copy in ascending index, and is `ERR_INVALID_REFERENCE` when `repeat.*` is used outside a repeater or when an external path names a copy. 12. Each of the seventeen behaviors advances a known object to a documented state after a fixed number of logical ticks; an unknown `type` is `ERR_INVALID_BEHAVIOR_TYPE`; a `property` outside the channel set, and `size.width` on an `ellipse`, are each `ERR_INVALID_BEHAVIOR_TARGET`; `point-wander` on a `rectangle` is `ERR_INVALID_BEHAVIOR_TARGET`; `9` behaviors on one object is `ERR_VISUAL_LIMIT_EXCEEDED`. 13. Behavior composition follows array order: two `drift` behaviors accumulate on `position`, two `oscillate` behaviors on `transform.scale.x` multiply, and swapping the array order changes the result only where the rule says it should. 14. `morph` between two `spline` objects of equal `mode` and point count interpolates each point; differing point counts, differing `type`, and differing spline `mode` are each `ERR_MORPH_INCOMPATIBLE`, as are a `morph` onto a `rectangle` and one between two `path` objects whose command sequences differ; a `morph` onto a target that is itself moving tracks the target's current points on each tick rather than a snapshot, and a mutual `morph` pair is `ERR_CYCLIC_DEPENDENCY`; `to` naming a missing key is `ERR_INVALID_REFERENCE`. 15. The noise function of 18.7 reproduces documented sample values to `1e-9` at fixed **lattice and non-lattice** coordinates from a fixed seed, and the permutation shuffle consumes exactly `255` samples, leaving the documented stream position; two runs of one seed agree exactly; a `value`-mode field without `direction`, and a `direction` under `curl` or `gradient`, are each rejected with the documented code, as are `octaves` of `5`, `persistence` above `1`, and a `scale` of `0`; an unknown field `type` is `ERR_INVALID_FIELD_TYPE`; `9` declared fields and `5` referenced fields are each `ERR_VISUAL_LIMIT_EXCEEDED`. 16. `curl` mode is divergence-free to within `1e-4 * amplitude / scale` over a sampled grid taken at non-lattice as well as lattice positions, so items following it do not accumulate at a point; `gradient` and `value` produce their documented vectors at the same points; and the four noise-using behaviors of 18.6 each draw exactly two samples at instantiation and none thereafter. 17. Trail history is sampled on the logical clock: the same trail geometry results at three different frame rates over the same logical span; a trail of `129` samples is `ERR_VISUAL_LIMIT_EXCEEDED`; a removed item's history is discarded in the same tick. 18. Link enumeration is deterministic and de-duplicated in ascending pair order under each of the three rules; `distance` links exactly the pairs within `maxDistance`; `nearest` breaks equidistant ties by ascending creation ordinal; the `index` rule re-indexes the live ordering densely, so a death in the middle of a chain closes the gap rather than splitting it; exceeding `maxLinks` drops the tail of that order rather than an arbitrary subset; `fadeWithDistance: true` without `maxDistance` is `ERR_SCHEMA_VALIDATION`; `links` on an `emitter` is `ERR_UNKNOWN_FIELD`; a `distance` rule over a literal population above `256` is `ERR_VISUAL_LIMIT_EXCEEDED` at import and over a procedurally resolved `repeater.count` above `256` at that repeater's instantiation boundary. 19. A binding, `set`, or `override` addressing any property introduced in this section — a behavior field, a field strength, an emitter `rate` — is `ERR_UNSUPPORTED_TARGET`, and the section 8.1 table remains unchanged by this slice. User-observed, and **not** satisfiable by the above: 20. The fourteen PRD 130 challenge items are reachable by composing this vocabulary with sections 17 and 19, judged visually on a real display, with no subject-specific renderer code. This is trace 15 of 17.16 and is not a second gate. 21. The early combined GC6 benchmark of slice 4h, which is what fixes whether the ceilings named throughout this section are the right ones. Traces 1-19 belong to slice 4e, where the procedural systems exist to run them. Traces 20 and 21 close slice 4h. Phase 4 is not accepted until both do, no matter how many automated traces pass. --- ## 19. Visual Subsystem Contract — Automation, Lifecycle, Camera, Effects, and Ceilings (Phase 4c) This section closes the Visual contract sections 17 and 18 opened. It covers visual automation and its loop modes (PRD 85), the visual lifecycle and ownership of persistent and spawned systems (PRD 86), the camera and its projection modes (PRD 87), the post-effect chain (PRD 88), and visual safety limits together with the centralized runtime ceilings of the whole engine (PRD 89, 119-120). Sections 17 and 18 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time and what it refuses to do, which is the same division sections 14-15 and 16 use for audio. Every forward reference to 19.1, 19.2, and 19.5 left open by sections 17 and 18 resolves here; after this section the visual contract contains no unresolved forward reference. **Implementation status.** Slices 4d–4g implement sections 17–19 in the production visual runtime, with automated contract/execution tests and authored challenge fixtures. Display judgment and slice 4h measurement remain pending. The ceilings of 19.5 remain provisional: their shape is normative, and their values require GC6 measurement. ### 19.1 Visual automation A visual automation track drives one numeric visual property along an authored curve on the logical clock (PRD 85). Tracks reuse the audio automation shape of 16.1 field for field, so an author who has written one has written the other; this subsection states only what differs. ```json { "visuals": { "camera": { "x": 800, "y": 450, "zoom": 1 }, "automation": [ { "target": "camera.zoom", "mode": "absolute", "interpolation": "smooth", "loop": { "mode": "ping-pong", "count": "infinite" }, "points": [ { "at": "0ms", "value": 1 }, { "at": "24s", "value": 1.35 } ] } ] } } ``` | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `target` | string | Yes | — | A property path in the automatable registry below, relative to the array's scope. | | `mode` | enum | No | `absolute` | `absolute`, `offset`, or `scale`, with the contributions 16.1 fixes. | | `interpolation` | enum | No | `linear` | `step`, `linear`, `exponential`, or `smooth`, with the curves 16.1 fixes, including the `exponential` fallback to linear and its `WARN_AUTOMATION_FALLBACK`. | | `loop` | object | No | — | Absent means the track holds its last point's value forever, exactly as an audio track does. | | `points` | array | Yes | — | `2` to `256` breakpoints of `{ "at": , "value": ValueSpec }`, strictly increasing in `at`. | **Two declaration scopes, each with its own time origin.** An `automation` array may appear in exactly two places: | Scope | Time origin for `at` | Targets | | --- | --- | --- | | `visuals.automation` | Exhibit activation | `scene.*`, `layers..*`, `camera.*`, and `effects[].*` properties in the registry below. | | `visuals.systems..automation` | That system's instantiation boundary (17.14) — activation for a persistent system, the spawn moment for a spawned one | Properties of that system, addressed relative to the system. | The second scope is what makes automation work on a spawned system: a track written against spawn-relative time behaves identically however late the spawn happens, which is the same property 16.1 gives an audio track measured from its sound instance's start. A track in one scope naming a target in the other is `ERR_INVALID_REFERENCE`; automation never reaches across scope, and no track may address another system. `at` is a duration literal only, never a procedural `TimeSpec`, for the reason 16.1 gives: the strictly-increasing ordering rule must stay decidable at import. Point *values* remain full ValueSpecs and resolve once, at the owning scope's instantiation boundary, sampled in depth-first document order after that scope's other fields. **Loop modes** (PRD 85). `loop` is `{ "mode": "repeat" | "ping-pong", "count": = 1> | "infinite" }`, `count` defaulting to `infinite`. The loop period is the `at` of the last point. `repeat` restarts from the first point, so a track whose first and last values differ steps discontinuously at the wrap; that is the author's choice, not a defect. `ping-pong` plays the curve forward then backward, and one `count` unit is one *complete* forward-and-back cycle, so that a finite count always ends where it began. Before the first point the track holds the first point's value under either mode. After a finite `count` is exhausted the track holds the value it ended on and stops advancing. **`infinite` is legal in every scope, and one rule covers all four cases.** A loop never outlives the scope that declares it, so an unbounded loop is bounded by the lifetime of its owner and needs no scope-dependent prohibition: | Declaring scope | What bounds an `infinite` loop | | --- | --- | | `visuals.automation` | Exhibit deactivation | | A `persistent` system | Exhibit deactivation, with the system | | A `spawned` system with `spawn.lifetime` | Expiry of that lifetime, then the release ramp (19.2) | | A `spawned` system with no `spawn.lifetime` | An explicit `remove`, owner termination, or deactivation, whichever comes first (19.2) | No combination of scope and lifetime is rejected, and no diagnostic is raised for any of them. The fourth row is the case earlier text left contradictory — declaring `infinite` legal "only on a persistent scope" while also declaring it legal on a finite-lifetime spawned system — and it resolves the same way as the other three: an indefinite spawned instance is still released by a `remove` or by its owner, and its tracks are disposed with it. This is what PRD 85 means by an infinite loop count for persistent visual behavior. **The automatable registry.** A track may address these properties and no others. A `target` outside this registry is `ERR_UNSUPPORTED_TARGET`; a `target` naming an undeclared layer, system, object, or effect index is `ERR_INVALID_REFERENCE`. | Scope | Automatable properties | | --- | --- | | `scene` | `depthFog.near`, `depthFog.far`, `depthFog.density` | | `layers.` | `opacity`, `parallax` | | `camera` | `x`, `y`, `zoom`, `rotation`, `focalLength` | | `effects[]` | Every numeric parameter of the effect's own table in 19.4 | | A system | `visible` is **not** automatable (it is boolean); for a `graphic` system, any numeric `transform`, `style`, or geometry property of an object in its `content` tree, addressed by container keys; for `particles` and `emitter`, `rate`, `position.x`, `position.y`, `acceleration.x`, `acceleration.y`, `acceleration.z`, and `drag`; for `repeater`, **nothing** | **A `repeater` has no automatable property.** Earlier drafts of this registry named the numeric fields of a repeater `step` block; no such block exists in 18.5 and none is added here. A repeater resolves its `count`, its distribution, and every copy's fields once at its instantiation boundary and never creates another copy (18.5), so there is no field whose later change could reach the copies. Per-copy motion is authored with behaviors on the repeated object, which is the mechanism 18.6 provides for exactly this. A `target` naming `step`, or any other property of a repeater, is `ERR_UNSUPPORTED_TARGET` — the code for a property that exists but exposes no automation stage — and is distinct from `ERR_INVALID_REFERENCE`, which a `target` naming an undeclared *system* raises. Non-numeric properties are outside the registry by construction: PRD 85 says automation targets numeric properties, and a curve between two colors or two booleans is a different mechanism (a life ramp, 18.2) with a different contract. **Exclusivity, and composition against behaviors.** Only one track may directly control one property of one instance; two tracks on one expanded target are `ERR_AUTOMATION_CONFLICT`, the same rule and the same code as 16.1. A behavior (18.6) that writes a channel an automation track also targets on the same object is `ERR_AUTOMATION_CONFLICT` as well: a behavior accumulates or multiplies onto a channel while an automation `mode` displaces the base, and two writers with different composition rules on one scalar have no defined answer. This is the resolution 18.6 defers here. Where they do *not* collide, the order is fixed: the shared pipeline of 8.1 resolves the property, and the object's behaviors then compose over that resolved value in array order, per 18.6. Behaviors are the object's own internal motion; the pipeline's output is the base they displace. **The visual rows of the section 8.1 target-capability table.** This slice adds exactly four target families to the shared registry. They are all *system-level*: camera, layer, system visibility, and effect parameters. | Target family | Type and base | Binding target | Automation | Override | Additive modulation | Safety clamp | | --- | --- | :---: | :---: | :---: | :---: | --- | | `visuals.camera.` | Number; camera `x`, `y`, `zoom`, `rotation`, `focalLength` (19.3) | Yes | Yes | Yes | Yes | The per-field range of 19.3 | | `visuals.layers..opacity` | Number; layer `opacity` (17.5) | Yes | Yes | Yes | No | `[0, 1]` | | `visuals.systems..visible` | Boolean; system `visible` (17.7) | Yes | No | Yes | No | — | | `visuals.effects[].` | Number; the effect's own parameter (19.4) | Yes | Yes | Yes | No | The parameter's range in 19.4 | Nothing else. Per-object geometry, transform, and style properties are **not** externally addressable in 0.1; nor are procedural-system fields, behavior fields, field strengths, emitter rates, or particle parameters. A `BindingSpec`, `set` action, or `override` action addressing one of them remains `ERR_UNSUPPORTED_TARGET`, exactly as 17.14 and trace 19 of 18.10 state, and those two statements survive this slice unchanged. The reason is the one 16.2 gives for audio. Automation is declared *inside* the subsystem that owns the property, so adding it never widens the subsystem's external surface; binding, `set`, and `override` come from outside, and every target they can reach is plumbing that slices 4d through 4f must carry, that the override stack must mask and release correctly, and that a future revision cannot withdraw. A camera, a layer's opacity, a system's visibility, and an effect's amount are the four handles an exhibit needs to make its visuals respond to state — Exhibit D's instrument displays are built from state-driven *geometry inside a system*, which automation and behaviors reach without any external capability. The narrower surface is therefore not a limitation on what 0.1 can express; it is the smallest surface that expresses it, and a later revision can widen it compatibly, while nothing can narrow it. Boolean targets take no automation and no modulation stage: those stages are absent, not identity hooks (8.1). `visuals.layers..visible` is deliberately **not** in the table; a layer is hidden by animating its `opacity` to `0`, which is continuous and needs no second mechanism. **Limits, and the two different things they count.** The "animation records" of PRD 89 are `128` tracks and `2048` points, and that budget is checked twice against two different populations. Conflating them is what makes a legal exhibit unadmittable: a template with four tracks is a legal *document* however many times it is spawned, and the thing that must be bounded is the number of tracks *live at once*. | Check | Population counted | On breach | | --- | --- | --- | | **Authoring bound**, at import | The authored records: `visuals.automation`, plus each declared system's `automation` counted **once** per declaration, whatever its lifecycle | `ERR_VISUAL_LIMIT_EXCEEDED`; the exhibit is rejected | | **Live budget**, at every spawn | The records actually instantiated: the exhibit-scope tracks, every persistent system's tracks, and the tracks of every spawned instance from `CREATED` until `DISPOSED` | The spawn is refused atomically; `WARN_VISUAL_CEILING` under the cadence of 19.5; not a scenario failure | **Atomic refusal.** A `spawn` whose instance would push either live total past its budget creates nothing: no track is allocated, no partial instance exists, no state is entered, and the template's spawn ordinal is **not** consumed, so the next successful spawn of that template takes the ordinal the refused one would have and reproducibility across runs is unaffected. Admitting the system with some of its tracks silently dropped is specifically forbidden — a partially automated instance is a different exhibit, not a degraded one — and this is the same refuse-never-evict policy 19.2 applies to the instance ceiling itself, for the same reason. **Reclamation.** An instance's records are released when it reaches `DISPOSED`, not when it reaches `FINISHED`, so a releasing instance still counts; a `FAILED` instance's records are reclaimed with it. Both totals are therefore exactly the sum over live instances at every moment, and a refused spawn leaves them unchanged. These bounds are restated in the centralized table of 19.5. ### 19.2 Visual lifecycle and ownership A visual system is either **persistent** or **spawned** (PRD 86). The discriminator is explicit: `lifecycle` is a top-level field of the system (17.7); every other lifecycle field is a field of that system's `spawn` object: | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `lifecycle` | enum | No | `persistent` | `persistent` or `spawned`. A restatement of the 17.7 system field table, which owns the row; the two must not drift. | | `spawn.lifetime` | DurationSpec | No | — | Logical duration from instantiation to the start of release. Absent means the instance runs until removed or its owner terminates. Distinct from a `particles` or `emitter` top-level `lifetime`, which is per item (17.7, 18.2, 18.4). | | `spawn.release` | DurationSpec | No | `0ms` | `0ms` to `10s`. | | `spawn.ownership` | enum | No | inherited | `persistent` transfers the instance's resources to the performance root (10.1). Any other value is `ERR_SCHEMA_VALIDATION`. | | `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 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. **A spawned system is a template, not a drawn system.** A `visuals.systems.` with `lifecycle: "spawned"` is declared, validated, and counted against the authoring limits at import, and is **not** instantiated or drawn at activation. It is instantiated only by a `spawn` action naming it, once per action, with the supplied values bound to the parameters its `spawn.inputs` declares. Its instantiation boundary (17.14) is the spawn moment; every ValueSpec in it, and every point value of its own `automation` array, resolves there. Two spawns of one template therefore sample independently and reproducibly, from the stream child key `#` (9.3), with the ordinal monotonic per template from `0`. The `spawn` and `remove` **action shapes** belong to the Action Model and are not fixed here, exactly as section 16 fixes the sound-instance lifecycle without redefining the `sound` action. This subsection fixes the *resource* contract that those actions operate on. A spawned instance is addressed through the `instances.*` runtime **action-addressing** namespace, which section 8.1 defines and explicitly excludes from ValueSpec, binding, and automation use. Section 1.3 reserves document namespaces; `instances.*` is not one of them, and citing it there would grant by implication a reach that 8.1 denies outright. **States.** A visual system instance occupies exactly one state: | State | Meaning | Drawn | | --- | --- | :---: | | `CREATED` | Template expanded, values resolved, objects constructed, not yet in a layer. | No | | `ACTIVE` | In its layer and advancing on the logical clock. | Yes | | `RELEASING` | Release ramp running; no new items are created; existing items still advance. | Yes, fading | | `FINISHED` | Release complete; contributes nothing to the frame; resources not yet reclaimed. | No | | `DISPOSED` | Objects, particles, emitted items, trail history, automation tracks, behavior state, field references, and subscriptions released. Terminal. | No | | `FAILED` | Construction raised; resources reclaimed as for `DISPOSED`. Terminal. | No | ```text CREATED -> ACTIVE | FINISHED | FAILED ACTIVE -> RELEASING | FINISHED | FAILED RELEASING -> FINISHED | FAILED FINISHED -> DISPOSED ``` There is no `SCHEDULED` state. The audio machine has one because a voice is committed to the audio clock before it sounds (16.3); a visual system has no second clock to commit to, so a spawn either constructs and becomes `ACTIVE` on the next logical tick or it fails. State names are runtime bookkeeping and are not exposed to exhibits. **Release is a composited fade, and its default is immediate.** Entering `RELEASING` ramps the instance's composited opacity linearly from `1` to `0` over `spawn.release`, then enters `FINISHED`. The default is `0ms` — immediate removal — where the audio default is `50ms`, and the difference is deliberate: an audio release exists to prevent a click, which is a defect, while a visual pop is merely abrupt and is sometimes exactly what an author wants. An author who wants a fade declares one. A `spawn.release` of `0ms` still passes through `RELEASING` for one tick, so a single teardown path handles every removal, which is the property 16.4 is protecting. Release is unconditional and runs on `spawn.lifetime` expiry, on an explicit `remove`, on owner termination, on exhibit deactivation, and on failure. Per 10.2, cleanup uses the shorter of the authored release and `5s`, and the cleanup owner's five-second deadline still applies, after which remaining instances are force-disposed with `WARN_CLEANUP_FORCED`. **The release factor.** Every system instance carries a **release factor**: a number, initialized to `1` at construction, that multiplies into the effective alpha of every object the instance draws (17.12). It is not a property, is not addressable, and is not the authored `style.opacity` of anything. It holds at `1` for the instance's whole life until the instance enters `RELEASING`, where it ramps linearly to `0` over `spawn.release`, and it never rises again — an instance cannot leave `RELEASING`. A `spawn.release` of `0ms` sets it to `0` for the single `RELEASING` tick, so that tick draws nothing and the teardown path is still the one path. Because it is a separate multiplier, release never rewrites an authored value and never restores one: an object authored at `style.opacity: 0.4` fades from `0.4` to `0` and does not pop to `1` on the way. That reading — that release must reset opacity to full — only follows if release is implemented by writing the opacity property, which is exactly what this factor exists to avoid, and it is why 0.1 adds no public system-level opacity property to carry it. **Ownership** follows 10.1 without amendment. A spawned instance inherits the dispatch context of the action that created it — a scenario instance, or the performance root — unless it declares `spawn.ownership: "persistent"` and the action requests it. A scenario-owned instance is released during that scenario's cleanup; `spawn.cancelWithScenario: false` on a persistent-owned instance is what lets a visual outlive the scenario that started it, which is the case PRD 86 names. A persistent system declared in `visuals.systems` is owned by the performance root, is created at activation, and is released only at deactivation. **`spawn.ownership` and `spawn.cancelWithScenario` are two different relationships.** Transferring an instance to the performance root moves where its *resources* are reclaimed; it does not by itself say anything about the scenario that created it, which is why a flag would otherwise be redundant. This contract keeps both, explicitly: | Relationship | What it decides | Set by | | --- | --- | --- | | **Resource ownership** | Which owner's cleanup reclaims the instance, and whose deadline (10.2) applies | `spawn.ownership` | | **Originating-scenario relationship** | Whether the instance is released when the scenario that spawned it is cancelled or completes | `spawn.cancelWithScenario`, retained independently of ownership | An inherited-ownership instance is reclaimed by its scenario, so its originating relationship is necessarily live and `spawn.cancelWithScenario: false` on it is `ERR_UNSUPPORTED_TARGET` — the documented false case, unchanged. A `persistent`-owned instance keeps the originating relationship by default (`true`), so it is still released with its scenario while being reclaimed by the root; setting it `false` severs that one relationship and is what lets a visual outlive the scenario that started it. Neither field is addressable at runtime; both are authored on the template. **Edge cases of the state machine, stated in prose and not only in a trace.** * A `remove` on an instance already in `RELEASING`, `FINISHED`, `DISPOSED`, or `FAILED` is a **no-op**: it raises no diagnostic, does not restart the release ramp, and does not consume a dispatch unit beyond the action itself. Removal is idempotent because an owner's cleanup and an authored `remove` can reach the same instance in one tick. * `CREATED -> FINISHED` is the path of an instance removed, or whose owner terminated, before its first tick: it never entered a layer, so there is nothing to fade and `RELEASING` is skipped. * `CREATED -> FAILED` and `ACTIVE -> FAILED` are construction and runtime failure. A `FAILED` instance's resources are reclaimed exactly as for `DISPOSED`, immediately and in the same tick; from that moment it counts against no ceiling of 19.5 and holds no automation record (19.1). It is not a scenario failure by itself. * Nothing leaves a terminal state. `DISPOSED` and `FAILED` are terminal, and an instance in either is not re-spawnable — a new `spawn` creates a new instance with the next ordinal. **A persistent system's `visible` is not its lifecycle.** A hidden system still advances its behaviors, automation, particles, and emissions, and still counts against every ceiling in 19.5 (17.7). Hiding is a compositing decision; removal is a lifecycle decision. An exhibit that wants the work to stop must remove the system, not hide it. **Ceiling.** At most `64` spawned instances live at once across every template, counted from `CREATED` until `DISPOSED`. A spawn that would exceed it is refused — not evicted — and raises `WARN_VISUAL_CEILING` naming the template and the ceiling, under the single diagnostic cadence 19.5 fixes rather than a cadence of its own. A refused spawn consumes no spawn ordinal, exactly as a spawn refused by the automation-record budget does (19.1). Refusal rather than eviction is the opposite of the voice policy of 16.6, and for a reason: an evicted voice fades out in milliseconds and is forgiven, whereas a visual system evicted mid-scene disappears in front of the viewer. A refused spawn is not a scenario failure. ### 19.3 Camera and projection `visuals.camera` is optional; absent, every field takes its default and the camera is an identity view of the scene (PRD 87). | Field | Type | Required | Default | Range | Notes | | --- | --- | :---: | --- | --- | --- | | `x` | ValueSpec\ | No | Scene center | — | Camera center in scene units. | | `y` | ValueSpec\ | No | Scene center | — | Camera center in scene units. | | `zoom` | ValueSpec\ | No | `1` | `0.01` to `100` | Uniform scale about the projection center. | | `rotation` | ValueSpec\ | No | `0` | — | Degrees, positive toward `+y` (17.2). | | `projection` | enum | No | `orthographic` | — | `orthographic` or `perspective`. Any other token is `ERR_SCHEMA_VALIDATION`. | | `focalLength` | ValueSpec\ | No | `1000` | `1` to `100000` | Scene units. See the bound note below. | The scene center is `(width / 2, height / 2)` for `virtual`, `(0.5, 0.5)` for `normalized`, and the display center for `viewport` (17.4). A camera default that is not the scene center would make an exhibit that never mentions the camera look different from one that declares its defaults, which is the kind of surprise a contract exists to prevent. **`focalLength` has a real lower bound, and it is `1`.** An open range "above `0`" gives a clamp no smallest admissible value, so a pipeline result of `0` or `-3` has nothing to clamp *to* and every implementation would pick its own epsilon. The bound is therefore closed at `1` scene unit at both ends of the pipeline: a **literal** outside `1` to `100000` is `ERR_OUT_OF_BOUNDS` at import, and a value that **resolves** outside it — from a `random`, a binding, an automation track, an override, or modulation — is clamped into it as the safety-clamp stage of 8.1, with no diagnostic, which is the same staging rule 14.5 fixes for audio. Below `1` the perspective factor changes faster than any authored geometry can express, and at the ceiling the projection is indistinguishable from `orthographic`, so neither end costs an exhibit anything it could otherwise say. **Named spaces.** Three coordinate spaces appear below and are never mixed: | Space | Units | Origin | | --- | --- | --- | | **Local** | Scene units of the declared coordinate space (17.4) | The object's own origin (17.9) | | **Scene** | Scene units | The scene rectangle's top-left (17.4) | | **CSS** | CSS pixels of the display surface | The display rectangle's top-left | | **Device** | Device pixels of the backing store | The backing store's top-left | The display rectangle is `Wd x Hd` CSS pixels, and the **projection center** `c` is `(Wd / 2, Hd / 2)` in CSS space — the center of the display rectangle after `fit` resolution (17.4), not the center of the scene. Every camera operation and the perspective factor of 17.6 act about that one point. **The fit matrix `F` maps scene space to CSS space.** The scene rectangle is `Ws x Hs`: `width x height` for `virtual`, `1 x 1` for `normalized`, and `Wd x Hd` for `viewport`. With ```text contain: sx = sy = min(Wd / Ws, Hd / Hs) cover: sx = sy = max(Wd / Ws, Hd / Hs) stretch: sx = Wd / Ws, sy = Hd / Hs viewport: sx = sy = 1 ``` the fit matrix carries the centering offset that `contain` and `cover` both need and that neither can be expressed without: ```text ox = (Wd - sx * Ws) / 2 oy = (Hd - sy * Hs) / 2 F = T(ox, oy) x S(sx, sy) ``` `ox` and `oy` are the letterbox bars under `contain` and are negative — the crop — under `cover`; under `stretch` and in `viewport` they are `0`. Checking the algebra only at `sx = 1` hides this offset entirely, which is exactly why it is written out. **The camera matrix `V` is normative, and it operates in CSS space.** The camera's `x` and `y` are authored in **scene** units, so they are carried into CSS space before the translation is formed. With `q = F * (x, y)` the camera center in CSS space, and `p` the layer's `parallax` (17.5): ```text V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c)) ``` Subtracting `q` from `c` without mapping through `F` first would subtract scene units from CSS units — the two agree only when `sx = sy = 1`, which is precisely the case a naive test uses. **The full chain.** For a local-space point of an object with effective depth `zEffective` (17.6), on a layer with parallax `p`: ```text x_device = B x P x V x F x M_effective x x_local M_effective = M_parent x M_local (17.11) P = T(c) x S(k) x T(-c), k = focalLength / (focalLength + zEffective) under perspective; P = I under orthographic B = S(dpr_effective) (19.5) ``` Read right to left: local geometry composes up its group tree, the fit places it in the display, the camera moves the display, perspective foreshortens about the projection center, and the backing-store scale is applied **once**, last, by `B`. A renderer that folds `dpr` into `F` and again into `B`, or that applies the camera before the fit, does not conform. Culling of objects at `zEffective <= -focalLength` is unchanged from 17.6 and raises no diagnostic. Under `orthographic`, `zEffective` still drives sorting, parallax, and fog but never scale. **Parallax multiplies the camera translation only** — never its zoom or rotation, as the position of `p` inside `V` shows — so a distant layer drifts more slowly than a near one without also being scaled or tilted differently, which is what makes the deep parallax field of PRD 130 item 1 hold together. A layer with `parallax: 0` is **pinned against camera translation**: the trailing `T` becomes the identity while `S(zoom)` and `R(-rotation)` still apply. It is the mechanism for a fixed overlay in a panning scene, and an exhibit that also wants that overlay unzoomed and unrotated declares a camera that does not zoom or rotate. Nothing in 0.1 exempts a layer from the whole camera. **Nonuniform `stretch`, and scalar appearance dimensions.** Under `stretch`, `F` carries a nonuniform scale that is applied **after** an object's own rotation, so a rotated square becomes a rotated parallelogram and a circle becomes an axis-aligned ellipse. That is the documented consequence of asking for an aspect-ratio change, not a defect, and it is the reason `contain` is the default. Appearance dimensions that are scalars with no axis — `strokeWidth`, `strokeDash` lengths, `strokeDashOffset`, `pointSize`, `style.blur`, `glow.radius`, `shadow.blurRadius`, `shadow.offsetX`, `shadow.offsetY`, and `text.size` and `letterSpacing` — cannot follow a nonuniform matrix, so they take the single uniform factor ```text g = zoom * k * sqrt(sx * sy) ``` which equals `zoom * k * s` whenever the fit is uniform, and is the geometric mean of the two axes when it is not. `dpr_effective` is not in `g`: it is applied by `B` to the whole frame, including these dimensions, and counting it twice would double every stroke on a retina display. **`projection` is not automatable and not externally addressable.** Switching projection mid-run has no continuous meaning: there is no value between `orthographic` and `perspective`, so an interpolating mechanism cannot express the change and a stepping one would snap the whole scene. `x`, `y`, `zoom`, `rotation`, and `focalLength` are automatable and are in the 8.1 table by 19.1; `projection` is authored once. This is the direct reading of PRD 87, which says camera *properties* may be automated or bound to state and modulators, in a document whose other enums are likewise fixed at authoring. **Camera and appearance units.** Stroke widths, blur radii, glow radii, and shadow offsets are measured in scene units before camera scaling (17.2), so zooming in thickens a stroke exactly as it enlarges the geometry it outlines. This is the behavior a design space implies and it is stated here because it is the question a renderer author asks first. ### 19.4 Post-processing (`visuals.effects`) `visuals.effects` is an ordered array of post-effect entries applied to the composited frame after every layer has been composited and before display (PRD 88). It defaults to `[]`; an exhibit with no effects pays for none. Every entry is `{ "type": , "enabled": ValueSpec, ...parameters }`. `enabled` defaults to `true`; a disabled effect is skipped entirely and costs nothing that frame. A `type` outside the seven below is `ERR_INVALID_EFFECT_TYPE`, and any parameter a type's own table does not declare is `ERR_UNKNOWN_FIELD`. | `type` | Parameters | Range | Default | Effect | | --- | --- | --- | --- | --- | | `vignette` | `amount` | `0` to `1` | `0.5` | Darkening toward the frame edge. | | | `radius` | `0` to `1` | `0.75` | Fraction of the half-diagonal at which darkening begins. | | | `softness` | `0` to `1` | `0.5` | Edge softness of that transition. | | | `color` | `color` | `#000000` | Color blended toward. | | `scanlines` | `amount` | `0` to `1` | `0.3` | Blend strength of the line pattern. | | | `spacing` | `1` to `64` | `3` | Line period in device pixels. | | | `thickness` | `0` to `1` | `0.5` | Fraction of the period the dark band occupies. | | | `speed` | — | `0` | Periods per logical second of vertical scroll. | | `grain` | `amount` | `0` to `1` | `0.15` | Blend strength of the noise. | | | `scale` | `0.25` to `16` | `1` | Grain cell size in device pixels. | | | `speed` | `0` to `60` | `24` | Regenerations per logical second. `0` freezes one pattern. | | `color-adjust` | `brightness`, `contrast`, `saturation` | `0` to `4` | `1` | The 17.12 filter semantics, applied to the frame. | | | `hueRotate` | — | `0` | Degrees. | | `blur` | `radius` | `0` to `32` | `4` | Scene units, scaled by camera `zoom`. | | `bloom` | `threshold` | `0` to `1` | `0.7` | Luminance above which a pixel contributes. | | | `intensity` | `0` to `2` | `0.6` | Strength of the added bloom. | | | `radius` | `0` to `32` | `8` | Scene units. | | `fade` | `color` | `color` | `#000000` | Full-surface blend, the transition primitive. | | | `amount` | `0` to `1` | `0` | Blend fraction. | Every numeric parameter above is a ValueSpec\, is automatable by 19.1, and is an 8.1 binding and override target by the table in that subsection. `color` parameters and `type` are authored once. A parameter outside its range is `ERR_OUT_OF_BOUNDS` when it is a literal at import and clamped with no diagnostic when it resolves out of range at instantiation, which is the staging rule 14.5 fixes for audio and the same rule applies here. **Scene-unit radii become device pixels by one stated conversion.** `blur.radius` and `bloom.radius` are authored in scene units, and the frame they act on is measured in device pixels, so the conversion is part of the contract: ```text r_device = r_scene * zoom * sqrt(sx * sy) * dpr_effective ``` This is the uniform appearance factor `g` of 19.3 with `dpr_effective` (19.5) folded in, and with the two terms a post-effect **cannot** have deliberately absent: an effect has no object, so the perspective factor `k` is `1`; and it runs after every layer is composited, so no layer's `parallax` enters it. Borrowing either — taking the depth of the nearest object, or the parallax of the last layer drawn — would make a full-frame effect depend on scene content and is specifically excluded. Under nonuniform `stretch` the geometric mean `sqrt(sx * sy)` is what a radius with no axis can take, exactly as for a stroke width. Where the effect is computed at a reduced resolution factor `q` (below), the radius used inside that buffer is `r_device * q`, so the upsampled result matches the full-resolution one to within the approximation the warning already declares. **Effect parameters resolve once, at activation.** Every ValueSpec in `visuals.effects`, including each entry's `enabled`, is resolved at exhibit activation — the effects chain is exhibit-scoped, so activation is its instantiation boundary (17.14). Sampling order is array order, and within one entry document field order after `type`, so a fixture's stream position after the chain is fixed. `color` parameters and `type` are authored literals, not ValueSpecs (17.12). The resolved value is the base the shared pipeline of 8.1 and the automation of 19.1 then compose over; `enabled` resolves once and has no external target capability. Section 19.1 exposes only numeric effect parameters; enabling an effect dynamically requires a future contract revision. **Filter parameter names map to the 17.12 operations without renaming.** `color-adjust`'s `brightness`, `contrast`, and `saturation` are the `brightness`, `contrast`, and `saturate` operations of 17.12, and its `hueRotate` is `hue-rotate`, with identical ranges and semantics. The authored spellings differ because one is an effect parameter and the other a filter `type` token; the operations are the same and a runtime implements them once. **Order is array order**, and it is normative because these operations do not commute: `bloom` before `color-adjust` blooms the authored colors, and after it blooms the adjusted ones. `fade` last is the usual transition placement. The runtime does not reorder the chain for efficiency. **Cost and passes.** `blur` and `bloom` each require a full-frame readback and count as **two** passes; `bloom` needs a bright-pass and a blend. `vignette`, `scanlines`, `grain`, `color-adjust`, and `fade` are single-pass pointwise operations over the frame and count as **one**. At most `4` effect entries are authorable (`ERR_VISUAL_LIMIT_EXCEEDED`), which is why the post-effect pass ceiling of 19.5 is `8`: four entries of two passes each. The offscreen compositing buffers of 17.5 and 17.12 have their own separate ceiling in the same table; the two budgets are counted separately because one is per object and the other is per frame. **Approximation.** PRD 88 permits a runtime to approximate an effect differently depending on renderer capability, and 0.1 fixes the boundary of that permission: * `blur` and `bloom` may be computed at a reduced resolution and upsampled, and their radius may be realized by a separable two-pass approximation. A runtime that does either raises `WARN_VISUAL_APPROXIMATION` once per effect instance, never once per frame, and the frame remains within one visually equivalent step of the exact result. * `grain` is **exempt from reproducibility and consumes no procedural stream.** Its per-pixel noise is generated from a frame counter and pixel coordinate, not from a seeded stream (9.3), so two runs of one seed are not guaranteed pixel-identical while the grain is enabled. This is the only place in the visual contract where that is true, and it is stated rather than discovered: section 9.3 promises identical *decisions*, grain drives no decision, and pulling per-pixel samples from the exhibit's stream would make the stream position depend on resolution and frame rate — which would break every other reproducibility guarantee to protect one that nobody needs. * An effect a renderer cannot perform at all is skipped for the frame and raises `WARN_VISUAL_APPROXIMATION` once per effect instance. It is never silently replaced with a different effect. No post-effect is subject-specific, and none may be used to smuggle geometry into the frame: the chain reads the composited frame and writes the composited frame, and it has no access to scene objects, systems, or state beyond its own declared parameters. ### 19.5 Visual safety limits and the centralized runtime ceilings PRD 120 requires that resource ceilings be centralized rather than scattered through individual subsystems. This subsection is that center. Every ceiling the engine enforces appears here, whether it was first stated in section 14, 15, 16, 17, or 18; a subsystem section states a limit for locality, and this table is where a runtime reads it and where a change is made. **Two kinds of limit, and they behave differently.** PRD 89 requires that clearly excessive values normally fail validation rather than be silently transformed into something materially different. That gives the dividing line: | Kind | Checked | On breach | Diagnostic | | --- | --- | --- | --- | | **Authoring bound** — a value in the document | Import (structural or semantic) | The exhibit is rejected | `ERR_VISUAL_LIMIT_EXCEEDED`, `ERR_NODE_LIMIT_EXCEEDED`, `ERR_OUT_OF_BOUNDS`, or `ERR_SCHEMA_VALIDATION`, as each limit's own section states | | **Runtime ceiling** — an aggregate the document does not name | Every logical tick or frame | Work is shed by a documented rule; the exhibit keeps running | `WARN_VISUAL_CEILING` under the single cadence below, never once per shed item | An authoring bound fails loudly because the author wrote a number and should learn it is wrong. A runtime ceiling sheds quietly because the exhibit is legal and the machine is merely small, and PRD 119 requires the runtime to remain usable when performance degrades. Neither is ever silently transformed into something materially different, which is the behavior PRD 89 forbids. **Aggregate runtime ceilings.** | Ceiling | Value | Shedding rule when reached | | --- | --- | --- | | Live particles, all systems | `8192` | Evict the oldest particle in the system holding the most, then retry (18.2 is oldest-first within a system) | | Live emitted items, all emitters | `2048` | Evict the oldest item in the emitter holding the most (18.4) | | Live spawned system instances | `64` | Refuse the spawn (19.2). Never evict a live system | | Link segments drawn per tick | `4096` | Drop the tail of the ascending pair order of 18.8, deterministically | | Trail history samples retained | `32768` | Truncate the oldest samples of the longest histories first | | Field evaluations per logical tick | `32768` | Evaluate fields for the nearest items first and treat the remainder as zero force for that tick | | Offscreen compositing buffers per frame | `16` | Draw the excess objects without their non-default `blend`, `mask`, `blur`, `glow`, or `filters`, farthest-`z` first, and raise `WARN_VISUAL_APPROXIMATION` | | Post-effect passes per frame | `8` | Skip the trailing effects of the chain (19.4) | | Device-pixel-ratio multiplier | `min(devicePixelRatio, 2)` | Clamp | | Backing store | `4096 x 4096` device pixels | Lower the multiplier until it fits, below `1` where a CSS display larger than `4096` requires it (see below) | | Automation records, live | `128` tracks / `2048` points | Refuse the spawn atomically (19.1). Never drop a track from an admitted instance. The same numbers are also an authoring bound over the *declared* records, checked at import | | Logical ticks of shed before the runtime reports degradation | `120` consecutive | Raise `WARN_VISUAL_CEILING` for that key with a `sustained` detail, under the same cadence (below) | **One diagnostic cadence, for every ceiling and every shed.** Three rate rules appeared across sections 17-19 — "once" per refused spawn, "once per ceiling per second", and a report after `120` sustained ticks — and they are one rule: * Every resource diagnostic is keyed by the pair **(code, subject)**, where the subject is the ceiling for `WARN_VISUAL_CEILING` and the effect instance, paint instance, or object for `WARN_VISUAL_APPROXIMATION`. A refused spawn's subject is the spawned-instance ceiling, not the template, so a burst of refusals across templates is one key. * A key is raised **at most once per logical second**, measured on the clock of 9.1, however many items were shed or spawns refused in it. This replaces every bare "once" written elsewhere for a resource condition; a per-instance "once for the life of the instance" cadence still governs the *capability* warnings of 17.12 and 19.4, which report a renderer fact rather than a resource state. * A key counts **consecutive ticks on which it shed**. On reaching `120`, the next raise for that key carries a `sustained` detail naming the ceiling and the tick count. It does not bypass the per-second limit and it does not mint a second code: one condition, one code, one cadence, with metadata for the difference. * One tick with no shed on a key resets both that key's consecutive counter and its rate limit, so a recovery followed by a new overload reports promptly rather than being suppressed by the previous burst. Approximation shedding — buffer refusal (17.12) and a skipped post-effect pass (19.4) — uses this cadence too, with `WARN_VISUAL_APPROXIMATION` as the code. **Ceilings from the other subsystems, restated for centralization.** | Ceiling | Value | Fixed in | | --- | --- | --- | | One-shot voices | `64` | 16.6 | | Continuous sounds | `16` | 16.6 | | Expanded audio nodes and routes per sound | Per section 15.14 | 15.14 | | Audio component nesting | `8` | 15.15 | | Audio automation tracks / points per sound | `64` / `256` | 16.1 | | Ordinary dispatch units per logical tick | `1024` | 10.5 | | Nested event depth | `16` | 10.5 | | Termination-hook actions per owner | `256` | 10.5 | | Layers | `16` | 17.5 | | Group nesting | `8` | 17.9 | | Polyline / polygon vertices | `512` | 17.9 | | Spline points | `256` | 17.13 | | Gradient stops | `16` | 17.12 | | Filters per object | `4` | 17.12 | | Visual component nesting | `8` | 18.1 | | Particle `capacity` per system | `4096` | 18.2 | | Emitter `capacity` per system | `512` | 18.4 | | Repeater `count` | `1024` | 18.5 | | Behaviors per object | `8` | 17.10, 18.6 | | Declared fields / referenced per system | `8` / `4` | 18.7 | | Trail `length` | `128` | 18.8 | | `maxLinks` per system | `1024` | 18.8 | | `nearest` links per item | `8` | 18.8 | | Pairwise-linked population | `256` | 18.8 | | Path commands per object | `512` | 17.13 | | Text characters after resolution | `256` | 17.13 | | `strokeDash` entries | `8` | 17.12 | | Burst entries per system | `16` | 18.2, 18.4 | | Grid `columns` and `rows` | `256` | 18.3 | | Custom oscillator partials | `64` | 14.7 | | Resonator modes | `16` | 15.10 | | Visual automation points per track | `256` | 19.1 | | Declared visual systems | `64` | 17.3 | | Expanded static visual objects | `16384` | 17.3 | | Post-effect entries | `4` | 19.4 | | Blur and bloom radius | `32` scene units | 19.4 | | Spawned instances | `64` | 19.2 | **Static draw load is bounded too.** Every ceiling above bounds a *dynamic* population; none of them bounds the work an exhibit declares outright. A document with sixty repeaters of `1024` copies each, or a component expanded ten thousand times, is legal under every other limit in this table and would never be shed, because 19.5's frame-time governance never sheds an authored persistent object or a declared system. Two authoring bounds close that gap: * **Declared visual systems:** at most `64` entries in `visuals.systems`, counting spawned templates once each. More is `ERR_VISUAL_LIMIT_EXCEEDED`. * **Expanded static visual objects:** at most `16384` across the whole document, counted after component expansion and repeater expansion. A `graphic` system contributes its object tree; a `component` object contributes its own expansion; a `repeater` contributes `count` times the object count of its `repeat` tree, using the *literal* `count` where it is one and its `1024` bound where it is procedural; a `particles` system or `emitter` contributes its `render` or `emit` tree once, because its live population is already bounded above. More is `ERR_VISUAL_LIMIT_EXCEEDED`, at import where every count is literal and at the system's instantiation boundary otherwise. Both values are provisional in exactly the sense the aggregate table's values are: their *shape* — an authoring bound that rejects at import rather than a shed that degrades in front of the viewer — is normative now, and the numbers are confirmed or replaced by the slice 4h GC6 measurement. Scenario instances and timeline expansions are the two entries of PRD 120's list that no contract has yet fixed; they are set with the scenario contract in Phase 6 and belong in this table when they are. Naming the gap here is what keeps the table the single center rather than a snapshot. **Large displays, and the one place the multiplier goes below `1`.** The two rows above conflict on a display wider or taller than `4096` CSS pixels: no multiplier at or above `1` can keep the backing store within `4096 x 4096`. The backing-store cap wins, and the multiplier is lowered **below** `1` — the frame is rendered at reduced resolution into the largest conforming backing store and upsampled to the display — raising `WARN_VISUAL_APPROXIMATION` once per resolution change, not once per frame. The multiplier is never raised above `min(devicePixelRatio, 2)`, so a device reporting a `devicePixelRatio` below `1` keeps it. Refusing to draw, or exceeding the platform's maximum surface size, are both worse answers than a soft frame, and PRD 119 requires the runtime to stay usable as resources run short. **Lowering, never raising.** A runtime may lower any ceiling in either table on a weaker device, exactly as 16.6 permits for voices. A document can never raise one: there is no field, parameter, or capability by which an exhibit requests a larger budget, and a document that could would be able to defeat the protection PRD 89 exists to provide. **Frame-time governance** (PRD 119). The runtime monitors frame interval and sheds work in the order of the aggregate table above — particles first, then emitted items, then links, then trail history, then compositing buffers, then post-effect passes — before it lowers the device-pixel-ratio multiplier, and it never sheds an authored persistent object or a declared system. The order is normative so that a degraded frame is the same degraded frame everywhere. The *thresholds* that trigger governance are provisional and are derived from the slice 4h GC6 measurement, in the same relationship 16.7 has with the audio protection values: the shape is normative now, the numbers are confirmed by measurement. Until 4h runs, every value in the aggregate table is a considered estimate and nothing more, and no evidence record may describe them as measured. ### 19.6 New diagnostic codes Added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_INVALID_EFFECT_TYPE` | Semantic | A `visuals.effects` entry `type` is not one of the seven of 19.4. | | `WARN_VISUAL_CEILING` | Runtime | An aggregate runtime ceiling of 19.5 was reached and work was shed or a spawn refused. | Two, and no more. Every other diagnostic this section needs already exists and is reused rather than duplicated under a visual-specific name, on the principle 17.15 and 18.9 state: `ERR_AUTOMATION_CONFLICT`, `WARN_AUTOMATION_FALLBACK`, `ERR_UNSUPPORTED_TARGET`, `ERR_INVALID_REFERENCE`, `ERR_VISUAL_LIMIT_EXCEEDED`, `ERR_NODE_LIMIT_EXCEEDED`, `ERR_OUT_OF_BOUNDS`, `ERR_UNKNOWN_FIELD`, `ERR_SCHEMA_VALIDATION`, `WARN_VISUAL_APPROXIMATION`, and `WARN_CLEANUP_FORCED`. `ERR_AUTOMATION_CONFLICT`'s section 7 cause text is widened by this slice from "in a recipe instance" to cover a visual scope and the behavior collision of 19.1. Widening the one code is correct where minting a second would not be: the condition is identical in both subsystems — two writers, one scalar, no defined answer — and an author who has learned what the code means in audio has learned what it means in visuals. ### 19.7 Required traces before slice 4f implementation is accepted Automated, and executable without a display measurement: 1. Each automation `mode` and each interpolation curve produces its documented value at segment start, midpoint, and end on a visual target, matching the audio results of trace 1 of 16.11 exactly, since the curves are shared. 2. `loop.mode` `repeat` restarts from the first point at the loop period and `ping-pong` reverses; a finite `count` of `n` ends on the first point under `ping-pong` and on the last under `repeat`; `infinite` never stops; a track with no `loop` holds its last value forever. 3. A `visuals.automation` track's `at` is measured from activation and a `visuals.systems..automation` track's from that system's instantiation boundary: two spawns of one template `4s` apart produce identical curves offset by `4s`. 4. A track naming a target in the other scope, another system, an undeclared layer, or an out-of-range effect index is `ERR_INVALID_REFERENCE`; a target outside the 19.1 registry — a `color`, a `visible`, a field `strength`, a behavior parameter — is `ERR_UNSUPPORTED_TARGET`. 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 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`. 12. A `65`th live spawned instance is refused with a single `WARN_VISUAL_CEILING`, no live instance is evicted, and the refusal is not a scenario failure. 13. The camera matrix of 19.3 places a known object at documented display coordinates for each of zoom, rotation, translation, and their composition; a layer at `parallax: 0.2` translates by one fifth of the camera translation and is neither scaled nor rotated differently; `parallax: 0` is pinned. 14. Under `perspective`, an object at `z` renders at the documented factor about the projection center and an object at `z <= -focalLength` is culled with no diagnostic; under `orthographic` the same object's scale is unchanged while its sorting, parallax, and fog are not. 15. Each of the seven effects produces its documented change on a known frame; array order is honored (`bloom` before and after `color-adjust` differ); a disabled effect costs no pass; a `type` outside the seven is `ERR_INVALID_EFFECT_TYPE`; a fifth entry is `ERR_VISUAL_LIMIT_EXCEEDED`. 16. `grain` consumes no procedural stream: with grain enabled, two runs of one seed produce identical decision traces, and disabling grain does not shift any later procedural sample. 17. A `blur` or `bloom` computed at reduced resolution raises `WARN_VISUAL_APPROXIMATION` once per effect instance and not once per frame; an unavailable effect is skipped, not substituted. 18. Each aggregate ceiling of 19.5 sheds by its documented rule under a synthetic overload: the shed is deterministic, the exhibit keeps running, and no authored persistent object or declared system is shed. The single diagnostic cadence holds under all four of its cases — many refusals within one second raise one `WARN_VISUAL_CEILING` for that key; `120` consecutive shedding ticks add the `sustained` detail without minting a second code and without bypassing the per-second limit; one clear tick resets both the counter and the rate limit; and a second overload after that recovery reports promptly. Buffer refusal (17.12) and a skipped post-effect pass (19.4) follow the same cadence with `WARN_VISUAL_APPROXIMATION`, while the capability warnings of 17.12's conic fallback and 19.4's reduced-resolution `blur` stay once per instance. 19. No document can raise a ceiling: every field, parameter, and action that names a count is checked to clamp or reject, never to widen. User-observed, and **not** satisfiable by the above: 20. The PRD 130 visual acceptance challenge passes in full, judged on a real display. This is trace 15 of 17.16 and trace 20 of 18.10, restated for locality; it is one gate, not three. 21. The early combined GC6 benchmark of slice 4h at `1920 x 1080` runs its 30-second warm-up and 120-second window on recorded hardware, and its results either confirm the aggregate ceilings of 19.5 or replace them. **Which slice carries which contract.** Sections 17, 18, and 19 were all written before slice 4d, so a section 17 rule that cites 19.3 is a forward reference within one contract, not a circular dependency between implementations. What each slice must carry, and what it must not claim: | Slice | Contracts it must implement | Contracts it must carry as data without executing | | --- | --- | --- | | **4d** | Scene and `fit` (17.4), the composition chain, camera matrix, `parallax`, projection, and the `focalLength` bound of 19.3 as a **static** view; layers, transforms, the fourteen primitives, appearance, compositing and the buffer pool (17.5-17.13); depth, sortable units, and fog (17.6) | The `visuals.automation` and system `automation` arrays, `spawn`, `fields`, `effects`, and every procedural system's schema: parsed, validated, and rejected correctly, drawn not at all | | **4e** | Components, particles, distributions, emitters, repeaters, behaviors, fields, trails, links (18.1-18.8), with 4d's renderer drawing them | Automation, lifecycle, and effects execution | | **4f** | Automation into the shared 8.1 pipeline, lifecycle and `spawn`/`remove`, camera and effect *execution*, ceilings and shedding (19.1-19.5) | — | Trace 6 of 17.16 — the perspective factor at three depths — stays in 4d. Depending on 19.3's static contract is what makes it testable; dropping it to avoid the dependency would remove the one automated check on the composition chain, which is where the unit-mismatch defects of this review lived. A parsed stub is never a passed runtime trace: a slice reports a trace as passed only when the behavior it names actually ran. Traces 1-19 belong to slice 4f. Traces 20 and 21 close slice 4h. Phase 4 is not accepted until both do, no matter how many automated traces pass, and the ceilings of 19.5 are not measured until trace 21 says so. --- ## 20. Cadence and Event Subsystems Contract (Phase 5) ### 20.1 Pipeline and scope The Cadence and Event subsystems provide the temporal orchestration and procedural trigger mechanisms that animate an exhibit between continuous modulation and autonomous scenarios. Cadence operates on the engine's fixed logical clock ($60\text{ Hz}$, $1000/60\text{ ms}$ fixed step, section 9.1). It drives automatic recurring sound activity through independent class schedulers, maintaining aesthetic variety through weighted random selection, cooldowns, overlap prevention, and recency-based anti-repetition penalties. Events provide reusable, parameterized bundles of actions that execute atomically within a logical tick. Events may be invoked by cadence, by manual UI interactions (soundboard / buttons), by scenarios, or by other events up to a bounded nesting depth. ### 20.2 Canonical cadence classes and usage separation #### 20.2.1 Cadence classes (PRD 61) Every sound in an exhibit that participates in automatic scheduling or ambient maintenance is assigned a canonical cadence class in its `cadence.class` field: | Class | Semantic Meaning | Scheduling Policy | | :--- | :--- | :--- | | `ambient` | Persistent background audio beds, drones, or continuous atmospheres. | Maintained automatically while audio is unlocked. Not scheduled by pulse intervals. | | `routine` | Frequent, predictable automatic background activity. | Independent periodic scheduler (default 10s–45s). | | `intermittent` | Periodic recurring activity with noticeable gaps. | Independent periodic scheduler (default 45s–4m). | | `occasional` | Infrequent, prominent exhibit events. | Independent periodic scheduler (default 3m–15m). | | `rare` | Unusual or special exhibit events. | Independent periodic scheduler (default 15m–60m). Highest priority during simultaneous firings. | | `scenario` | Orchestrated audio reserved exclusively for scenario timelines. | **Never** scheduled by the automatic cadence engine. | Any other class value is a semantic validation error (`ERR_SCHEMA_VALIDATION`). #### 20.2.2 Usage and cadence separation (PRD 62) A sound's `usage` array defines which execution surfaces are authorized to invoke it: * `"automatic"`: Authorized for invocation by the automatic cadence engine. * `"manual"`: Authorized for invocation by direct user actions (soundboard / UI buttons). * `"scenario"`: Authorized for invocation by scenario actions or timelines. Defaults to `["automatic"]` if omitted. A sound action executed from a manual context naming a sound whose `usage` does not include `"manual"` is rejected with `ERR_UNSUPPORTED_TARGET`. A sound action executed from a scenario naming a sound whose `usage` does not include `"scenario"` is rejected with `ERR_UNSUPPORTED_TARGET`. The automatic cadence engine considers only sounds whose `usage` includes `"automatic"`. ### 20.3 Cadence configuration container (`cadence`) The top-level `cadence` object configures exhibit-wide cadence behavior: ```json { "cadence": { "intensity": { "ref": "parameters.activity" }, "minGap": "1.5s", "clocks": { "routine": { "min": "8s", "max": "30s" }, "intermittent": { "min": "30s", "max": "3m" }, "occasional": { "min": "2m", "max": "10m" }, "rare": { "min": "10m", "max": "45m" } } } } ``` | Field | Type | Required | Default | Description | | :--- | :--- | :---: | :--- | :--- | | `intensity` | ValueSpec 0.1 | No | `1.0` | Global cadence intensity. Resolves to a number clamped to `[0, 1]`. | | `minGap` | DurationSpec 0.1 | No | `"1.5s"` | Minimum time separation between consecutive automatic one-shot sound starts. | | `clocks` | object | No | Default ranges | Map of class ID (`routine`, `intermittent`, `occasional`, `rare`) to `{ min, max }` ranges. | Unrecognized fields in `cadence` produce `ERR_UNKNOWN_FIELD`. In `clocks.`, `min` and `max` must be valid positive DurationSpec 0.1 strings with `min <= max`; inverted bounds produce `ERR_INVALID_RANGE_ORDER`. ### 20.4 Sound cadence metadata (`sounds..cadence`) A sound's optional `cadence` property configures its behavior within the cadence engine: ```json { "sounds": { "relay-click": { "name": "Relay Click", "usage": ["automatic", "manual"], "cadence": { "class": "routine", "weight": 1.5, "cooldown": "5s", "overlap": false, "when": { "op": ">", "left": { "ref": "state.pressure" }, "right": 0.2 } }, "recipe": { ... } } } } ``` | Field | Type | Required | Default | Description | | :--- | :--- | :---: | :--- | :--- | | `class` | enum | **Yes** | — | One of the six canonical classes. | | `weight` | numeric ValueSpec 0.1 | No | `1.0` | Base selection weight. Evaluated from the cadence stream. Must be $\ge 0$. | | `cooldown` | DurationSpec 0.1 | No | `"0ms"` | Minimum time after firing before this sound may be selected again. | | `overlap` | boolean | No | `true` | If `false`, cannot be selected if any voice of this sound is active in the engine. | | `when` | ConditionSpec 0.1 | No | — | Optional eligibility condition. Must evaluate true for selection. | Unrecognized fields in `sounds..cadence` produce `ERR_UNKNOWN_FIELD`. Negative literal weight produces `ERR_OUT_OF_BOUNDS`. ### 20.5 Cadence selection algorithm and anti-repetition (PRD 64, 65) Each automatic class (`routine`, `intermittent`, `occasional`, `rare`) maintains an independent timer and a recency history queue of depth $4$. #### 20.5.1 Firing step When a class timer expires: 1. **Eligible Pool:** Collect all declared sounds where `cadence.class` matches the firing class, `usage` contains `"automatic"`, and any declared `when` condition evaluates to `true`. 2. **Cooldown Filter:** Exclude any sound where $\text{currentLogicalTime} - \text{lastFiredTime} < \text{cooldown}$. 3. **Overlap Filter:** For each sound with `overlap === false`, inspect the audio subsystem's active voices (`SCHEDULED`, `ACTIVE`, `RELEASING`). Exclude the sound if any voice of that sound is currently active. 4. **Base Weights:** Evaluate `weight` for each remaining sound using `rng.stream('cadence', ':weight:')`. Clamp to $\ge 0$. 5. **Anti-Repetition Multipliers:** For each remaining candidate, look up its position in the class's recency queue: * Most recently selected ($1$ selection ago): multiplier $0.0$ * $2$ selections ago: multiplier $0.25$ * $3$ selections ago: multiplier $0.50$ * $4$ selections ago: multiplier $0.75$ * Older / not in recency queue: multiplier $1.0$ $\text{effectiveWeight} = \text{baseWeight} \times \text{multiplier}$. 6. **Pool Relaxation (PRD 65):** If the sum of effective weights for all remaining candidates is $0$ (which occurs when all eligible sounds are penalized to zero, such as in single-sound pools): * Relax anti-repetition penalties: set all multipliers to $1.0$ ($\text{effectiveWeight} = \text{baseWeight}$). * If the sum of base weights is still $0$, the class firing is skipped with no sound played. 7. **Weighted Selection:** Draw a pseudo-random value $u \in [0, 1)$ from `rng.stream('cadence', ':select:')` and pick the winning sound proportional to its effective weight. 8. **Execution:** Instantiate and play the winning sound via the audio subsystem. 9. **History Update:** Record the winning sound's firing timestamp $\text{lastFiredTime} = \text{currentLogicalTime}$. Append the winning sound ID to the front of the class recency queue, trimming the queue to maximum depth $4$. 10. **Reschedule:** Sample the next interval uniformly from $[T_{\min}, T_{\max}]$ using `rng.stream('cadence', ':interval:')`, scale by $1 / \text{intensity}$, and set the next class timer. ### 20.6 Minimum automatic gap and priority scheduling (PRD 67) To prevent simultaneous or jarringly close auditory collisions between different automatic classes: * The runtime tracks $\text{lastAutomaticSoundTime}$, the logical timestamp of the most recent automatic one-shot start. * A class firing is permitted only if $\text{currentLogicalTime} - \text{lastAutomaticSoundTime} \ge \text{minGap}$ (default $1.5\text{s}$). * If multiple classes become due simultaneously or while a gap hold is in effect, they are queued and serviced in strict canonical priority order: $$\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$$ * Deferred classes retain their firing opportunity: when the required $\text{minGap}$ has elapsed, the highest-priority deferred class fires immediately and resets the gap timer. ### 20.7 Cadence intensity (PRD 66) The `cadence.intensity` ValueSpec resolves on each logical tick to a finite number clamped to $[0, 1]$. * Effective interval: $T_{\text{effective}} = T_{\text{base}} / \text{intensity}$. * When $\text{intensity} \le 0$ (or $< 10^{-6}$): * All automatic one-shot class scheduling pauses. * No class timers advance or fire. * Existing playing voices play out their natural release or ending. * When $\text{intensity}$ rises above zero, timers resume with intervals scaled by the new intensity. * Continuous `ambient` sounds are unaffected by cadence intensity unless explicitly bound to a parameter. ### 20.8 Ambient sound maintenance (PRD 61, 139) Sounds with `cadence.class === "ambient"` and `usage` containing `"automatic"` represent persistent exhibit beds: * When the audio subsystem is active and unlocked, the runtime ensures that continuous ambient sounds are instantiated and playing. * If an ambient voice finishes, releases, or fails unexpectedly, the runtime restarts it at the next logical tick. * Pausing the performance pauses ambient voices; stopping or deactivating disposes them cleanly. ### 20.9 Manual SAMPLE isolation semantics (PRD 68) Manual soundboard playback (SAMPLE buttons in the UI) allows auditioning sounds independently of the performance: * Manual SAMPLE playback uses exclusively the isolated PRNG stream `sample` (`rng.stream('sample', ':')`). * SAMPLE playback: * Does **not** advance cadence clocks. * Does **not** alter class recency history. * Does **not** alter sound cooldown timestamps. * Does **not** alter automatic selection weights. * Does **not** reset or perturb the minimum automatic gap timer. * Two runs of an exhibit with arbitrary manual SAMPLE buttons clicked in between produce identical automatic cadence sequences and visual frames. ### 20.10 Event Model 0.1 (`events.`) (PRD 90, 91) The top-level `events` container defines reusable, named action bundles: ```json { "events": { "minor-disturbance": { "inputs": { "intensity": { "type": "number", "default": 0.5 }, "pitch": { "type": "number", "default": 440 } }, "actions": [ { "type": "sound", "sound": "warning-tone", "with": { "pitch": { "ref": "inputs.pitch" } } }, { "type": "override", "target": "parameters.activity", "value": { "ref": "inputs.intensity" }, "scope": "duration", "duration": "2s" } ] } } } ``` * `inputs`: Optional map of input identifier to parameter-like declaration (`type`, `default`, optional `min`, `max`, `step`, `values`). * `actions`: Required array of Action Model 0.1 action objects. Must contain at least one action. Unrecognized properties in `events.` or `inputs.` produce `ERR_UNKNOWN_FIELD`. Invalid identifiers produce `ERR_INVALID_ID`. ### 20.11 Event action execution (`type: "event"`) (PRD 27, 91) An `event` action invokes a declared event: ```json { "type": "event", "event": "minor-disturbance", "with": { "intensity": 0.8, "pitch": 880 }, "when": { "op": ">", "left": { "ref": "state.power" }, "right": 0.5 }, "chance": 0.9, "critical": false } ``` * `event`: ID of the target declared in `events`. Undeclared target is `ERR_INVALID_REFERENCE`. * `with`: Map of input values. Evaluated in the caller's context before event entry. Values must match declared input types without implicit coercion (`ERR_TYPE_MISMATCH`). Undeclared input names in `with` produce `ERR_UNKNOWN_FIELD`. Omitted inputs take declared defaults; missing required inputs without defaults fail with `ERR_SCHEMA_VALIDATION`. * **Input Scoping:** Within the invoked event actions, references matching `inputs.` resolve to the evaluated input value. Inputs are strictly local to that event invocation instance. * **Context Inheritance:** Nested actions inherit the caller's ownership context (scenario instance owner or performance root) and critical propagation setting (section 10.1). ### 20.12 Sound action execution (`type: "sound"`) (PRD 26) A `sound` action triggers an audio voice: ```json { "type": "sound", "sound": "relay-click", "with": { "pitch": 440 }, "ownership": "scenario" } ``` * `sound`: ID of the target declared in `sounds`. Undeclared target is `ERR_INVALID_REFERENCE`. * `with`: Optional map of input values for sound component/recipe expressions. * `ownership`: Optional ownership assignment (`"performance"` | `"scenario"` | `"persistent"`). Continuous sounds invoked by scenarios inherit scenario ownership and release at scenario completion (section 10.1); `"persistent"` keeps resources active across scenario boundaries. * **Usage Check:** The sound must permit the caller's execution mode: * Dispatched from manual UI: sound `usage` must include `"manual"`. * Dispatched from a scenario: sound `usage` must include `"scenario"`. * Dispatched from cadence: sound `usage` must include `"automatic"`. Violations produce `ERR_UNSUPPORTED_TARGET`. ### 20.13 Dispatch budget, nesting limits, and static cycle detection (PRD 91, Section 10.5) #### 20.13.1 Static cycle detection The static dependency graph of event-to-event invocations (direct or indirect) must be a Directed Acyclic Graph (DAG). Any cycle (e.g. $A \to B \to A$) is rejected during semantic validation at import with `ERR_CYCLIC_DEPENDENCY`. #### 20.13.2 Runtime nesting depth limit Event execution tracks call stack nesting depth. If an event invocation reaches depth $> 16$, execution halts with `ERR_DISPATCH_BUDGET`. #### 20.13.3 Per-tick ordinary dispatch budget To prevent infinite data-dependent feedback loops between events, state mutations, and triggers: * A hard ceiling of $1024$ ordinary dispatch units is enforced per logical tick. * Entering an event consumes $1$ dispatch unit. * Attempting an action consumes $1$ dispatch unit (including actions skipped due to `when` or `chance`). * When the $1025\text{th}$ unit would be consumed, the runtime emits `ERR_DISPATCH_BUDGET`, discards the remaining ordinary queue for that tick, and terminates any affected scenario owner as `FAILED`. * Mandatory termination hooks (`onComplete`, `onCancel`) are exempt from the ordinary budget and use their own isolated $256$-action ceiling (section 10.2). ### 20.14 Diagnostic codes Phase 5 reuses existing standard codes from the Section 7 table: * `ERR_SCHEMA_VALIDATION`: Missing required fields, invalid types, or invalid enum values (e.g. unknown cadence class). * `ERR_UNKNOWN_FIELD`: Undeclared property in `cadence`, `sounds..cadence`, `events.`, or event action `with` map. * `ERR_INVALID_ID`: Invalid event identifier. * `ERR_INVALID_REFERENCE`: Target sound or event does not exist, or dangling reference in input expression. * `ERR_UNSUPPORTED_TARGET`: Sound action invokes a sound whose `usage` excludes the caller's context. * `ERR_TYPE_MISMATCH`: Action `with` value type does not match declared event input type. * `ERR_CYCLIC_DEPENDENCY`: Static event invocation dependency cycle detected. * `ERR_INVALID_RANGE_ORDER`: `clocks.` `min` duration exceeds `max` duration. * `ERR_OUT_OF_BOUNDS`: Negative cadence weight. * `ERR_DISPATCH_BUDGET`: Per-tick ordinary dispatch budget ($1024$) or event nesting depth ($16$) exceeded. ### 20.15 Required traces before Phase 5 implementation is accepted 1. **Schema & Validation:** Valid and malformed `cadence`, `sounds..cadence`, and `events` blocks validate or reject with documented error codes. 2. **Static Cycle Detection:** Direct self-invocation ($A \to A$) and transitive cycles ($A \to B \to C \to A$) fail semantic validation with `ERR_CYCLIC_DEPENDENCY`. Valid acyclic event hierarchies pass. 3. **Event Execution & Scoping:** Event action executes member actions in order, resolves `inputs.` from passed `with` values, falls back to declared defaults, and isolates inputs across concurrent invocations. 4. **Event Nesting & Depth Limit:** Nested events pass inputs through multiple levels up to depth 16; depth 17 raises `ERR_DISPATCH_BUDGET`. 5. **Per-Tick Dispatch Budget:** Ordinary actions and events exceeding $1024$ units in a single tick halt with `ERR_DISPATCH_BUDGET` and discard remaining ordinary queue while preserving scenario termination cleanup. 6. **Sound Action Execution:** Sound actions validate sound references, pass inputs to recipes, check caller usage permissions, and reject unauthorized usage with `ERR_UNSUPPORTED_TARGET`. 7. **Cadence Eligibility & Cooldown:** A sound with `when: false` or active cooldown ($\text{elapsed} < \text{cooldown}$) is excluded from selection. 8. **Cadence Overlap Policy:** A sound with `overlap: false` is excluded from selection while any voice of that sound is active in the audio engine; a sound with `overlap: true` admits overlapping voices up to voice ceilings. 9. **Cadence Anti-Repetition Multipliers:** Consecutive firings verify multipliers $0.0$, $0.25$, $0.50$, $0.75$, $1.0$ against the class recency history. 10. **Pool Relaxation:** When all eligible sounds have effective weight zero, anti-repetition penalties relax to base weights and selection succeeds rather than permanently stalling. 11. **Cadence Intensity Scaling:** Decreasing intensity increases interval spacing ($T / \text{intensity}$); setting intensity to $0$ stops one-shot class scheduling completely. 12. **Minimum Automatic Gap & Priority:** Simultaneous class firings enforce `minGap` (default $1.5\text{s}$) between audio starts and service deferred classes in priority order ($\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$). 13. **Ambient Maintenance:** Ambient sounds (`cadence.class: "ambient"`) auto-start on audio unlock and are maintained by the runtime. 14. **Manual SAMPLE Isolation:** Manual SAMPLE playback draws exclusively from `rng.stream('sample', ...)` and leaves cadence clocks, cooldowns, recency history, and automatic PRNG sequences byte-identical.