Add Format Specification section 21 (Scenario Model 0.1) and the runtime that realizes it: the scenario director, seven trigger classes, timelines with repeats and branches, admission and concurrency control, nested ownership with bounded cleanup, and the production GC5 resource counters. Exhibit E provides a reproducible forty-minute long-scenario reference and scenario-challenge.xzbt covers the PRD 131 cases. A standalone acceptance/soak page is built by tools/build-scenario-acceptance.mjs. 252 automated checks pass. The two-hour real-duration development soak required by the GC6 schedule remains pending. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
343 KiB
XZBT Format Specification 0.1
XZBT format version: 0.1
Document revision: 0.10
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); Scenario Model 0.1 is complete in section 21 (Phase 6); UI and library-hardening contracts remain in progress
Related resources: PRD, decisions, verification
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:
^[a-z][a-z0-9_-]*$ - Reference Path Syntax: Dot-delimited path referencing a target property or namespace:
Valid canonical namespace prefixes:
<namespace>.<resource_id>[.<property>]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[<index>].<property>andpoints[<index>].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.<id>)
Parameters represent user-configurable settings. They have documented defaults and bounds:
{
"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 usesetto mutate parameters. Temporary programmatic modifications must useoverride.
3.2 State Variables (state.<id>)
State variables represent simulation-owned state. They are initialized at launch and manipulated by runtime events and scenarios:
{
"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:
440.0
4.2 Reference (ref)
Reads the currently resolved value of a declared target:
{ "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:
{
"random": {
"min": 200.0,
"max": 800.0,
"integer": false
}
}
min(number, required): Lower bound.max(number, required): Upper bound (max >= min).integer(boolean, optional, defaultfalse): Iftrue, output is rounded to an integer viaMath.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:
{
"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(literalor nestedValueSpec) andweight(number > 0).
4.5 Calculation Operator (op)
Evaluates an arithmetic or mathematical operation over argument operands:
{
"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:
{
"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)
leftandrightmust evaluate to compatible primitive types (numberwithnumber,booleanwithboolean,stringwithstring). Cross-type comparison producesERR_TYPE_MISMATCH.
5.2 Logical Combinators
Combines child condition expressions:
- Conjunction (
and): All child conditions must evaluate totrue. Short-circuits on firstfalse.{ "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 totrue. Short-circuits on firsttrue.{ "or": [ { "op": "lt", "left": { "ref": "state.energy" }, "right": 0.1 }, { "op": "eq", "left": { "ref": "state.alarm" }, "right": true } ] } - Negation (
not): Inverts the child condition.{ "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
falseon tickT-1totrueon tickT. - Re-Arming Rule: As long as the condition remains continuously
true, it will not fire again. It must evaluate tofalseon at least one tick to re-arm before it can fire on a subsequenttrueevaluation.
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:
{
"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.<id> 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.<id> 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:
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.<id> |
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.<id> |
Declared primitive; current state value | Yes | No | Yes | No | Declared min/max |
audio.buses.<id>.gain |
Number; bus gain ValueSpec |
Yes | Yes | Yes | Yes | [0, 4] in 0.1 before master output protection |
visuals.camera.<field> |
Number; camera x, y, zoom, rotation, focalLength |
Yes | Yes | Yes | Yes | The per-field range of 19.3 |
visuals.layers.<id>.opacity |
Number; layer opacity |
Yes | Yes | Yes | No | [0, 1] |
visuals.systems.<id>.visible |
Boolean; system visible |
Yes | No | Yes | No | — |
visuals.effects[<index>].<param> |
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.
{
"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:
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-<activationSequence>. 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]:
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:
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:
- Increment
tickIndexand latch queued user/external inputs in arrival-sequence order. Tests replace external inputs, including audio analysis, with deterministic fixtures. - Advance time-based modulators and automation; resolve the dependency graph in topological order.
- Evaluate cadence, event conditions, and scenario timelines once against that tick's resolved snapshot; enqueue work using document order as the final tie-break.
- 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.
- Advance owned audio/visual instance state and perform mandatory cleanup, which is not charged to the ordinary dispatch budget.
- 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_SKIPcount 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
20msanti-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:
xzbt-0.1\0<root-seed-decimal>\0<domain>\0<stable-instance-key>
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; complete trigger/timeline shapes | Shared lifecycle contract complete (GC5); Event Model in section 20; Scenario Model in section 21 (Revision 0.10 / 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:
{
"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.<recipe-id>— a named, reusable recipe graph (15.16).sounds.<sound-id>.recipe— an inline recipe graph (15.16, PRD 59).components.audio.<component-id>— a component graph, which addsparametersandinputdeclarations (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.<id>.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
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:
- 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, isERR_OUT_OF_BOUNDS. Validation never opens anAudioContextand never depends on audio hardware. - Instantiation stage (node creation on a live context). The runtime computes
audioMaxFrequencyfrom the actualAudioContext.sampleRateand 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 raisesWARN_AUDIO_RATE_CLAMPonce 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:
noiseandimpulsesample 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-holddoes draw from a seeded stream. Its held values are observable control decisions that can drive audible outcomes, so they are reproducible. It uses domainsoundwith 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)
{
"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<number> | 0.1 Hz to audioMaxFrequency (14.5) |
440 |
Yes (14.4 scope). |
detune |
ValueSpec<number> | -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)
{ "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)
{
"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<number> | 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)
{ "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<number> | -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)
{
"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<number> | 0.001 to 40 Hz |
1 |
Yes (14.4 scope). |
amplitude |
ValueSpec<number> | 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<number> | 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.<id> 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)
{
"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.<id> sample-and-hold.
| Field | Type | Range | Default | ValueSpec |
|---|---|---|---|---|
rate |
ValueSpec<number> | 0.01 to 100 Hz |
2 |
Yes (14.4 scope). |
min |
ValueSpec<number> | -1000 to 1000 |
-1 |
Yes (14.4 scope). |
max |
ValueSpec<number> | -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:
<sound-instance-key>|node|<node-path>
<sound-instance-key> 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. <node-path> 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
- 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.
- A node object carrying an
idfield isERR_UNKNOWN_FIELD, and a node keyedoutputisERR_INVALID_ID. - Semantic validation rejects a frequency above
24000without opening anAudioContext, and instantiation clamps againstmin(24000, sampleRate x 0.45)computed from the live sample rate, raisingWARN_AUDIO_RATE_CLAMPrather than failing. - 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. - An external
BindingSpectargeting a node field fails withERR_UNSUPPORTED_TARGET, confirming the 14.4 scope boundary. - Two
sample-holdinstances 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)
{ "type": "gain", "gain": 1 }
| Field | Type | Range | Default | ValueSpec | Modulatable |
|---|---|---|---|---|---|
gain |
ValueSpec<number> | 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)
{ "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<number> | 10 Hz to audioMaxFrequency (14.5) |
1000 |
Yes | Yes, Hz |
q |
ValueSpec<number> | 0.0001 to 100 |
1 |
Yes | Yes, unitless |
gain |
ValueSpec<number> | -40 to +40 dB |
0 |
Yes | Yes, dB |
detune |
ValueSpec<number> | -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)
{ "type": "compressor", "threshold": -24, "knee": 30, "ratio": 12, "attack": "3ms", "release": "250ms" }
| Field | Type | Range | Default | ValueSpec | Modulatable |
|---|---|---|---|---|---|
threshold |
ValueSpec<number> | -100 to 0 dB |
-24 |
Yes | No |
knee |
ValueSpec<number> | 0 to 40 dB |
30 |
Yes | No |
ratio |
ValueSpec<number> | 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)
{ "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<number> | 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)
{ "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<number> | 0 to 0.95 |
0.2 |
Yes | No |
mix |
ValueSpec<number> | 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)
{ "type": "reverb", "size": 0.5, "decay": "2s", "damping": 0.5, "predelay": "0ms", "mix": 0.25 }
| Field | Type | Range | Default | ValueSpec | Modulatable |
|---|---|---|---|---|---|
size |
ValueSpec<number> | 0 to 1 |
0.5 |
Yes | No |
decay |
DurationSpec 0.1 | 50ms to 30s |
2s |
Sampled once | No |
damping |
ValueSpec<number> | 0 to 1 |
0.5 |
Yes | No |
predelay |
DurationSpec 0.1 | 0ms to 500ms |
0ms |
Sampled once | No |
mix |
ValueSpec<number> | 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)
{ "type": "stereo-pan", "pan": 0 }
| Field | Type | Range | Default | ValueSpec | Modulatable |
|---|---|---|---|---|---|
pan |
ValueSpec<number> | -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)
{ "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)
{
"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<number> | 0.1 Hz to audioMaxFrequency (14.5) |
120 |
Yes | Yes, Hz |
modes |
array | 1 to 16 entries |
required | Per entry | No |
mix |
ValueSpec<number> | 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<number> | 0.001 to 256 |
— | Mode frequency as a multiple of the resolved fundamental. |
frequency |
ValueSpec<number> | 0.1 Hz to audioMaxFrequency |
— | Absolute mode frequency; ignores fundamental. |
gain |
ValueSpec<number> | 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)
{ "type": "component", "use": "voice", "values": { "pitch": 330 } }
| Field | Type | Required | Notes |
|---|---|---|---|
use |
string | Yes | ID of a declared components.audio.<id>. 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 <component-node-key>.<parameter-id> (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):
{ "from": "tone", "to": "filter" }
{ "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: <node-key>.<property>. |
depth |
ValueSpec<number> | 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 |
<exposed-parameter-id> |
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:
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
{
"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.<id> 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.<parameter-id>" } 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
{
"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.<id> 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.<id>. 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": "<recipe-id>" } naming an audio.recipes.<id>. |
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.<recipe-id> 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
{
"audio": {
"buses": {
"ambient": { "gain": 1 },
"effects": { "gain": 1 }
}
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
gain |
ValueSpec<number> | No (default 1) |
Range [0, 4], matching the section 8.1 safety clamp for audio.buses.<id>.gain. |
A bus is a named summing point with one gain stage. audio.buses.<id>.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
- 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.
- 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.
- A graph whose only path to
outputoriginates at anlfo,constant, orsample-holdfails rule 5, and the same graph with anoscillatorsource passes. - 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 isERR_COMPONENT_RECURSION. - Two modulation routes onto one property sum and then clamp to the property's declared range.
- A graph exceeding
128expanded nodes or256routes after component expansion isERR_NODE_LIMIT_EXCEEDED, while a graph at exactly those counts passes. - 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:
{
"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 | <node-key>.<property>, 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": <duration literal>, "value": ValueSpec<number> }. |
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:
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.<id>.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:
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:
- Dispose the oldest instance already in
FINISHED. - Evict the oldest instance in
RELEASINGby advancing its release ramp to immediate completion and disposing it. - For a one-shot request only: evict the oldest
ACTIVEone-shot by starting its release. - 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_SKIPis 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
t0and unlocks att1starts atACTIVEwith its automation tracks and sample-hold ticks advanced tot1 - 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_SKIPcarrying 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:
- Each automation mode and each interpolation curve produces its documented value at segment start, midpoint, and end;
stepholds;smoothhas zero slope at both endpoints. - 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.
- Two tracks on one expanded target are
ERR_AUTOMATION_CONFLICT; two modulation routes on that same target still sum. - An
exponentialsegment with a zero endpoint falls back to linear and raisesWARN_AUTOMATION_FALLBACKexactly once. - An automation track targeting a property outside the 15.13 registry is
ERR_UNSUPPORTED_TARGET; one naming an undeclared node isERR_INVALID_REFERENCE; one reaching a component's internals is rejected by the 15.15 encapsulation rule. 65tracks or257total points isERR_NODE_LIMIT_EXCEEDED;64and256pass.- Every permitted lifecycle transition is exercised and every forbidden one is rejected; a second stop on a
DISPOSEDinstance is a no-op. - An override masking an automated property releases to the track's current value, not the value held when the override took hold.
- The determinable-ending bound matches the 16.5 table for a graph combining
impulse,delay,reverb, andresonator; aoneshotfed by anoscillatorisERR_INDETERMINATE_ONESHOT; the same recipe ascontinuouspasses. - At the one-shot ceiling the eviction order of 16.6 is followed,
WARN_VOICE_LIMITis raised, a continuous sound is never evicted by a one-shot request, and a refused request leaves the runtime stable. - Disposal releases every node, connection, automation track, buffer, and subscription; a
FINISHEDinstance still counts against its ceiling untilDISPOSED. - A non-finite sample injected at the master chain mutes its block and raises
WARN_AUDIO_NONFINITEonce. - 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. - With no
AudioContextavailable the performance runs silently, raisesWARN_AUDIO_UNAVAILABLEonce, and creates no instance.
User-observed, and not satisfiable by the above:
- 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.
- Listening observations across the reference exhibits report no clicks, clipping, or pumping on release, eviction, unlock, or pause.
- 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
{
"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.xandyrun0to1from the top-left. The square is mapped into the display surface underfit.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 absentfitis accepted and has no mapping effect, and an explicitly authoredfitother thanstretchisERR_SCHEMA_VALIDATION. Thecontaindefault of thefitrow is not taken in this space, so absent andstretchbehave identically and neither scales anything. Content is responsible for its own responsiveness; the runtime does not scale it.virtual— the scene is a fixedwidthxheightdesign space with the origin at its top-left, mapped into the display surface underfit. 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.<layer-id> is:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
opacity |
ValueSpec<number> | No | 1 |
0 to 1. Applied to the composited layer, not per object. |
blend |
enum | No | normal |
Safe blend set (17.12). |
visible |
ValueSpec<boolean> | 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:
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:
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.<id>)
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<boolean> | 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
{
"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
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<number> |
No | {0, 0} |
The object's origin in its parent's local space. |
z |
ValueSpec<number> | 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<number> |
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<boolean> | 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
{
"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<number> |
{0, 0, 0} |
Added to position and z. |
rotation |
ValueSpec<number> | 0 |
Degrees (17.2). |
scale |
{x, y}, each ValueSpec<number> |
{1, 1} |
Negative values mirror. 0 collapses the object; this is legal and draws nothing. |
skew |
{x, y}, each ValueSpec<number> |
{0, 0} |
Degrees. Each magnitude must be below 90 or ERR_OUT_OF_BOUNDS. |
origin |
{x, y}, each ValueSpec<number> |
{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:
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<color>, paint object, or null |
null |
null draws no fill. |
stroke |
ValueSpec<color>, paint object, or null |
null |
null draws no stroke. |
strokeWidth |
ValueSpec<number> | 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<number> | 0 |
Scene units. |
pointSize |
ValueSpec<number> | 1 |
Diameter of a point, in scene units. |
opacity |
ValueSpec<number> | 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<number> | 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<color> (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<color> 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
transformand before any camera stage, using the extents of the primitive's row above. - The axis runs from
centerin the direction(cos angle, sin angle)(17.2) to the first intersection with that box's boundary. centeroutside the box, or a ray that never meets it, is not an error and needs no special renderer behavior: the axis then runs fromcentertocenter + d * (cos angle, sin angle), wheredis the distance fromcenterto 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<number> } 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:
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
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.<id>.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:
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
commandsarray whose first entry is notmoveisERR_INVALID_PATH. - A
closewith no open subpath, or two consecutiveclosecommands, isERR_INVALID_PATH. - An
arcwith a zero or negative radius component isERR_OUT_OF_BOUNDS. It is not silently degraded to aline: PRD 89 requires clearly excessive or degenerate values to fail validation rather than become something materially different. - More than
512commands isERR_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<number> | 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:
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[<index>].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<string> | Yes | — | Max 256 characters after resolution. |
font |
enum | No | sans-serif |
sans-serif, serif, monospace only. |
size |
ValueSpec<number> | 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<number> | No | 0 |
Scene units. |
maxWidth |
ValueSpec<number> | 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:
- 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.
- Visual automation (19.1) — declared tracks over numeric properties, with the
step,linear,exponential, andsmoothcurves and therepeatandping-pongloop modes of PRD 85. - 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.<field>, visuals.layers.<id>.opacity, visuals.systems.<id>.visible, and visuals.effects[<index>].<param>. 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.<id> 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:
- Each of the three coordinate spaces maps a known scene point to the expected display point under each
fitmode, including the letterbox offsets ofcontainand the crop ofcover;widthorheightoutsidevirtualisERR_UNKNOWN_FIELD, and either missing inside it isERR_SCHEMA_VALIDATION. - Every primitive in Visual Primitive Set 0.1 instantiates from a minimal example, and an unknown
typeisERR_INVALID_PRIMITIVE_TYPEwhile an unknown property on a known type isERR_UNKNOWN_FIELD. - 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.
- A nested
groupcomposesM_parent x M_localandz_parent + z_local; nesting9levels deep isERR_VISUAL_LIMIT_EXCEEDEDand8passes. - Depth sorting draws greater
zfirst and preserves document key order for equalz; the sort is stable across repeated frames with unchanged input. - Perspective scaling matches
focalLength / (focalLength + z)at three depths; an object atz = -focalLengthis culled without a diagnostic; underorthographicthe same object is unscaled. - Depth fog blends an object's colors by
density * clamp((z - near) / (far - near), 0, 1)atnear, midpoint, and beyondfar;far <= nearisERR_INVALID_RANGE_ORDER. - Style inheritance is per field: a child redeclaring
fillkeeps its ancestor'sstroke; afillinherited by alineis ignored while afilldeclared on alineisERR_UNKNOWN_FIELD. - Gradient stops out of increasing order are
ERR_INVALID_RANGE_ORDER;17stops,5filters,17layers,513path commands,257spline points, and513polygon vertices are eachERR_VISUAL_LIMIT_EXCEEDED, and each limit's maximum legal value passes. Group nesting depth is trace 4's9-versus-8case and is not restated here. - A renderer reporting no conic-gradient support falls back to the documented linear gradient and raises
WARN_VISUAL_APPROXIMATIONexactly once per paint instance across many frames, never once per frame. - Path legality: a first command other than
moveand an unopenedcloseareERR_INVALID_PATH; a zero-radiusarcisERR_OUT_OF_BOUNDSrather than a line; abezier-mode spline whose point count is not3n + 1isERR_SCHEMA_VALIDATION. - A
masknaming a missing child isERR_INVALID_REFERENCE; amaskon a non-groupobject isERR_UNKNOWN_FIELD; the masked child is not itself drawn. - Visual ValueSpec fields resolve once: a
randomposition sampled at instantiation holds its value across many frames, two runs of the same seed produce identical resolved values, and rendering600frames consumes no procedural stream. - Visual target capability is exactly the four families of the section 8.1 table (19.1), and no wider: a binding,
set, oroverrideaddressing a per-object property —visuals.systems.horizon.content.band.style.opacity— isERR_UNSUPPORTED_TARGET, as is one addressing a procedural-system field, a behavior field, a fieldstrength, or an emitterrate. Within the four families, each operation is accepted only where that family's row permits it:visuals.systems.<id>.visibleaccepts a binding and anoverrideand rejects an automationtargetnaming it,visuals.layers.<id>.opacityaccepts no modulation entry, and a value whose type does not match the row's type isERR_TYPE_MISMATCH. An undeclared layer or system, or aneffectsindex outside the authored array, isERR_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:
- 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.
- The early combined GC6 benchmark of slice 4h, on recorded hardware at
1920 x 1080with 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.<id>)
{
"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.<id> 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.<id>.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.<parameter-id>" } 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.<component> 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)
{
"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<number> | 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<number> | 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<number> |
No | {0, 0} |
Offset added to every distributed position. |
velocity |
{x, y, z}, each ValueSpec<number> |
No | {0, 0, 0} |
Scene units per logical second. |
acceleration |
{x, y, z}, each ValueSpec<number> |
No | {0, 0, 0} |
Scene units per logical second squared. |
drag |
ValueSpec<number> | No | 0 |
0 to 1. Fraction of velocity lost per logical second; see below. |
size |
ValueSpec<number> or life ramp | No | 1 |
Uniform local geometry scale on the render object; see below. |
rotation |
ValueSpec<number> | No | 0 |
Degrees at creation. |
angularVelocity |
ValueSpec<number> | No | 0 |
Degrees per logical second. |
opacity |
ValueSpec<number> 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<number> | 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 <system-path>#<creation-ordinal>, 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:
{ "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:
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)
{
"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<number> | No | 0 |
Emissions per logical second. |
burst |
array | No | [] |
0 to 16 entries of { "at": DurationSpec, "count": ValueSpec<number> }, 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<number> |
No | {0, 0} |
Offset added to every distributed position. |
velocity |
{x, y, z}, each ValueSpec<number> |
No | {0, 0, 0} |
Scene units per logical second. |
acceleration |
{x, y, z}, each ValueSpec<number> |
No | {0, 0, 0} |
Scene units per logical second squared. |
drag |
ValueSpec<number> | 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 <system-path>#<emission-ordinal>. 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)
{
"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<number> | 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<number> |
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 <system-path>#<copy-index>. 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
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:
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:
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:
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:
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-wandering 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.<id>)
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.
{
"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<boolean> 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:
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 <field-id>):
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:
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:
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
( px / scale , py / scale , t * speed )
Scalar to vector. Let h = 1e-3 be the central-difference step, taken in noise-space coordinates:
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<number> | No | inherits style.strokeWidth |
Scene units at the head of the trail. |
taper |
ValueSpec<number> | No | 1 |
0 to 1. Fraction of width remaining at the tail. |
fade |
ValueSpec<number> | 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<number> | 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 arepeater.countthat is a literal number. - Instantiation, where it is not: a
repeater.countauthored 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.<id> 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:
- A visual component instantiates with defaults, with supplied
inputs, and with acomponentobject nested inside another component;inputs.<id>resolves inside the component and isERR_INVALID_REFERENCEoutside it; an undeclared parameter key ininputsisERR_INVALID_REFERENCE; adefaultof the wrong type isERR_TYPE_MISMATCH. - Component nesting
9levels deep and a component instantiating itself are eachERR_COMPONENT_RECURSION, and8levels pass; a reference from outside a component to an object key inside it isERR_INVALID_REFERENCE. - Two instances of one component sample independently from their expansion paths, and reordering unrelated siblings leaves both instances' resolved values unchanged.
- Particle fields resolve once per particle: a
randomsize 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 rendering600frames consumes no procedural stream. - The integrator of 18.2 is verified against a closed-form case: constant acceleration with zero drag reaches the documented position after
nticks, and halving the tick length leaves the trajectory within the documented tolerance, while integrating position before velocity does not. dragisdt-correct: one tick of1sand ten ticks of0.1sleave the same velocity to within floating-point tolerance.- A life ramp interpolates
fromtotounder each of the four curves at age0, midpoint, and1; a ramp withoutlifetimeisERR_SCHEMA_VALIDATION; anexponentialramp through zero falls back to linear and raisesWARN_AUTOMATION_FALLBACK. rateemission is exact: at a constant rate the cumulative count aftertlogical seconds isfloor(rate * t)under three different tick lengths, and a burst emits its wholecounton the first tick at or after itsat.- Emission with neither
lifetimenorlimitisERR_UNBOUNDED_EMISSION;countalone withoutlifetimepasses; exceedingcapacityevicts the oldest item and leaves the live count atcapacity. - 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
evenmode and agridwith absent or zerojitterconsume no procedural samples, while agridwith non-zerojitterconsumes two per item and an addeddepthsub-block consumes one more, drawn last; adepthsub-block's three curves each place a known item at the documentedz; an index-driven placement on a continuousrateemission isERR_INVALID_DISTRIBUTION; apathdistribution naming a sibling key rather than carrying inlinecommandsisERR_INVALID_REFERENCE; an unknown distributiontypeisERR_INVALID_DISTRIBUTION_TYPE. - A repeater resolves
repeat.index,repeat.count, andrepeat.fractionper copy, samples achooseonce per copy in ascending index, and isERR_INVALID_REFERENCEwhenrepeat.*is used outside a repeater or when an external path names a copy. - Each of the seventeen behaviors advances a known object to a documented state after a fixed number of logical ticks; an unknown
typeisERR_INVALID_BEHAVIOR_TYPE; apropertyoutside the channel set, andsize.widthon anellipse, are eachERR_INVALID_BEHAVIOR_TARGET;point-wanderon arectangleisERR_INVALID_BEHAVIOR_TARGET;9behaviors on one object isERR_VISUAL_LIMIT_EXCEEDED. - Behavior composition follows array order: two
driftbehaviors accumulate onposition, twooscillatebehaviors ontransform.scale.xmultiply, and swapping the array order changes the result only where the rule says it should. morphbetween twosplineobjects of equalmodeand point count interpolates each point; differing point counts, differingtype, and differing splinemodeare eachERR_MORPH_INCOMPATIBLE, as are amorphonto arectangleand one between twopathobjects whose command sequences differ; amorphonto a target that is itself moving tracks the target's current points on each tick rather than a snapshot, and a mutualmorphpair isERR_CYCLIC_DEPENDENCY;tonaming a missing key isERR_INVALID_REFERENCE.- The noise function of 18.7 reproduces documented sample values to
1e-9at fixed lattice and non-lattice coordinates from a fixed seed, and the permutation shuffle consumes exactly255samples, leaving the documented stream position; two runs of one seed agree exactly; avalue-mode field withoutdirection, and adirectionundercurlorgradient, are each rejected with the documented code, as areoctavesof5,persistenceabove1, and ascaleof0; an unknown fieldtypeisERR_INVALID_FIELD_TYPE;9declared fields and5referenced fields are eachERR_VISUAL_LIMIT_EXCEEDED. curlmode is divergence-free to within1e-4 * amplitude / scaleover a sampled grid taken at non-lattice as well as lattice positions, so items following it do not accumulate at a point;gradientandvalueproduce 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.- 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
129samples isERR_VISUAL_LIMIT_EXCEEDED; a removed item's history is discarded in the same tick. - Link enumeration is deterministic and de-duplicated in ascending pair order under each of the three rules;
distancelinks exactly the pairs withinmaxDistance;nearestbreaks equidistant ties by ascending creation ordinal; theindexrule re-indexes the live ordering densely, so a death in the middle of a chain closes the gap rather than splitting it; exceedingmaxLinksdrops the tail of that order rather than an arbitrary subset;fadeWithDistance: truewithoutmaxDistanceisERR_SCHEMA_VALIDATION;linkson anemitterisERR_UNKNOWN_FIELD; adistancerule over a literal population above256isERR_VISUAL_LIMIT_EXCEEDEDat import and over a procedurally resolvedrepeater.countabove256at that repeater's instantiation boundary. - A binding,
set, oroverrideaddressing any property introduced in this section — a behavior field, a field strength, an emitterrate— isERR_UNSUPPORTED_TARGET, and the section 8.1 table remains unchanged by this slice.
User-observed, and not satisfiable by the above:
- 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.
- 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.
{
"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": <duration literal>, "value": ValueSpec<number> }, 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.<id>.*, camera.*, and effects[<index>].* properties in the registry below. |
visuals.systems.<id>.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": <integer >= 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.<id> |
opacity, parallax |
camera |
x, y, zoom, rotation, focalLength |
effects[<index>] |
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.<field> |
Number; camera x, y, zoom, rotation, focalLength (19.3) |
Yes | Yes | Yes | Yes | The per-field range of 19.3 |
visuals.layers.<id>.opacity |
Number; layer opacity (17.5) |
Yes | Yes | Yes | No | [0, 1] |
visuals.systems.<id>.visible |
Boolean; system visible (17.7) |
Yes | No | Yes | No | — |
visuals.effects[<index>].<param> |
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.<id>.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.<parameter-id>" } for a parameter declared in spawn.inputs, exactly as a component's content reads its own exposed parameters (18.1). The two scopes never overlap: a component's content sees the component's parameters, a template's body sees the template's, and a component instantiated inside a template sees only its own. inputs.* outside both is ERR_INVALID_REFERENCE, it is not a section 1.3 document namespace, and it grants no section 8.1 capability.
A spawned system is a template, not a drawn system. A visuals.systems.<id> 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 <system-id>#<spawn-ordinal> (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 |
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
removeon an instance already inRELEASING,FINISHED,DISPOSED, orFAILEDis 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 authoredremovecan reach the same instance in one tick. CREATED -> FINISHEDis 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 andRELEASINGis skipped.CREATED -> FAILEDandACTIVE -> FAILEDare construction and runtime failure. AFAILEDinstance's resources are reclaimed exactly as forDISPOSED, 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.
DISPOSEDandFAILEDare terminal, and an instance in either is not re-spawnable — a newspawncreates 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<number> | No | Scene center | — | Camera center in scene units. |
y |
ValueSpec<number> | No | Scene center | — | Camera center in scene units. |
zoom |
ValueSpec<number> | No | 1 |
0.01 to 100 |
Uniform scale about the projection center. |
rotation |
ValueSpec<number> | No | 0 |
— | Degrees, positive toward +y (17.2). |
projection |
enum | No | orthographic |
— | orthographic or perspective. Any other token is ERR_SCHEMA_VALIDATION. |
focalLength |
ValueSpec<number> | 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
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:
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):
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:
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
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": <enum>, "enabled": ValueSpec<boolean>, ...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<number>, 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:
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:
blurandbloommay 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 raisesWARN_VISUAL_APPROXIMATIONonce per effect instance, never once per frame, and the frame remains within one visually equivalent step of the exact result.grainis 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_APPROXIMATIONonce 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_CEILINGand the effect instance, paint instance, or object forWARN_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 asustaineddetail 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
64entries invisuals.systems, counting spawned templates once each. More isERR_VISUAL_LIMIT_EXCEEDED. - Expanded static visual objects: at most
16384across the whole document, counted after component expansion and repeater expansion. Agraphicsystem contributes its object tree; acomponentobject contributes its own expansion; arepeatercontributescounttimes the object count of itsrepeattree, using the literalcountwhere it is one and its1024bound where it is procedural; aparticlessystem oremittercontributes itsrenderoremittree once, because its live population is already bounded above. More isERR_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:
- Each automation
modeand 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. loop.moderepeatrestarts from the first point at the loop period andping-pongreverses; a finitecountofnends on the first point underping-pongand on the last underrepeat;infinitenever stops; a track with noloopholds its last value forever.- A
visuals.automationtrack'satis measured from activation and avisuals.systems.<id>.automationtrack's from that system's instantiation boundary: two spawns of one template4sapart produce identical curves offset by4s. - 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 — acolor, avisible, a fieldstrength, a behavior parameter — isERR_UNSUPPORTED_TARGET. - Two tracks on one expanded visual target are
ERR_AUTOMATION_CONFLICT; a behavior writing a channel a track targets on the same object isERR_AUTOMATION_CONFLICT; the same behavior on an object with no track on that channel passes and composes by the 18.6 rule. - The authoring bound counts declared records once per declaration:
129declared tracks or2049declared points isERR_VISUAL_LIMIT_EXCEEDEDat import, and128and2048pass 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_CEILINGunder 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. - 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
setoroverrideon any per-object, particle, emitter, behavior, or field property isERR_UNSUPPORTED_TARGET, and trace 19 of 18.10 still passes unchanged. - A
spawnobject on apersistentsystem isERR_UNKNOWN_FIELD, and so arerelease,ownership,inputs, andcancelWithScenarioat the top level of a system of either lifecycle, while a top-levellifetimepasses onparticlesandemitterand isERR_UNKNOWN_FIELDongraphicandrepeater;spawn.cancelWithScenario: falsewithoutspawn.ownership: "persistent"isERR_UNSUPPORTED_TARGET; aspawnedtemplate is not drawn at activation and draws only after a spawn. A spawnedparticlessystem with both a top-levellifetimeand aspawn.lifetimeresolves them independently: its particles expire on the first and its instance releases on the second, and a spawnedrepeaterwithspawn.lifetimeand no top-levellifetimeis legal. - Every permitted state transition of 19.2 is exercised and every forbidden one rejected; a
releaseof0msstill passes throughRELEASINGfor one tick; a secondremoveon aDISPOSEDinstance is a no-op. - Two spawns of one template resolve independently from
<system-id>#<spawn-ordinal>, and one seed reproduces both exactly across two runs and across two frame rates. - A scenario-owned spawned instance is released at scenario cleanup; a
persistent-owned instance withcancelWithScenario: falsesurvives it; a cleanup that exceeds the 10.2 deadline force-disposes withWARN_CLEANUP_FORCED. - A
65th live spawned instance is refused with a singleWARN_VISUAL_CEILING, no live instance is evicted, and the refusal is not a scenario failure. - 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.2translates by one fifth of the camera translation and is neither scaled nor rotated differently;parallax: 0is pinned. - Under
perspective, an object atzrenders at the documented factor about the projection center and an object atz <= -focalLengthis culled with no diagnostic; underorthographicthe same object's scale is unchanged while its sorting, parallax, and fog are not. - Each of the seven effects produces its documented change on a known frame; array order is honored (
bloombefore and aftercolor-adjustdiffer); a disabled effect costs no pass; atypeoutside the seven isERR_INVALID_EFFECT_TYPE; a fifth entry isERR_VISUAL_LIMIT_EXCEEDED. grainconsumes 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.- A
blurorbloomcomputed at reduced resolution raisesWARN_VISUAL_APPROXIMATIONonce per effect instance and not once per frame; an unavailable effect is skipped, not substituted. - 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_CEILINGfor that key;120consecutive shedding ticks add thesustaineddetail 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 withWARN_VISUAL_APPROXIMATION, while the capability warnings of 17.12's conic fallback and 19.4's reduced-resolutionblurstay once per instance. - 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:
- 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.
- The early combined GC6 benchmark of slice 4h at
1920 x 1080runs 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:
{
"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.<class>, 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.<id>.cadence)
A sound's optional cadence property configures its behavior within the cadence engine:
{
"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.<id>.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:
- Eligible Pool: Collect all declared sounds where
cadence.classmatches the firing class,usagecontains"automatic", and any declaredwhencondition evaluates totrue. - Cooldown Filter: Exclude any sound where
\text{currentLogicalTime} - \text{lastFiredTime} < \text{cooldown}. - 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. - Base Weights: Evaluate
weightfor each remaining sound usingrng.stream('cadence', '<class>:weight:<ordinal>'). Clamp to\ge 0. - Anti-Repetition Multipliers: For each remaining candidate, look up its position in the class's recency queue:
- Most recently selected (
1selection ago): multiplier0.0 2selections ago: multiplier0.253selections ago: multiplier0.504selections ago: multiplier0.75- Older / not in recency queue: multiplier
1\text{effectiveWeight} = \text{baseWeight} \times \text{multiplier}.
- Most recently selected (
- 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(\text{effectiveWeight} = \text{baseWeight}). - If the sum of base weights is still
0, the class firing is skipped with no sound played.
- Relax anti-repetition penalties: set all multipliers to
- Weighted Selection: Draw a pseudo-random value
u \in [0, 1)fromrng.stream('cadence', '<class>:select:<ordinal>')and pick the winning sound proportional to its effective weight. - Execution: Instantiate and play the winning sound via the audio subsystem.
- 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 depth4. - Reschedule: Sample the next interval uniformly from
[T_{\min}, T_{\max}]usingrng.stream('cadence', '<class>:interval:<ordinal>'), scale by1 / \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}(default1500ms). - 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
ambientsounds 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', '<soundId>:<ordinal>')). - 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.<id>) (PRD 90, 91)
The top-level events container defines reusable, named action bundles:
{
"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, optionalmin,max,step,values).actions: Required array of Action Model 0.1 action objects. Must contain at least one action.
Unrecognized properties in events.<id> or inputs.<id> 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:
{
"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 inevents. Undeclared target isERR_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 inwithproduceERR_UNKNOWN_FIELD. Omitted inputs take declared defaults; missing required inputs without defaults fail withERR_SCHEMA_VALIDATION.- Input Scoping: Within the invoked event actions, references matching
inputs.<name>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:
{
"type": "sound",
"sound": "relay-click",
"with": {
"pitch": 440
},
"ownership": "scenario"
}
sound: ID of the target declared insounds. Undeclared target isERR_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
usagemust include"manual". - Dispatched from a scenario: sound
usagemust include"scenario". - Dispatched from cadence: sound
usagemust include"automatic". Violations produceERR_UNSUPPORTED_TARGET.
- Dispatched from manual UI: sound
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
1024ordinary dispatch units is enforced per logical tick. - Entering an event consumes
1dispatch unit. - Attempting an action consumes
1dispatch unit (including actions skipped due towhenorchance). - When the
1025\text{th}unit would be consumed, the runtime emitsERR_DISPATCH_BUDGET, discards the remaining ordinary queue for that tick, and terminates any affected scenario owner asFAILED. - 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 incadence,sounds.<id>.cadence,events.<id>, or event actionwithmap.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 whoseusageexcludes the caller's context.ERR_TYPE_MISMATCH: Actionwithvalue type does not match declared event input type.ERR_CYCLIC_DEPENDENCY: Static event invocation dependency cycle detected.ERR_INVALID_RANGE_ORDER:clocks.<class>minduration exceedsmaxduration.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
- Schema & Validation: Valid and malformed
cadence,sounds.<id>.cadence, andeventsblocks validate or reject with documented error codes. - Static Cycle Detection: Direct self-invocation (
A \to A) and transitive cycles (A \to B \to C \to A) fail semantic validation withERR_CYCLIC_DEPENDENCY. Valid acyclic event hierarchies pass. - Event Execution & Scoping: Event action executes member actions in order, resolves
inputs.<name>from passedwithvalues, falls back to declared defaults, and isolates inputs across concurrent invocations. - Event Nesting & Depth Limit: Nested events pass inputs through multiple levels up to depth 16; depth 17 raises
ERR_DISPATCH_BUDGET. - Per-Tick Dispatch Budget: Ordinary actions and events exceeding
1024units in a single tick halt withERR_DISPATCH_BUDGETand discard remaining ordinary queue while preserving scenario termination cleanup. - Sound Action Execution: Sound actions validate sound references, pass inputs to recipes, check caller usage permissions, and reject unauthorized usage with
ERR_UNSUPPORTED_TARGET. - Cadence Eligibility & Cooldown: A sound with
when: falseor active cooldown (\text{elapsed} < \text{cooldown}) is excluded from selection. - Cadence Overlap Policy: A sound with
overlap: falseis excluded from selection while any voice of that sound is active in the audio engine; a sound withoverlap: trueadmits overlapping voices up to voice ceilings. - Cadence Anti-Repetition Multipliers: Consecutive firings verify multipliers
0,0.25,0.50,0.75,1against the class recency history. - Pool Relaxation: When all eligible sounds have effective weight zero, anti-repetition penalties relax to base weights and selection succeeds rather than permanently stalling.
- Cadence Intensity Scaling: Decreasing intensity increases interval spacing (
T / \text{intensity}); setting intensity to0stops one-shot class scheduling completely. - Minimum Automatic Gap & Priority: Simultaneous class firings enforce
minGap(default1500ms) between audio starts and service deferred classes in priority order (\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}). - Ambient Maintenance: Ambient sounds (
cadence.class: "ambient") auto-start on audio unlock and are maintained by the runtime. - Manual SAMPLE Isolation: Manual SAMPLE playback draws exclusively from the
manual-sampledomain and leaves cadence clocks, cooldowns, recency history, and automatic PRNG sequences byte-identical.
21. Scenario Model 0.1
This section completes PRD 92–102. Sections 9 and 10 remain authoritative for clock, ownership, dispatch and failure. Scenarios are finite declarative performances. Their director runs on the same fixed tick as cadence, after resolution and cadence and before visual advancement. It never installs a timer for a timeline entry. Rendering samples the current result and does not advance scenarios.
21.1 Definition and limits
scenarios is an ID-keyed object. Unknown fields in every object below are
ERR_UNKNOWN_FIELD; wrong shapes are ERR_SCHEMA_VALIDATION. IDs use section 1.
| Field | Type | Default / requirement |
|---|---|---|
name |
string | Optional display name; ID is the fallback |
enabled |
boolean | true; affects new starts, does not cancel an active instance |
priority |
integer | 50; range 0 through 100 |
group |
ID string | Optional; required with group exclusivity |
trigger |
trigger object | { "type": "manual" } |
eligibility |
object with optional when, timeout |
ConditionSpec and positive literal duration; timeout defaults to 5m |
concurrency |
object | Fields and defaults in section 21.3 |
cooldown |
literal duration | 0ms; measured from the last termination, including failure |
duration |
positive literal duration | Optional upper bound; without it completion follows the last beat |
onStart |
ActionSpec array | [] |
timeline |
timeline-entry array | Required; may be empty |
onComplete, onCancel |
restricted ActionSpec arrays | []; section 10.2 |
tags |
string array | Optional; at most 16 strings of at most 32 characters |
At most 64 definitions, 1024 entries per definition, and 1024 total occurrences of
one repeated entry are authorable; excess is ERR_OUT_OF_BOUNDS. The runtime admits
at most 16 active instances and at most one instance of each definition. Capacity is a
start conflict and obeys the requested conflict policy. These scenario limits extend
the centralized inventory of section 19.5 and remain provisional pending GC6.
Instance IDs are instances.scenario-<definition>-<ordinal>, with a monotonically
increasing ordinal per definition. The director retains only live instances, bounded
cleanup owners, and the last terminal status per definition. Completed histories are
not retained. The lifecycle follows PRD 93: definition/eligibility/scheduling,
STARTING, ACTIVE, COMPLETING or CANCELLING, then COMPLETED, CANCELLED or
FAILED. Exactly one termination hook runs. signals.scenario.active is true when
any scenario instance is starting or active.
21.2 Trigger shapes and sampling
| Type | Additional fields | Opportunity |
|---|---|---|
manual |
none | Start API or scenario control action |
once |
optional at (default 0ms) |
First tick at/after elapsed performance time |
interval |
required positive every |
After each interval, starting one interval after activation |
random-interval |
required positive min, max, min <= max |
Independently sample the first and each subsequent interval |
probability |
required positive every, required numeric chance in [0,1] |
One probability draw per interval |
condition |
required when, optional nonnegative for (default 0ms) |
Hold/rearm state machine in section 10.3 |
event |
required declared event ID |
Notification at event invocation, before its actions |
Triggers use performance logical time. The first true tick starts a condition's hold
timer; subsequent true ticks accumulate the hold. A true initial condition remains
disarmed until a false tick. Firing consumes the edge even if admission is deferred or
rejected. Disabled definitions still advance trigger clocks and rearming state; admission
rechecks enabled. Intervals shorter than a tick produce at most one opportunity per tick,
with no catch-up burst. Invalid durations use ERR_INVALID_DURATION, reversed ranges
use ERR_INVALID_RANGE_ORDER, missing references use ERR_INVALID_REFERENCE.
Each definition has one stream scenario / <id>:trigger:1. Each admitted instance
uses scenario / <id>:<ordinal> for its start actions, timeline timing, weighted branches,
repeat intervals, and nested action values, in execution order. Neither manual sampling
nor another scenario's actions consume that stream. Sound and visual instantiation use
their own domains. Trigger event input values are copied into a deferred request once;
they are not an authorable scenario input namespace in 0.1.
21.3 Admission, exclusivity, and controls
concurrency permits exactly mode (parallel or exclusive, required when the
object is present), scope (global default, or group), and policy (defer
default, reject, or replace). Parallel is the default mode. Exclusivity is symmetric:
either participant's global exclusive scope blocks overlap; group exclusive scope blocks
overlap with the same group. Same-definition overlap always conflicts.
replace succeeds only if the incoming priority is strictly greater than every
conflicting instance's priority. Check eligibility and capacity before cancelling any
victim. Equal/lower priority rejects. reject drops an inadmissible opportunity;
defer retains it under section 10.4. Failed eligibility or cooldown obeys the same
policy. Replacement hooks and cleanup precede the incoming start hook. Pending admission
sorts by descending priority, creation logical time, then definition document order.
Starts and beats consume one ordinary dispatch unit each in addition to their actions;
even empty-start feedback is bounded by section 10.5.
control actions for this subsystem have target: "scenarios.<id>" and command
equal to start, stop, enable or disable. Stop cancels an active instance and
removes the definition's deferred request. Enable/disable does not reset cooldown or
trigger state. Other scenario commands are ERR_UNSUPPORTED_TARGET. Global pause and
resume are runtime controls and preserve independent user and visibility pause reasons.
Events generated during ordinary dispatch may admit scenarios later in the same tick;
new scenarios get their own owner. Static validation rejects event/scenario invocation
and trigger feedback cycles with ERR_CYCLIC_DEPENDENCY, including indirect events and
timeline branches. This conservative check rejects cycles even if authored conditions
would make them unreachable. The runtime budget remains the second backstop.
21.4 Timeline, relative timing, repeats, and branches
An entry permits exactly id, at, after, delay, repeat, actions, choose.
Use exactly one of at (nonnegative literal duration from instance start) or after
(another entry's unique ID). delay belongs only to after, defaults to 0ms, and
accepts a nonnegative literal or { "random": { "min": "20s", "max": "50s" } }.
The anchor is the first occurrence's scheduled time, independent of its action outcomes
or repeat count. Forward anchors are legal; missing anchors and cycles are rejected.
After successful onStart, resolve first-occurrence timestamps by walking entries in
document order, resolving each anchor before its dependent and sampling each relative
delay once. An instance keeps one mutable scheduling record per entry rather than
expanding every repetition. Beats execute in non-decreasing scheduled time; ties use
scenario definition document order, then entry document order. Timing is quantized only
at dispatch: execute on the first tick at/after the scheduled timestamp. A skipped or
zero-weight branch still counts as an occurrence and anchors dependent entries.
repeat permits exactly required integer count (1–1024, including the first beat)
and required positive every (literal or bounded random TimeSpec). Sample the next
interval after each occurrence and add it to the preceding scheduled timestamp.
Choose exactly one of actions or nonempty choose. A branch permits required finite
nonnegative numeric weight, optional ConditionSpec when, and required actions.
At each occurrence evaluate branch conditions, discard ineligible/zero weights, draw
one weighted choice and run its actions. An empty eligible pool is a no-op.
An explicit duration keeps the instance active even after its timeline empties. Beats at the duration execute before completion; later beats are cancelled. Without duration, completion follows the last beat, including repeats. Empty undurated scenarios finish in their start tick. The application exposes the director's start/cancel/status controls; the final schema-generated presentation belongs to Phase 7.
21.5 Resource ownership and termination
Section 10 governs all termination causes. Indirect event resources retain the owner,
priority, usage authorization and stream of their caller. Sound ownership: "scenario"
means that concrete caller owner; persistent and the existing performance spelling
transfer to the performance root. A continuous sound requested before audio unlock is
retained as a bounded logical intent (at most 16), removed if its owner terminates, and
started when audio is available. Missed one-shots are not replayed. This does not claim
sample-accurate reconstruction of pre-unlock oscillator phase.
Termination removes future timeline records before running the hook, continues past
hook failures, and transfers both scenario-scope and duration overrides and live audio
to a cleanup owner. Visual cleanup honors the independent originating-scenario relation
of section 19.2. Release durations and cleanup retention are capped at five logical
seconds. Counters include releasing audio pools, not only the actively playing set.
Performance teardown runs hooks before disposing audio/visual engines, and force-clears
cleanup immediately. No persistent set is rolled back. Persistent resources remain
until performance teardown or explicit removal.
21.6 Required examples and verification traces
Minimal five-second scenario:
{"xzbt":"0.1","meta":{"id":"five-second","name":"Five seconds"},"scenarios":{"brief":{"trigger":{"type":"once"},"duration":"5s","timeline":[]}}}
The composition example is exhibits/exhibit-e.xzbt: a 40-minute sequence with random
trigger timing, random relative delays, repeated beats, weighted branching, persistent
state, cadence interaction, continuous sound, visual spawning, override and recovery.
Exhibits A and B add temporary parameter overrides. exhibits/scenario-challenge.xzbt
provides short, multi-minute, condition, event, parallel, exclusive and deferred cases.
Invalid examples: priority: 101 → ERR_OUT_OF_BOUNDS; { "at": "1s", "after": "a", "actions": [] } → ERR_SCHEMA_VALIDATION; after: "missing" →
ERR_INVALID_REFERENCE; reciprocal after anchors → ERR_CYCLIC_DEPENDENCY;
onCancel: [{ "type": "spawn", "target": "visuals.systems.burst" }] →
ERR_UNSUPPORTED_TARGET; onFailure: [] → ERR_UNKNOWN_FIELD.
- Five-second and multi-minute duration endpoints, including a beat exactly at the endpoint.
- Equal-time order, forward relative references, random delays and lazy finite repeats.
- Weighted branching, excluded branches, persistent mutations and unchanged manual PRNG stream.
- Once, interval, probability, random interval, manual, condition and event triggers.
- Startup-true condition disarming, hold cancellation and false-tick rearming (GC5).
- Deferred deduplication, retained expiry, eligibility/cooldown recheck, and priority ordering (GC5).
- Symmetric global/group exclusivity, parallel starts, higher-priority replacement and capacity.
- Nested event ownership using production visual and audio instances and override counters (GC5).
- Completion, cancellation, critical startup/action failure and hook failure preserve cleanup and prior set (GC5).
- Duration overrides release at owner termination, masked user edits survive, and five-second cleanup is bounded (GC5).
- Ordinary dispatch/depth failures preserve the independent 256-action termination budget (GC5).
- Repeated completion/cancellation/failure cycles return resource and scheduler counters to baseline (GC5).
- Forty-minute Exhibit E runs accelerated through the same fixed-step production tick; equal seeds give equal traces.
- Pause/resume preserves logical time; disposal leaves no owned resources or subscriptions.
- Two real hours on a frozen combined workload, with GC6 environment, frame intervals, resource/memory trend, audio peak/nonfinite measurements and listening observations. Accelerated traces cannot satisfy this gate.