diff --git a/docs/XZBT_0-1_Format_Specification.md b/docs/XZBT_0-1_Format_Specification.md index a8978e3..4b31dba 100644 --- a/docs/XZBT_0-1_Format_Specification.md +++ b/docs/XZBT_0-1_Format_Specification.md @@ -1,8 +1,8 @@ # XZBT Format Specification 0.1 **XZBT format version:** 0.1 -**Document revision:** 0.7 -**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, and the remaining subsystem contracts are in progress +**Document revision:** 0.8 +**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; revision 0.8 applies the §§17-19 multi-model review triage in full, resolving thirty repair packages and three additional defects across the visual contract before renderer implementation begins; and the remaining subsystem contracts are in progress **Related resources:** [PRD](../XZBT_0-1_MVP_Product_Requirements_Document.md), [decisions](XZBT_0-1_Gap_Closure_Decisions.md), [verification](XZBT_0-1_Verification_Gates.md) This document defines normative syntax and runtime semantics for XZBT 0.1 exhibits. An exhibit is a UTF-8 JSON document that configures generic procedural visual, audio, cadence, and orchestration primitives. It does not contain executable JavaScript. @@ -295,9 +295,9 @@ To ensure consistent error reporting between structural schema validation, seman | `WARN_AUDIO_NONFINITE` | Runtime | A non-finite sample reached the master chain and the containing block was muted. | | `WARN_AUDIO_UNAVAILABLE` | Runtime | No audio device is available; the performance continues silently. | | `ERR_INVALID_SYSTEM_TYPE` | Semantic | A `visuals.systems.` `type` is not a member of the Visual System Set 0.1. | -| `ERR_INVALID_PRIMITIVE_TYPE` | Semantic | A visual object `type` is not a member of Visual Primitive 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, particle capacity, emitter capacity, repeater count, behaviors per object, declared or referenced fields, trail length, link count, automation tracks and points, post-effect entries). | +| `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. | @@ -1544,7 +1544,7 @@ XZBT never implements a primitive, field, or system named after a specific exhib | 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. | +| 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. | @@ -1578,9 +1578,12 @@ Angles are degrees throughout the visual contract. The audio contract's `phase` | `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 | @@ -1588,7 +1591,7 @@ Per the strict unknown-field policy (section 1), any other property of `visuals` | `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`. | +| `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). | @@ -1597,7 +1600,7 @@ Per the strict unknown-field policy (section 1), any other property of `visuals` The three coordinate spaces: * **`normalized`** — the scene is the unit square. `x` and `y` run `0` to `1` from the top-left. The square is mapped into the display surface under `fit`. -* **`viewport`** — scene units are CSS pixels of the display surface, origin at its top-left. The scene has no intrinsic size, so `fit` is ignored and a `fit` other than `stretch` is `ERR_SCHEMA_VALIDATION`. Content is responsible for its own responsiveness; the runtime does not scale it. +* **`viewport`** — scene units are CSS pixels of the display surface, origin at its top-left. The scene has no intrinsic size, so there is nothing to fit: an **absent** `fit` is accepted and has no mapping effect, and an **explicitly authored** `fit` other than `stretch` is `ERR_SCHEMA_VALIDATION`. The `contain` default of the `fit` row is not taken in this space, so absent and `stretch` behave identically and neither scales anything. Content is responsible for its own responsiveness; the runtime does not scale it. * **`virtual`** — the scene is a fixed `width` x `height` design space with the origin at its top-left, mapped into the display surface under `fit`. This is the recommended space for reproducible composition, and the one the reference exhibits use. `fit` resolves the scene rectangle against the display rectangle: `contain` scales uniformly until the scene fits entirely inside, letterboxing the remainder with `background`; `cover` scales uniformly until the scene covers the display, cropping the overflow; `stretch` scales each axis independently, changing the aspect ratio. Coordinates outside the scene rectangle are legal and are simply outside the visible area under `contain` and `stretch`. @@ -1615,7 +1618,9 @@ Layers are the compositing groups of PRD 69. Each `visuals.layers.` is | `visible` | ValueSpec\ | No | `true` | An invisible layer is composited out entirely and its systems still advance. | | `parallax` | number | No | `1` | Multiplier on camera translation for this layer (17.6, 19.3). `0` pins the layer to the display. | -Layers composite in document key order, back to front. A system's `layer` must name a declared layer or `ERR_INVALID_REFERENCE`. When `visuals.layers` is absent, one implicit layer with every default applies and a system's `layer` field is `ERR_INVALID_REFERENCE`. +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. @@ -1625,9 +1630,28 @@ XZBT 0.1 supports 2.5D depth through an optional `z` on any visual object (PRD 7 **Sign.** Increasing `z` is farther from the camera. `z` defaults to `0`. -**Depth sorting.** Within one layer, objects are drawn farthest first: sort by effective `z` descending, breaking ties by document key order. The sort is stable, so equal-`z` objects always draw in document order — the same document-order rule sections 14.4 and 9.3 already use for sampling. +**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`: -**Perspective scaling.** Under the `perspective` camera projection (19.3), an object's transform is post-multiplied by the uniform factor `focalLength / (focalLength + z)` about the camera's projection center. `z <= -focalLength` places an object at or behind the eye; such an object is culled for that frame and raises no diagnostic. Under `orthographic`, `z` affects sorting, parallax, and fog but never scale. +```text +zEffective(object) = sum over the object and its ancestors of ( z + transform.translate.z ) +``` + +This one quantity drives depth sorting, perspective, parallax participation, fog, and eye culling. Nothing else contributes to it: `position` carries no `z`, and a point's own `z` (17.9) is not part of it. + +**Depth sorting, and what a sortable unit is.** Within one layer the frame is drawn farthest first: sortable units are ordered by representative depth descending, and the sort is stable, so equal depths always draw in document order — the same document-order rule sections 14.4 and 9.3 already use for sampling. A **sortable unit** is one of exactly two things: + +| Unit | Representative depth | Internal order | +| --- | --- | --- | +| A top-level visual object of a `graphic` system's `content` (17.8) — including a `group` or `component` and its whole subtree | Its own `zEffective` | Its descendants sort among themselves by the same rule, within the unit | +| An entire `particles`, `emitter`, or `repeater` system (18.2, 18.4, 18.5) | The **lowest** `zEffective` among its live items — the nearest one | Its items sort among themselves by `zEffective` descending, then by creation ordinal ascending | + +A procedural system is therefore **atomic**: it never interleaves item-by-item with objects of another system. Interleaving deep and near content is what layers (17.5) are for. The rule 18.2 states for particles is the general rule, and emitters and repeaters follow it unchanged rather than each restating it. + +Ties are broken in this order: greater representative depth first; then by the owning system's key order in `visuals.systems`; then, within one `graphic` system, by object key order in `content`; then, within one procedural system, by creation ordinal ascending. A procedural system with no live items draws nothing and its place in the order is unobservable; for the tie-break it is treated as having representative depth `0`. + +**Objects whose points carry `z`.** A `points` entry's optional `z` (17.9) does **not** make that point a sortable unit. It has exactly one effect: under `perspective`, each point is projected with its own factor computed from `zEffective(object) + point.z`, so a polygon whose corners carry different `z` foreshortens as one piece of geometry rather than snapping between two flat depths. Sorting and depth fog use the object's `zEffective` alone, with the point offsets ignored, which is what keeps fog exact and per object (below). Under `orthographic` a point's `z` has no effect at all. + +**Perspective scaling.** Under the `perspective` camera projection (19.3), an object receives the uniform factor `focalLength / (focalLength + zEffective)` about the projection center, applied as one stage of the normative composition chain of 19.3 — that subsection, not this sentence, fixes where in the chain it sits and in which coordinate space. The factor scales the object's geometry and, with it, every scene-unit appearance dimension: `strokeWidth`, `strokeDash` lengths, `pointSize`, `blur`, `glow.radius`, and `shadow` offsets and `blurRadius` all shrink with a receding object, which is what makes a receding object look receding rather than look near and small. `zEffective <= -focalLength` places an object at or behind the eye; such an object is culled for that frame and raises no diagnostic. Under `orthographic`, `zEffective` affects sorting, parallax, and fog but never scale. **Parallax.** A layer's `parallax` multiplies the camera's translation before it is applied to that layer, giving the deep parallax field of PRD 130 item 1 without any subject-specific renderer. @@ -1640,7 +1664,17 @@ XZBT 0.1 supports 2.5D depth through an optional `z` on any visual object (PRD 7 | `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 `density * clamp((z - near) / (far - near), 0, 1)`, and its resolved fill, stroke, glow, and shadow colors are blended toward `color` by that fraction before drawing. Fog applies per object, not per pixel, so it is exact and renderer-independent; it is not an approximation and raises no diagnostic. +The fog fraction for an object is `f = density * clamp((zEffective - near) / (far - near), 0, 1)`, and its resolved colors are blended toward `color` by that fraction before drawing. + +**Fog is exact, and its arithmetic is fixed.** Blending is per component in **non-premultiplied sRGB**, on the `0`-to-`1` component scale, with no linearization step: + +```text +rgb' = rgb + (fogColor.rgb - rgb) * f +``` + +Alpha is never fogged: the object's own alpha survives unchanged, and the `color` field's alpha, if it carries one, is ignored. The colors fogged are the object's resolved `style.fill`, `style.stroke`, `glow.color`, and `shadow.color`. Where `fill` or `stroke` is a paint object, **each stop's color is fogged individually before the paint is constructed**, so a fogged gradient keeps its stop offsets and its shape and loses only its contrast — constructing the paint first and fogging the rasterized result would be a per-pixel operation and would not be renderer-independent. Post-effect `color` parameters (19.4) are not fogged; the effect chain runs after compositing and has no object depth. + +Fog applies per object, not per pixel, so it is exact and renderer-independent; it is not an approximation and raises no diagnostic. ### 17.7 Visual system objects (`visuals.systems.`) @@ -1649,13 +1683,16 @@ A **visual system** is the addressable unit of the visual subsystem and the reso | 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 | No | implicit layer | A declared layer ID (17.5). | +| `layer` | string | Conditional | implicit layer | A declared layer ID (17.5). Required when `visuals.layers` is present; omitted there it is `ERR_INVALID_REFERENCE`. | | `visible` | ValueSpec\ | No | `true` | A hidden system is not drawn; its behaviors and automation still advance. It is an external target by the section 8.1 rows 19.1 adds. | | `lifecycle` | string enum | No | `persistent` | `persistent` or `spawned` (19.2). A `spawned` system is a template: declared and validated at import, instantiated only by a `spawn` action. | | `automation` | array | No | `[]` | System-scoped automation tracks (19.1), with `at` measured from this system's instantiation boundary. | +| `spawn` | object | Conditional | — | Spawned-instance lifecycle configuration (19.2). Legal only when `lifecycle` is `spawned`. | | *(type-specific fields)* | — | Per type | — | 17.8 for `graphic`; 18.2-18.5 for the others. | -Lifecycle fields (`lifetime`, `release`, `ownership`, `inputs`, `cancelWithScenario`) are legal only when `lifecycle` is `spawned` and are specified in 19.2; on a `persistent` system each is `ERR_UNKNOWN_FIELD`. A system declared in `visuals.systems` without a `lifecycle` field is persistent. +**Lifecycle configuration lives in its own container.** Every spawned-instance lifecycle field — `lifetime`, `release`, `ownership`, `inputs`, and `cancelWithScenario` — is a field of the `spawn` object and is specified in 19.2. None of the five is a top-level system field under any lifecycle, and the container exists precisely so that they cannot collide with the type-specific fields that already carry two of those names: a `particles` system's top-level `lifetime` is the lifetime of one *particle* (18.2) and an `emitter`'s is the lifetime of one *emitted item* (18.4), while `spawn.lifetime` is the lifetime of the *system instance*; likewise an `emitter`'s or `repeater`'s component `inputs` (18.1) configure one created item, while `spawn.inputs` declares the parameters of the template itself. A spawned emitter therefore expresses instance duration and item duration independently, and a spawned `repeater` — which has no per-item lifetime at all (18.5) — still takes `spawn.lifetime` without contradiction. + +A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names appearing at the top level of a system of either lifecycle. A system declared in `visuals.systems` without a `lifecycle` field is persistent. ### 17.8 The `graphic` system @@ -1689,8 +1726,8 @@ Every value in a `content` or `children` container is a **visual object**: a `ty | `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. A sweep whose magnitude exceeds `360` degrees is `ERR_OUT_OF_BOUNDS`. | -| `ring` | `radius`, `innerRadius`, `startAngle`, `endAngle`, `direction` | An annulus or annular sector. `innerRadius` must be less than `radius` or `ERR_INVALID_RANGE_ORDER`. Angles default to a full ring. Fill and stroke. | +| `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. | @@ -1703,6 +1740,36 @@ Visual Primitive Set 0.1 is exactly these fourteen geometry primitives and is cl `points` entries are `{ "x": number, "y": number, "z"?: number }`. A point's `z` offsets the object's `z` for depth purposes but does not sort points independently; an object is sorted and drawn as one unit. +**Local geometry, extents, and anchors.** Every primitive's geometry is expressed in the object's **local space**, whose origin is the object's `position` (17.10) in its parent's space. This table fixes where each primitive sits relative to that origin, so that a transform `origin`, a clip rectangle, and a gradient axis all have one unambiguous frame. Geometry field requiredness and defaults are fixed here rather than inferred from examples. + +| `type` | Geometry fields, requiredness, defaults | Local extent relative to the origin | +| --- | --- | --- | +| `point` | — | The origin itself; zero extent. The drawn disc has diameter `style.pointSize` centered on it. | +| `line` | `to` required | Origin to `to`. | +| `polyline` | `points` required, `2` to `512` | The points as written. | +| `polygon` | `points` required, `3` to `512` | The points as written, implicitly closed. | +| `rectangle` | `size` required | **Top-left corner at the origin**, extending to `(width, height)`. This matches the top-left origin of every coordinate space (17.4) and the corner-anchored rectangle of `clip` (17.12). | +| `rounded-rectangle` | `size` required; `radius` required | As `rectangle`. Each corner radius clamps to half the shorter side. | +| `ellipse` | `radius` required | **Centered on the origin**, half-extent `radius.x` by `radius.y`. | +| `arc` | `radius` required; `startAngle` default `0`; `endAngle` default `0`; `direction` default `clockwise` | Centered on the origin. | +| `ring` | `radius` required; `innerRadius` default `0`; `startAngle` default `0`; `endAngle` default `360`; `direction` default `clockwise` | Centered on the origin. The angle defaults are what make an omitted pair a full ring. | +| `path` | `commands` required, `1` to `512`; `fillRule` default `nonzero` | The commands' own coordinates, which are local coordinates; the first `move` is not required to be the origin. | +| `bezier` | `c1`, `c2`, `to` all required | Origin to `to` through the two control points. | +| `spline` | `points` required, `2` to `256`; `mode` default `catmull-rom`; `closed` default `false`; `tension` default `0.5`; `fillRule` default `nonzero` | The points as written. | +| `text` | `text` required; every other field defaults per 17.13 | The origin is the anchor selected by `align` and `baseline`. | +| `group` | `children` required, non-empty | The union of its children's extents. | + +A primitive whose required geometry field is absent is `ERR_SCHEMA_VALIDATION`; a geometry field with no listed default has no default and must be authored. + +**Angular sweep is directed, and its bound is checked before normalization.** For `arc` and `ring`, let + +```text +d = endAngle - startAngle when direction is clockwise +d = startAngle - endAngle when direction is counter-clockwise +``` + +`|d| > 360` is `ERR_OUT_OF_BOUNDS`, and that check runs on the authored numbers **before** any normalization, so `startAngle: 0, endAngle: 720` is rejected rather than silently folded to a full turn. Otherwise the drawn sweep is `|d|` degrees from `startAngle` in `direction`, with two boundary cases stated rather than left to a modulo: `|d| == 360` draws a complete turn, and `d == 0` draws nothing and raises no diagnostic. Wrapping across `0` needs no special case — `startAngle: 350, endAngle: 10` clockwise is a `20`-degree sweep — and a negative `d` under `clockwise` is the same sweep taken the long way round, which is why the direction, not the sign, decides. + **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 @@ -1763,15 +1830,15 @@ A renderer must not fold the order differently even where the result would coinc | Field | Type | Default | Notes | | --- | --- | --- | --- | -| `fill` | `color`, paint object, or `null` | `null` | `null` draws no fill. | -| `stroke` | `color`, paint object, or `null` | `null` | `null` draws no stroke. | +| `fill` | ValueSpec\, paint object, or `null` | `null` | `null` draws no fill. | +| `stroke` | ValueSpec\, paint object, or `null` | `null` | `null` draws no stroke. | | `strokeWidth` | ValueSpec\ | `1` | Scene units. `0` draws no stroke. | | `strokeCap` | enum | `butt` | `butt`, `round`, `square`. | | `strokeJoin` | enum | `miter` | `miter`, `round`, `bevel`. | | `strokeDash` | array of numbers | — | Alternating on/off lengths in scene units, `1` to `8` entries. | | `strokeDashOffset` | ValueSpec\ | `0` | Scene units. | | `pointSize` | ValueSpec\ | `1` | Diameter of a `point`, in scene units. | -| `opacity` | ValueSpec\ | `1` | `0` to `1`. Multiplies with any inherited group opacity and with layer opacity. | +| `opacity` | ValueSpec\ | `1` | `0` to `1`. Multiplies with any inherited group opacity and with the owning instance's release factor (19.2). Layer `opacity` is **not** a second per-object multiplication; see the compositing order below. | | `blend` | enum | `normal` | Safe blend set below. | | `glow` | object or `null` | `null` | `{ "color": color, "radius": number, "strength": number }`. `strength` is `0` to `1`. | | `shadow` | object or `null` | `null` | `{ "color": color, "blurRadius": number, "offsetX": number, "offsetY": number }`. | @@ -1780,6 +1847,12 @@ A renderer must not fold the order differently even where the result would coinc | `clip` | object or `null` | `null` | Clip region; see below. | | `mask` | string or `null` | `null` | `group` only; see below. | +**Color leaves and ValueSpecs.** A `color`-typed leaf in this table is a `ValueSpec` (section 4): a literal, a `ref`, a `choose` over color literals, or a `random` over none — `random` has no color form and is `ERR_TYPE_MISMATCH` on a color leaf. The color leaves that accept a ValueSpec are exactly `fill`, `stroke`, `glow.color`, `shadow.color`, and each entry's `color` inside a paint object's `stops`. This is what makes the component example of 18.1 legal: `{ "fill": { "ref": "inputs.tint" } }` reads the instance's exposed color parameter. + +Three limits keep that from widening the contract. A `ValueSpec` resolves to a **color**, never to a paint object: a gradient is authored structurally or not at all, and a ValueSpec that resolved to an object is `ERR_TYPE_MISMATCH`. The `type`, `stops[].offset`, `from`/`to`, `center`, `radius`, `innerRadius`, and `angle` fields of a paint object are authored literals, not ValueSpecs, so a paint's *shape* is fixed at import while its *colors* may be resolved. And system-level colors outside this table — `visuals.scene.background`, `visuals.scene.depthFog.color`, and every `color` parameter of a post-effect (19.4) — are literals, matching 19.4's statement that effect `color` parameters are authored once. + +Every color ValueSpec resolves once at its owning object's instantiation boundary, in the same depth-first document order as every other field (17.14), and is constant for that object's life. Resolving a color consumes procedural samples exactly as resolving a number does, and it consumes none per frame. + **Safe blend set (PRD 75).** `normal`, `add`, `screen`, `multiply`, `overlay`, `lighten`, `darken`, `difference`. Any other token is `ERR_SCHEMA_VALIDATION`. This is the set every target renderer supports identically; no other blend mode is authorable in 0.1. **Paint objects.** A `fill` or `stroke` may be a gradient instead of a flat `color`: @@ -1794,7 +1867,16 @@ A renderer must not fold the order differently even where the result would coinc 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 along the axis from `center` at `angle` to the paint's bounding-box edge, 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. +**Conic gradient fallback.** PRD 75 makes conic gradients conditional on renderer support. Where the active renderer has no conic gradient, the runtime substitutes a `linear-gradient` with the **same stops at the same offsets** along a documented axis, and raises `WARN_VISUAL_APPROXIMATION` once per paint instance, not once per frame. This is the only appearance fallback in 0.1; every other field in this table is exact on every target renderer. + +The fallback axis is fixed so that two conforming renderers substitute the same gradient: + +* The reference box is the **local-space axis-aligned bounding box of the owning object's geometry**, taken before `transform` and before any camera stage, using the extents of the primitive's row above. +* The axis runs from `center` in the direction `(cos angle, sin angle)` (17.2) to the first intersection with that box's boundary. +* **`center` outside the box, or a ray that never meets it**, is not an error and needs no special renderer behavior: the axis then runs from `center` to `center + d * (cos angle, sin angle)`, where `d` is the distance from `center` to the farthest corner of the box. This reduces to the intersection case whenever the intersection exists, so a renderer may implement only this form. +* A **degenerate box** — zero width and zero height, as on a `point` — has no axis. The fallback then paints the first stop's color flat and still raises the warning once, because a flat fill is a materially milder substitution than omitting the paint. + +Nothing in this fallback re-orders, re-offsets, or re-colors the stops. Substituting a *different* set of stops would be the "materially different" behavior PRD 89 forbids, and the point of fixing the axis is to make the approximation reproducible rather than to make it good. **Filters.** Each entry is `{ "type": enum, "amount": ValueSpec }` with `type` in `brightness`, `contrast`, `saturate`, `hue-rotate`, `grayscale`, `sepia`, `invert`. `amount` is `0` to `4` for `brightness`, `contrast`, and `saturate`; `0` to `1` for `grayscale`, `sepia`, and `invert`; and degrees for `hue-rotate`. Filters apply in array order, after the object is drawn and before it composites into its layer. More than `4` entries is `ERR_VISUAL_LIMIT_EXCEEDED`. @@ -1802,7 +1884,50 @@ Stop `offset` values are `0` to `1` and must be strictly increasing; a non-incre **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`. -**Cost.** A non-default `blend`, a `mask`, a `blur`, a `glow`, or any `filters` entry requires an offscreen buffer for the object or group. Those buffers count against the pass budget fixed in 19.5; this section declares the cost, and section 19 sets the ceiling. +**Compositing order is normative.** An object's contribution to its parent is computed in this order, and a renderer must not fold two stages together where the result would differ: + +```text +1 shadow drawn behind the geometry, at shadow.offsetX/offsetY, blurred by shadow.blurRadius +2 geometry fill, then stroke, in the object's own resolved (and fogged, 17.6) colors +3 glow added around the result at glow.radius, scaled by glow.strength +4 blur style.blur applied over stages 1-3 +5 filters array order (17.12) +6 alpha multiply by the effective alpha a, defined below +7 clip and mask intersect with any inherited clip; multiply by the group mask's rendered alpha +8 blend composite into the parent under style.blend +``` + +The **effective alpha** at stage 6 is + +```text +a = style.opacity (own, resolved) + x the product of style.opacity over every ancestor group and component + x the release factor of the owning system instance (19.2) +``` + +and nothing else. In particular **layer `opacity` is not in it.** A layer's `opacity` (17.5) applies exactly once, to the composited layer, at stage 9 below; multiplying it in per object as well would darken overlapping children twice and would make `layers..opacity` behave differently for a single object than for two that overlap. The **release factor** is a separate multiplier owned by the system instance and initialized to `1`; it is untouched for the instance's whole life until release begins, so releasing never rewrites an authored `opacity` and never pops a partially transparent object back to opaque (19.2). + +A layer is then composited as: + +```text +9 layer draw its sortable units in the order of 17.6, multiply by the layer's opacity, + composite into the frame under the layer's blend, in layer document key order +``` + +**Buffers, and what happens when they run out.** A buffer is an offscreen surface the renderer must allocate to compute a stage correctly. Two kinds compete for one pool: + +| Allocation | Cause | +| --- | --- | +| Layer buffer | A layer whose `opacity` or `blend` is not the default (17.5) | +| Object buffer | An object or group with a non-default `blend`, a `mask`, a `blur`, a `glow`, or any `filters` entry | + +The pool holds `16` concurrently live buffers (19.5). A buffer is returned to the pool as soon as its owner composites into its parent, so what the ceiling bounds is the number live **at one moment** — the nesting depth of buffered groups plus the buffered layers still open — not the number allocated over a frame. Allocation order is fixed: layer buffers first, in layer document key order; then object buffers in draw order (17.6). + +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. @@ -1838,7 +1963,24 @@ Path legality: | `tension` | ValueSpec\ | No | `0.5` | `0` to `1`. `catmull-rom` only; present under another mode is `ERR_UNKNOWN_FIELD`. | | `fillRule` | enum | No | `nonzero` | Applies only when the spline is `closed` and has a fill. | -In `bezier` mode the points are read as `p0, c1, c2, p1, c1, c2, p2, ...`, so the entry count must satisfy `count = 3n + 1` for an integer `n >= 1`; any other count is `ERR_SCHEMA_VALIDATION`. In `linear` mode the spline is a polyline over its points, differing from `polyline` only in that its points are individually addressable. In `catmull-rom` mode the curve passes through every point; for an open spline the first and last points are duplicated as the phantom endpoints. +In `bezier` mode the points are read as `p0, c1, c2, p1, c1, c2, p2, ...`, so the entry count must satisfy `count = 3n + 1` for an integer `n >= 1`; any other count is `ERR_SCHEMA_VALIDATION`. **A `closed` `bezier` spline closes with a straight line** from the last on-curve point back to the first: the point list carries no control points for a closing segment, and inventing two would be authoring geometry the author did not write. In `linear` mode the spline is a polyline over its points, differing from `polyline` only in that its points are individually addressable, and `closed` joins last to first with a straight segment. + +**`catmull-rom` is a uniform cardinal spline, and its equation is normative.** For the segment between `p1` and `p2`, with neighbours `p0` and `p3`, parameter `u` from `0` to `1`, and `tension` `t`: + +```text +m1 = t * (p2 - p0) +m2 = t * (p3 - p1) +q(u) = (2u^3 - 3u^2 + 1) * p1 + + (u^3 - 2u^2 + u) * m1 + + (-2u^3 + 3u^2) * p2 + + (u^3 - u^2) * m2 +``` + +Parameterization is **uniform**, not chordal or centripetal: `u` advances at the same rate on every segment regardless of its length. `tension` `0.5` is the classical Catmull-Rom, `0` gives straight segments between the points, and `1` gives the widest overshoot; the curve passes through every point at every tension. Index selection at the ends is fixed too: for an **open** spline the first and last points are duplicated as the phantom neighbours (`p0 = points[0]` for the first segment, `p3 = points[n-1]` for the last), and for a **closed** spline every index wraps modulo `n`, so a closed spline has `n` segments and an open one has `n - 1`. + +**Open geometry that carries a fill.** A `path` with more than one subpath, an open `path`, and an open `spline` may all declare a `fill`. Each open subpath is closed for the fill computation only, with a straight segment from its last point to its own `move` point; the stroke is drawn open, exactly as authored. `fillRule` then applies over the closed subpaths. This is a fill rule, not a geometry change: nothing about the stroked outline moves. + +**A `point`'s stroke.** A `point` draws a filled disc of diameter `style.pointSize` centered on its origin, painted with `fill`. A declared `stroke` outlines that disc with `strokeWidth`, centered on the disc's edge as every other stroke is. A `point` with `fill: null` and a `stroke` draws a ring; a `point` with both `null` draws nothing and raises no diagnostic. **Independently mutable points.** PRD 76 requires spline points to be independently mutable so that slow point wandering and morphing can build evolving organic geometry. This contract fixes what that means: a spline's point *count* is fixed at instantiation (17.14) and its point *indices* are stable for the object's lifetime, so index `i` always denotes the same point. Individual coordinates are addressable as `points[].x`, `.y`, and `.z` by the behaviors of 18.6 and by the visual automation of 19.1. More than `256` points is `ERR_VISUAL_LIMIT_EXCEEDED`. Morphing between two splines requires equal point counts and equal `mode`; section 18.6 fixes that rule and its diagnostic. @@ -1854,7 +1996,11 @@ In `bezier` mode the points are read as `p0, c1, c2, p1, c1, c2, p2, ...`, so th | `align` | enum | No | `left` | `left`, `center`, `right`, relative to the object's origin. | | `baseline` | enum | No | `alphabetic` | `top`, `middle`, `alphabetic`, `bottom`. | | `letterSpacing` | ValueSpec\ | No | `0` | Scene units. | -| `maxWidth` | ValueSpec\ | No | — | When set, the rendered run is condensed horizontally to fit; it is never wrapped or truncated. | +| `maxWidth` | ValueSpec\ | No | — | When set, the rendered run is condensed horizontally to fit; it is never wrapped or truncated. Condensation is fixed below. | + +**`maxWidth` condensation.** Let `w` be the run's measured advance width in scene units, after `letterSpacing` and before any transform. When `maxWidth` is absent, or when `w <= maxWidth`, the run is drawn unmodified. Otherwise the run is scaled horizontally by `maxWidth / w` about the anchor selected by `align`, with the vertical scale unchanged; glyph shapes condense, the baseline does not move, and no character is dropped or wrapped. `maxWidth <= 0` is `ERR_OUT_OF_BOUNDS`. + +Because `w` comes from the viewer's own generic font, the condensation factor is a device-dependent number. That is not a reproducibility defect: section 9.3 promises identical procedural *decisions*, never identical pixels, and text metrics feed no decision — no ValueSpec reads them, no behavior branches on them, and no procedural sample is consumed by measuring. This subsection needs no new exemption for it, and 19.4's `grain` remains the only stated exemption in the visual contract. `font` is restricted to the three generic CSS families because section 13.3 requires the standalone artifact to carry every asset it needs and make no external request. A named family would resolve differently on each viewer's machine and could not be embedded without shipping a font file; 0.1 does not ship one. @@ -1862,11 +2008,21 @@ In `bezier` mode the points are read as `p0, c1, c2, p1, c1, c2, p2, ...`, so th ### 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, and is constant for that object's lifetime. 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. +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. 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). -**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, because no visual construct in 0.1 generates per-sample output outside the seeded streams. +**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: @@ -1885,7 +2041,7 @@ Added to the section 7 table, which remains the single authoritative list: | Error Code | Stage | Cause | | :--- | :--- | :--- | | `ERR_INVALID_SYSTEM_TYPE` | Semantic | A `visuals.systems.` `type` is not a member of the Visual System Set 0.1. | -| `ERR_INVALID_PRIMITIVE_TYPE` | Semantic | A visual object `type` is not a member of Visual Primitive 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. | @@ -1902,12 +2058,12 @@ Automated, and executable without a display measurement: 6. Perspective scaling matches `focalLength / (focalLength + z)` at three depths; an object at `z = -focalLength` is culled without a diagnostic; under `orthographic` the same object is unscaled. 7. Depth fog blends an object's colors by `density * clamp((z - near) / (far - near), 0, 1)` at `near`, midpoint, and beyond `far`; `far <= near` is `ERR_INVALID_RANGE_ORDER`. 8. Style inheritance is per field: a child redeclaring `fill` keeps its ancestor's `stroke`; a `fill` inherited by a `line` is ignored while a `fill` declared on a `line` is `ERR_UNKNOWN_FIELD`. -9. Gradient stops out of increasing order are `ERR_INVALID_RANGE_ORDER`; `17` stops, `5` filters, `9` layer nesting levels, `513` path commands, `257` spline points, and `513` polygon vertices are each `ERR_VISUAL_LIMIT_EXCEEDED`, and each limit's maximum legal value passes. +9. Gradient stops out of increasing order are `ERR_INVALID_RANGE_ORDER`; `17` stops, `5` filters, `17` layers, `513` path commands, `257` spline points, and `513` polygon vertices are each `ERR_VISUAL_LIMIT_EXCEEDED`, and each limit's maximum legal value passes. Group nesting depth is trace 4's `9`-versus-`8` case and is not restated here. 10. A renderer reporting no conic-gradient support falls back to the documented linear gradient and raises `WARN_VISUAL_APPROXIMATION` exactly once per paint instance across many frames, never once per frame. 11. Path legality: a first command other than `move` and an unopened `close` are `ERR_INVALID_PATH`; a zero-radius `arc` is `ERR_OUT_OF_BOUNDS` rather than a line; a `bezier`-mode spline whose point count is not `3n + 1` is `ERR_SCHEMA_VALIDATION`. 12. A `mask` naming a missing child is `ERR_INVALID_REFERENCE`; a `mask` on a non-`group` object is `ERR_UNKNOWN_FIELD`; the masked child is not itself drawn. 13. Visual ValueSpec fields resolve once: a `random` position sampled at instantiation holds its value across many frames, two runs of the same seed produce identical resolved values, and rendering `600` frames consumes no procedural stream. -14. A binding, `set`, or `override` addressing any visual property is `ERR_UNSUPPORTED_TARGET`, and the section 8.1 table is unchanged by this slice. +14. Visual target capability is exactly the four families of the section 8.1 table (19.1), and no wider: a binding, `set`, or `override` addressing a per-object property — `visuals.systems.horizon.content.band.style.opacity` — is `ERR_UNSUPPORTED_TARGET`, as is one addressing a procedural-system field, a behavior field, a field `strength`, or an emitter `rate`. Within the four families, each operation is accepted only where that family's row permits it: `visuals.systems..visible` accepts a binding and an `override` and rejects an automation `target` naming it, `visuals.layers..opacity` accepts no modulation entry, and a value whose type does not match the row's type is `ERR_TYPE_MISMATCH`. An undeclared layer or system, or an `effects` index outside the authored array, is `ERR_INVALID_REFERENCE`. This trace verifies acceptance and rejection of the *targets*; whether an accepted binding then runs through the shared pipeline is trace 7 of 19.7, and a schema that parses is never evidence that a runtime stage executed. User-observed, and **not** satisfiable by the above: @@ -2013,7 +2169,7 @@ A `particles` system maintains a bounded pool of lightweight items that share on | `velocity` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second. | | `acceleration` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second squared. | | `drag` | ValueSpec\ | No | `0` | `0` to `1`. Fraction of velocity lost per logical second; see below. | -| `size` | ValueSpec\ or life ramp | No | — | Multiplier on `render`'s own size. | +| `size` | ValueSpec\ or life ramp | No | `1` | Uniform local geometry scale on the `render` object; see below. | | `rotation` | ValueSpec\ | No | `0` | Degrees at creation. | | `angularVelocity` | ValueSpec\ | No | `0` | Degrees per logical second. | | `opacity` | ValueSpec\ or life ramp | No | `1` | `0` to `1`. Multiplies `render`'s style opacity. | @@ -2024,7 +2180,43 @@ A `particles` system maintains a bounded pool of lightweight items that share on | `trail` | object | No | — | Trail block (18.8). | | `links` | object | No | — | Link block (18.8). | -**Per-particle resolution boundary.** Every ValueSpec above resolves **once per particle, at that particle's creation**, extending the rule of 17.14 rather than modifying it: creation is an instantiation boundary. `capacity` and `limit` are plain numbers, not ValueSpecs, because a pool bound that could differ per particle is not a bound. Fields are sampled from the system's stream in depth-first, property-document order (9.3), with the documented child key `#`, where the ordinal is monotonic within the system and starts at `0`. Two runs of one seed therefore create identical particles in identical order. +**Resolution boundaries: which fields belong to the system and which to the item.** "Every ValueSpec resolves per particle" cannot be literally true — `count` and `rate` decide *how many* particles to create and must be known before any of them exists. The fields divide into three classes, and this table is the normative division for `particles` and, with the noted substitutions, for `emitter` (18.4): + +| Class | Fields | Resolved | +| --- | --- | --- | +| **System-instantiation** | `count`, `rate`, `burst[].at`, `burst[].count`, `limit`, `capacity`, the `distribution`'s own configuration fields, `fields`, `trail`, `links` | Once, at the system's instantiation boundary (17.14), before any item is created. `capacity` and `limit` are plain numbers, not ValueSpecs, because a pool bound that could differ per item is not a bound. | +| **System channels** | `position.x`, `position.y`, `acceleration.x`, `acceleration.y`, `acceleration.z`, `drag`, `visible` | Base resolved once at the system's instantiation boundary; these are the properties automation may drive live (19.1). | +| **Per-item** | `velocity`, `size`, `rotation`, `angularVelocity`, `opacity`, `color`, `z`, `lifetime`, each life ramp's `from` and `to`, every ValueSpec inside `render` (including a `component` object's `inputs`), every behavior's fields, and the distribution's per-item samples | Once per item, at that item's creation, which is an instantiation boundary in the sense of 17.14 | + +Creation counts are resolved **before** allocation: a burst's `count` is a system-instantiation value precisely so that the number of items is known when the burst fires, and an integer field whose resolved value is not an integer is `ERR_TYPE_MISMATCH` at that boundary rather than being rounded (section 2). + +**What a live system channel does to items that already exist.** The distinction is whether the field is read at creation or every tick, and it is not a new rule — it follows from the integrator above: + +| Channel | Effect of a live change | +| --- | --- | +| `rate` | Future emission only. The fractional accumulator is **not** reset by the change (18.4), so cumulative counts stay continuous across it. | +| `position.x`, `position.y` | Future items only: `position` is an offset added to a *distributed* position, which is read at creation. | +| `acceleration.*`, `drag` | **Every live item**, from the next tick, because both are read by the integrator every tick. | +| `visible` | The whole system's compositing, immediately; items continue to advance (17.7). | + +An automation track composes against the **sampled base** — the value the channel resolved to at the system's instantiation boundary — under the track's own `mode`, exactly as 16.1 fixes for audio: `absolute` replaces it, `offset` adds to it, `scale` multiplies it. The base is a fixed number for the system's life; the effective value is what the pipeline produces from it each tick. + +**Streams and ordinals.** Per-item fields are sampled from the system's stream in depth-first, property-document order (9.3), with the documented child key `#`, where the ordinal is monotonic within the system and starts at `0`. When a burst and continuous emission land on the same tick, ordinals are assigned **burst entries first, in `burst` array order, then the continuous emissions**, and each item's samples are drawn in that same order, so one seed creates identical particles in identical order across runs and across tick alignments. + +**What `size` scales, and where it sits.** `size` is a **uniform scale on the `render` object's local geometry**, applied innermost — before the `render` object's own `transform`, so that `transform.rotation` still turns a scaled shape rather than the reverse. It multiplies: + +| `render` type | What `size` multiplies | +| --- | --- | +| `point` | `style.pointSize` | +| `ellipse`, `arc`, `ring` | `radius` and `innerRadius` | +| `rectangle`, `rounded-rectangle` | `size.width`, `size.height`, and every corner `radius` | +| `line`, `polyline`, `polygon`, `spline`, `bezier`, `path` | Every geometry coordinate, including each command's `to`, `c`, `c1`, `c2`, and `radius` | +| `text` | `size` and `letterSpacing` | +| `group`, `component` | The whole expanded sub-assembly, as one scale about its origin | + +It does **not** multiply `strokeWidth`, `style.blur`, `glow.radius`, `shadow` offsets, or `strokeDash` lengths: those are appearance dimensions, not geometry, and a particle system that shrinks its dots is not asking for hairline strokes. A particle whose `size` resolves to `0` draws nothing and raises no diagnostic, exactly as a `transform.scale` of `0` does (17.11). + +**Velocity alignment uses the XY plane.** Wherever this contract aligns an item to its motion — `emitter.align` (18.4), the `face-motion` behavior (18.6), and a `ribbon` trail's perpendicular (18.8) — the direction is `atan2(vy, vx)` in degrees over the **XY components of velocity only**. The model is 2.5D (17.6): `z` is a depth coordinate, not a third axis of a rotation an object could take. An item whose XY speed is below `1e-6` scene units per second — including one moving purely in `z` — holds its previous rotation, and at creation holds its authored `rotation`. **Life ramps.** `size`, `opacity`, and `color` may be a **life ramp** instead of a ValueSpec: @@ -2072,7 +2264,7 @@ All coordinates are in the owning system's local space, in scene units (17.2). A | `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 either a `commands` array (17.13) or the key of a sibling `path`, `spline`, `polyline`, or `polygon` object in the same container. `mode` is `random` (default, uniform by arc length) or `even`. `align` (default `false`) sets the item's initial `rotation` to the curve tangent. | +| `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`. | @@ -2080,7 +2272,55 @@ All coordinates are in the owning system's local space, in scene units (17.2). A **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. -**Sample consumption.** A distribution's samples are drawn from the owning system's stream in the field order of its row, once per item, at that item's creation. The order is normative so that a fixture's placements are byte-identical across renderers: `x` before `y` before `z`, and for polar forms angle before radius. `point`, `grid` without `jitter`, and every `even` mode consume no samples at all, which is what makes a large static grid reproducible without exhausting a stream. +**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`) @@ -2114,14 +2354,15 @@ An `emitter` creates full visual objects or component instances over time (PRD 8 | `acceleration` | `{x, y, z}`, each ValueSpec\ | No | `{0, 0, 0}` | Scene units per logical second squared. | | `drag` | ValueSpec\ | No | `0` | `0` to `1` per logical second, integrated as in 18.2. | | `align` | boolean | No | `false` | When `true`, the item's `rotation` is set to its velocity direction at creation. | -| `inputs` | object of ValueSpecs | No | `{}` | Only when `emit` is a `component` object; supplies its parameters. Resolved per emission. | | `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.** Every ValueSpec above except `capacity` and `limit` resolves once per emitted item, at its emission, using the emitter's stream with the child key `#`. `rate` and `burst[].at` are properties of the emitter, not of an item, and resolve once at the emitter's own instantiation boundary. +**Per-item resolution.** The three-class division of 18.2 governs an emitter unchanged, with `emit` substituted for `render`: `rate`, `burst[].at`, `burst[].count`, `limit`, `capacity`, the `distribution`'s configuration, `fields`, and `trail` are **system-instantiation** values; `position`, `acceleration`, and `drag` are **system channels** with a base resolved there; and `velocity`, `align`, `lifetime`, every ValueSpec inside `emit` (including a `component` object's own `inputs`), and every behavior field are **per-item**, resolved at emission from the emitter's stream with the child key `#`. A burst's `count` resolves at the emitter's instantiation boundary so that the number of items is known before the burst fires, and a burst and a continuous emission on one tick order burst-first exactly as 18.2 fixes. **Unbounded emission.** An emitter with a non-zero `rate` or a non-empty `burst`, and neither `lifetime` nor `limit`, is `ERR_UNBOUNDED_EMISSION`, for the reason given in 18.2. An emitter with a `limit` and no `lifetime` is legal: it creates a bounded number of persistent items. @@ -2149,14 +2390,15 @@ A `repeater` creates a fixed number of **persistent** copies of an object or com | `count` | ValueSpec\ | Yes | — | Integer, `1` to `1024`. Resolved once, at the repeater's instantiation boundary. | | `distribution` | object | No | `{ "type": "point" }` | Placement (18.3). | | `position` | `{x, y}`, each ValueSpec\ | No | `{0, 0}` | Offset added to every distributed position. | -| `inputs` | object of ValueSpecs | No | `{}` | Only when `repeat` is a `component` object. Resolved per copy. | | `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`, or `lifetime`; each is `ERR_UNKNOWN_FIELD`. Copies live as long as the repeater. `count` above `1024` is `ERR_VISUAL_LIMIT_EXCEEDED`. +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). -**Per-copy resolution and the `repeat.*` namespace.** Each copy is an instantiation boundary (17.14): a `random` or `choose` inside `repeat` or `inputs` is sampled once per copy, in ascending copy index, from the repeater's stream with the child key `#`. Inside a repeater's `repeat` and `inputs`, and only there, a ValueSpec may read three copy-scoped values: +**One location for component inputs.** As on an emitter, a `component` object inside `repeat` carries its own `inputs` (18.1) and that is the only place per-copy component parameters are supplied — the example above shows exactly that shape. There is no second, system-level `inputs` to reconcile it with, so there is no precedence rule, no duplicate-key case, and no question about which of two locations a `repeat.*` reference is sampled in. + +**Per-copy resolution and the `repeat.*` namespace.** Each copy is an instantiation boundary (17.14): a `random` or `choose` anywhere inside `repeat`, including inside a `component` object's `inputs`, is sampled once per copy, in ascending copy index, from the repeater's stream with the child key `#`. Inside a repeater's `repeat`, and only there, a ValueSpec may read three copy-scoped values: | Reference | Type | Value | | --- | --- | --- | @@ -2198,6 +2440,131 @@ Behaviors advance on the fixed logical tick of 9.1, after the particle integrati **Shared numeric conventions.** `frequency` and `rate` are in occurrences per logical second (17.2); `speed` is degrees per logical second for angular behaviors and scene units per logical second for linear ones, as each row states; `phase` and every angle are degrees (17.2). `falloff` on `attract` and `repel` is `none`, `linear` (default), `inverse`, or `inverse-square`; `minDistance` (default `1`) clamps the denominator so the force is finite at the target, and `maxDistance` (absent by default) is the radius outside which the behavior contributes nothing. +**Field contracts.** Every field below is a ValueSpec of the stated type unless the row says enum, boolean, or array, and every one resolves once at the owning object's instantiation boundary. A required field that is absent is `ERR_SCHEMA_VALIDATION`; a value outside a stated range is `ERR_OUT_OF_BOUNDS` as a literal at import and is clamped when it resolves at instantiation, per 14.5's staging rule. + +| `type` | Field | Required | Default | Range | +| --- | --- | :---: | --- | --- | +| `drift` | `velocity` (`{x, y, z}`) | Yes | — | — | +| | `damping` | No | `0` | `0` to `1` | +| `rotate` | `speed` | Yes | — | — | +| | `origin` (`{x, y}`) | No | the object's `transform.origin` | — | +| `oscillate` | `property` (enum) | Yes | — | The channel set below | +| | `amplitude` | Yes | — | — | +| | `frequency` | No | `1` | `0` or above | +| | `phase` | No | `0` | Degrees | +| | `waveform` (enum) | No | `sine` | `sine`, `triangle`, `square`, `sawtooth` | +| | `center` | No | `0` | — | +| `orbit` | `center` (`{x, y}`) | Yes | — | — | +| | `radius` (number or `{x, y}`) | Yes | — | — | +| | `speed` | Yes | — | — | +| | `phase` | No | `0` | Degrees | +| `wander` | `strength` | Yes | — | `0` or above | +| | `rate` | No | `1` | `0` or above | +| | `maxSpeed` | No | — | Above `0`; absent means no clamp | +| `follow-path` | `path` (array) | Yes | — | Inline `commands` (17.13) or a sibling object key | +| | `speed` or `duration` | Exactly one | — | `duration` is a DurationSpec | +| | `loop` (enum) | No | `once` | `once`, `repeat`, `ping-pong` | +| | `align` (boolean) | No | `false` | — | +| | `offset` | No | `0` | `0` to `1` | +| `point-wander` | `amplitude` (`{x, y, z}`) | Yes | `z` defaults `0` | — | +| | `rate` | No | `1` | `0` or above | +| | `indices` (array of integers) | No | every point | `0` to `count - 1`, else `ERR_OUT_OF_BOUNDS` | +| `pulse` | `property` (enum) | Yes | — | The channel set below | +| | `amplitude` | Yes | — | — | +| | `frequency` | No | `1` | `0` or above | +| | `curve` (enum) | No | `smooth` | `step`, `linear`, `exponential`, `smooth` | +| | `duty` | No | `0.5` | Above `0` to `1` | +| `twinkle` | `property` (enum) | No | `style.opacity` | The channel set below | +| | `min` | No | `0` | — | +| | `max` | No | `1` | — | +| | `rate` | No | `1` | `0` or above | +| `noise-displace` | `amplitude` (`{x, y, z}`) | Yes | `z` defaults `0` | — | +| | `scale` | No | `100` | Above `0`, else `ERR_OUT_OF_BOUNDS` | +| | `speed` | No | `0` | — | +| | `octaves` (integer) | No | `1` | `1` to `4` | +| | `persistence` | No | `0.5` | `0` to `1` | +| `face-motion` | `offset` | No | `0` | Degrees | +| | `smoothing` | No | `0` | `0` to `1` | +| `wrap` | `bounds` | No | `scene` | `scene` or `{x, y, width, height}` | +| | `margin` | No | `0` | `0` or above | +| `bounce` | `bounds` | No | `scene` | As `wrap` | +| | `restitution` | No | `1` | `0` to `1` | +| | `axes` (enum) | No | `both` | `both`, `x`, `y` | +| `attract`, `repel` | `target` (`{x, y}` or sibling key) | Yes | — | — | +| | `strength` | Yes | — | — | +| | `falloff` (enum) | No | `linear` | `none`, `linear`, `inverse`, `inverse-square` | +| | `minDistance` | No | `1` | Above `0` | +| | `maxDistance` | No | — | Above `minDistance`, else `ERR_INVALID_RANGE_ORDER` | +| `field-follow` | `field` (string) | Yes | — | A declared field ID (18.7) | +| | `strength` | No | `1` | — | +| | `mode` (enum) | No | `force` | `force`, `velocity`, `direct` | +| `morph` | `to` (string) | Yes | — | A sibling object key | +| | `duration` (DurationSpec) | Yes | — | — | +| | `loop` (enum) | No | `once` | `once`, `repeat`, `ping-pong` | +| | `curve` (enum) | No | `linear` | The four-curve set of 18.2 | + +**Waveforms are equations in cycles.** For `oscillate`, let the cycle position be + +```text +phi = frac(frequency * t + phase / 360) +``` + +where `t` is logical seconds since the owning object's instantiation boundary — the same origin every behavior uses — and `frac(v) = v - floor(v)`. The four waveforms are: + +```text +sine(phi) = sin(2 * pi * phi) +triangle(phi) = 1 - 4 * | frac(phi + 0.25) - 0.5 | +square(phi) = +1 when phi < 0.5, otherwise -1 +sawtooth(phi) = 2 * frac(phi + 0.5) - 1 +``` + +All four are `0` at `phi = 0` except `square`, which has no zero, and all four have range `[-1, 1]`. The behavior's contribution to its channel is `center + amplitude * w(phi)`. + +`pulse` uses the same `phi` and a one-shot envelope inside each period, rising over the first half of `duty` and falling over the second: + +```text +e(phi) = curve(2 * phi / duty) when phi < duty / 2 + = curve(2 - 2 * phi / duty) when duty / 2 <= phi < duty + = 0 otherwise +``` + +with `curve` the four-curve set of 18.2 evaluated from `0` to `1`. The contribution is `amplitude * e(phi)`, so the channel rests at its base value for `1 - duty` of every period. `twinkle` reads one coherent-noise stream (18.7) and maps it to `min + (max - min) * (n + 1) / 2`, where `n` is the noise value in `[-1, 1]`. + +**Accumulated offsets versus fresh evaluation.** A behavior is one or the other, never both, and confusing them turns an orbit into a spiral: + +| Behavior | Kind | +| --- | --- | +| `drift` | **Accumulating.** Its contribution grows by `velocity * dt` each tick, with `velocity` scaled by `(1 - damping)^dt` first. | +| `rotate` | **Accumulating** on `transform.rotation`, by `speed * dt` each tick. | +| `wander`, `bounce`, `attract`, `repel`, `field-follow` in `force` or `velocity` mode | **Accumulating** through velocity: they write the item's velocity, which the integrator of 18.2 turns into displacement. | +| `oscillate`, `pulse`, `twinkle`, `orbit`, `follow-path`, `noise-displace`, `point-wander`, `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. | + +For `orbit` in particular the contribution is an absolute placement expressed as an offset from the object's resolved base position `p0`: + +```text +theta = speed * t + phase +contribution = center + (radius.x * cos theta, radius.y * sin theta) - p0 +``` + +Recomputed each tick from `t`, this traces the ellipse exactly. Added as a fresh displacement each tick — the mistake the distinction exists to prevent — it would integrate into an outward spiral. + +`face-motion` turns toward its target angle by the fraction `1 - smoothing^dt` per tick, taking the shorter way round: `smoothing: 0` snaps immediately, `smoothing: 1` never turns, and intermediate values are frame-rate independent. Its target angle is the XY velocity direction plus `offset`, and the zero-velocity rule is the one 18.2 fixes for velocity alignment generally. + +**Channel write sets, for conflict detection.** 19.1 makes a behavior and an automation track on one object channel `ERR_AUTOMATION_CONFLICT`. Detecting that needs each behavior's write set stated as *scalar* channels: + +| Behavior | Channels written | +| --- | --- | +| `drift`, `noise-displace` | `position.x`, `position.y`, `z` | +| `orbit`, `wander`, `wrap` | `position.x`, `position.y` | +| `rotate`, `face-motion` | `transform.rotation` | +| `follow-path` | `position.x`, `position.y`; and `transform.rotation` when `align` is `true` | +| `oscillate`, `pulse`, `twinkle` | The single channel named by `property` | +| `field-follow` in `direct` mode | `position.x`, `position.y`, `z` | +| `bounce`, `attract`, `repel`, `field-follow` in `force` or `velocity` mode | `velocity.x`, `velocity.y`, `velocity.z` | +| `point-wander`, `morph` | `points[*].x`, `points[*].y`, `points[*].z` | + +`velocity.*` and `points[*].*` are **not** in the automatable registry of 19.1, so behaviors writing only those can never collide with a track and are never `ERR_AUTOMATION_CONFLICT`. A vector target is likewise outside the numeric-only registry: a track addresses `position.x`, not `position`, so the conflict test is always a scalar-to-scalar comparison and no vector-track counterexample arises. + **The `property` channel set.** `oscillate`, `pulse`, and `twinkle` name one channel in `property`: ```text @@ -2211,9 +2578,24 @@ size.width size.height radius **`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`. Morphing reads the target's resolved points and does not suppress it: the target 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. +**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`. -**Behaviors and reproducibility.** `wander`, `twinkle`, `point-wander`, and `noise-displace` are the only behaviors that consume the procedural stream, and each consumes it exactly once, at instantiation, to derive its noise offsets. 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. +**Which geometry can morph.** Morphing interpolates a point list, so it is defined only on primitives that *have* one: + +| `type` | Morphs | Point representation | +| --- | --- | --- | +| `polyline`, `polygon` | Yes | `points` | +| `spline` | Yes, when `mode` matches | `points` | +| `path` | Yes, only when **every** command of both objects is `move`, `line`, or `close`, and the two command sequences are op-for-op identical | The command endpoints in order | +| Every other type, including `point`, `line`, `rectangle`, `rounded-rectangle`, `ellipse`, `arc`, `ring`, `bezier`, `text`, `group`, and `component` | No | — | + +A `morph` whose source or target is outside the first three rows, or whose two `path` command sequences differ, is `ERR_MORPH_INCOMPATIBLE`. Interpolating a rectangle's `size` into an ellipse's `radius`, or a group's subtree into a text run, would be inventing a geometry correspondence the author never wrote, and a curved `path` command carries control points whose count is not the endpoint count — which is why curved paths are excluded rather than partially supported. + +**Target points are read live, not snapshotted.** Morph interpolates toward the target's points **as they are on the current tick**, so a morph onto a moving or `point-wander`ing target follows it; snapshotting at instantiation would silently freeze the target and make one of the two obvious readings wrong. Evaluation order makes that decidable: within a tick, objects advance in document key order (17.8), and a morph reads the target's post-behavior local points from the same tick — the target's own value if it has already advanced, and its previous-tick value if it has not, exactly as the document order says. A `morph` whose target itself carries a `morph` naming the source, directly or through a chain, is `ERR_CYCLIC_DEPENDENCY` at semantic validation, the same code section 8.1 uses for a reference cycle. + +Morphing does not suppress the target: it draws normally unless the author also hides it with `visible` or uses it as the group's `mask` (17.12). `duration` is a DurationSpec, `loop` is `once` (default), `repeat`, or `ping-pong`, and `curve` is the four-curve set of 18.2. + +**Behaviors and reproducibility.** `wander`, `twinkle`, `point-wander`, and `noise-displace` are the only behaviors that consume the procedural stream, and each consumes it exactly twice, at instantiation, to derive the noise offset pair 18.7 fixes. Their subsequent motion is a pure function of logical time and those offsets. No behavior samples per frame or per tick, so 9.3's guarantee holds: rendering consumes no procedural stream, and identical seeds produce identical motion. ### 18.7 Procedural fields (`visuals.fields.`) @@ -2236,22 +2618,96 @@ A **field** is a named vector function over scene space that other systems read. | --- | --- | --- | | `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, counter-clockwise for positive `strength`. | +| `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`, `center`, `size` | Coherent noise; see below. | +| `noise` | `scale`, `speed`, `octaves`, `persistence`, `amplitude`, `mode`, `direction`, `center`, `size` | Coherent noise; see below. | `falloff`, `minDistance`, and `maxDistance` carry the meanings fixed in 18.6. Every field also accepts an optional `bounds` (`{x, y, width, height}`) outside which it contributes nothing, and an optional `enabled` ValueSpec\ defaulting to `true`. A `type` outside the six is `ERR_INVALID_FIELD_TYPE`. -**Coherent noise is normative.** 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 itself is fixed here: +**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. -* Three-dimensional gradient noise on the integer lattice, sampled at `(x / scale, y / scale, t * speed)`, where `t` is logical seconds (9.1). -* Lattice gradients are the twelve edge-midpoint vectors of a cube, selected by a permutation table of `256` entries derived at field instantiation from the field's own stream (9.3, `visual` domain, child key ``), shuffled by Fisher-Yates consuming one sample per entry. -* Interpolation uses the quintic fade `6u^5 - 15u^4 + 10u^3` on each axis. -* Octaves sum at doubling frequency and `persistence` amplitude decay, normalized so the result stays within `[-1, 1]`, then scaled by `amplitude`. -* `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. +`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`. -`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. `center` and `size` optionally restrict the sampled region; outside it the field contributes nothing. +**Gradients.** The twelve edge-midpoint vectors of a cube, in exactly this order, used **unnormalized**: + +```text +G[ 0..3 ] = ( 1, 1, 0) (-1, 1, 0) ( 1,-1, 0) (-1,-1, 0) +G[ 4..7 ] = ( 1, 0, 1) (-1, 0, 1) ( 1, 0,-1) (-1, 0,-1) +G[ 8..11] = ( 0, 1, 1) ( 0,-1, 1) ( 0, 1,-1) ( 0,-1,-1) +``` + +**Permutation table.** At the field's instantiation boundary, from the field's own stream (9.3, `visual` domain, child key ``): + +```text +p[i] = i for i in 0..255 +for i from 255 down to 1: + j = floor(rng.nextFloat() * (i + 1)) # one sample per swap + swap p[i], p[j] +p[256 + i] = p[i] for i in 0..255 # duplicated tail, no wrap arithmetic +``` + +The draw count is exactly **255** samples — one per swap, not one per entry — and `nextFloat()` returns `[0, 1)`, so `floor` yields `0..i` without a boundary case. This is the integer-from-uniform construction 9.3 already requires; no other integer sampling policy is introduced. + +**Single-octave value.** For a sample point `(x, y, z)` in noise space: + +```text +X = floor(x) & 255 ; fx = x - floor(x) ; u = fade(fx) # and likewise Y/fy/v, Z/fz/w +fade(a) = 6a^5 - 15a^4 + 10a^3 +A = p[X] + Y ; AA = p[A] + Z ; AB = p[A+1] + Z +B = p[X + 1] + Y ; BA = p[B] + Z ; BB = p[B+1] + Z +grad(h, dx, dy, dz) = dot( G[h mod 12], (dx, dy, dz) ) +lerp(s, a, b) = a + s * (b - a) + +n = lerp(w, lerp(v, lerp(u, grad(p[AA ], fx, fy, fz ), grad(p[BA ], fx-1, fy, fz )), + lerp(u, grad(p[AB ], fx, fy-1, fz ), grad(p[BB ], fx-1, fy-1, fz ))), + lerp(v, lerp(u, grad(p[AA+1], fx, fy, fz-1), grad(p[BA+1], fx-1, fy, fz-1)), + lerp(u, grad(p[AB+1], fx, fy-1, fz-1), grad(p[BB+1], fx-1, fy-1, fz-1)))) +``` + +`h mod 12` maps `256` table values onto `12` gradients with a small, fixed bias. Bias is not nonconformance: the mapping is deterministic and identical on every renderer, which is the only property a fixture can test. The result is clamped to `[-1, 1]` as a guard; with these gradients the unclamped magnitude does not exceed `1`, and the clamp is never a shaping step. + +**Octaves.** With `s1(x, y, z)` the single-octave value above: + +```text +total = 0 ; amp = 1 ; freq = 1 ; norm = 0 +repeat octaves times: + total += amp * s1(x * freq, y * freq, z * freq) + norm += amp + amp *= persistence + freq *= 2 +s = total / norm # in [-1, 1] +``` + +Dividing by the accumulated amplitude, rather than by a closed form, keeps `persistence: 0` well defined (`norm = 1`, one octave contributing). + +**Sample coordinates.** A field sampled at scene point `(px, py)` at logical time `t` (9.1) evaluates `s` at + +```text +( px / scale , py / scale , t * speed ) +``` + +**Scalar to vector.** Let `h = 1e-3` be the central-difference step, taken in noise-space coordinates: + +```text +ds/dx = ( s(x + h, y, z) - s(x - h, y, z) ) / (2h) # and likewise ds/dy +gradient : amplitude * ( ds/dx , ds/dy ) +curl : amplitude * ( ds/dy , -ds/dx ) +value : amplitude * s * ( cos(direction) , sin(direction) ) +``` + +`curl` is the explicit perpendicular of the gradient of the scalar potential `s`, and it is divergence-free by construction — not "the curl of the noise potential" left to the implementer to build. Both derivative modes cost four extra scalar evaluations per sample. Tolerances for the traces of 18.10: a scalar sample matches its published oracle to `1e-9`, and the numerically estimated divergence of a `curl` field is within `1e-4 * amplitude / scale` of zero at both lattice and non-lattice positions. + +**Behaviors that use coherent noise** — `wander`, `twinkle`, `point-wander`, and `noise-displace` (18.6), whose rows say "value-noise" and mean this function — share **one permutation table per exhibit**, derived at activation from the `visual` domain with child key `behavior-noise`. Per-object independence comes from offsets, not from per-object tables: deriving 255 samples for every particle would make a large system's instantiation cost quadratic in nothing an author asked for. Each such behavior draws an **offset pair** `(o1, o2)`, two samples uniform in `[0, 1024)`, once at its owning object's instantiation boundary, and samples as: + +| Behavior | Noise-space coordinates | Use | +| --- | --- | --- | +| `wander` | `(o1, o2, t * rate)`, one octave | Direction `theta = 360 * s`; acceleration `strength * (cos theta, sin theta)`, with accumulated wander velocity clamped to `maxSpeed` | +| `twinkle` | `(o1, o2, t * rate)`, one octave | The scalar `s`, mapped as 18.6 fixes | +| `point-wander` | Point `i` uses `(o1 + 64 * i, o2, t * rate)` and `(o1, o2 + 64 * i, t * rate)` | The two scalars scale `amplitude.x` and `amplitude.y`; a third at `(o1 + 64 * i, o2 + 64 * i, t * rate)` scales `amplitude.z` | +| `noise-displace` | `(px / scale, py / scale, t * speed)`, and the same point offset by `(137, 71, 0)` and by `(271, 193, 0)`, with the behavior's own `octaves` and `persistence` | The three scalars scale `amplitude.x`, `amplitude.y`, and `amplitude.z` | + +`mode` selects how the scalar noise becomes a vector: `curl` (default) takes the curl of the noise potential, giving divergence-free flow that never piles items into a point; `gradient` takes its gradient; `value` returns the scalar along `direction` degrees. The exact vector construction for each mode is fixed below. `direction` is a number in degrees (17.2) with no default: it is **required** when `mode` is `value` and is `ERR_UNKNOWN_FIELD` under `curl` and `gradient`, which derive their direction from the noise itself. `center` and `size` optionally restrict the sampled region; outside it the field contributes nothing. **Fields do not read each other.** A field's vector depends only on position and logical time. Fields never reference other fields, systems, or objects, so there is no evaluation order to fix and no cycle to detect. Fields are summed into a system's acceleration in the order of that system's `fields` array; addition is commutative, so the order is fixed for traceability rather than for correctness. @@ -2259,7 +2715,7 @@ A **field** is a named vector function over scene space that other systems read. ### 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 on a `particles` system (18.2), on an `emitter` (18.4), or on the `trail` field of a particle's `render` object, and it applies per item. +**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 | | --- | --- | :---: | --- | --- | @@ -2281,19 +2737,32 @@ History is sampled on the logical clock, never on the frame, so a trail has the | Field | Type | Required | Default | Notes | | --- | --- | :---: | --- | --- | | `rule` | enum | No | `distance` | `distance`, `nearest`, or `index`. | -| `maxDistance` | ValueSpec\ | Conditional | — | Required for `distance`; optional for `nearest`. Scene units. | +| `maxDistance` | ValueSpec\ | Conditional | — | Required for `distance`, and for any rule when `fadeWithDistance` is `true`. Optional for `nearest`. Scene units, above `0`. | | `count` | number | Conditional | — | Required for `nearest`. Integer, `1` to `8`. Links per item. | | `stride` | number | No | `1` | `index` only. Links item `i` to item `i + stride`. | | `closed` | boolean | No | `false` | `index` only. Links the last item back to the first. | | `maxLinks` | number | No | `256` | Integer, `1` to `1024`. Links drawn per system per tick. | -| `style` | object | No | the system's style | 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`. | +| `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. `index` links items by creation order and needs no distance search at all. +`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. -A system whose live item count exceeds `256` while `links` is declared with rule `distance` or `nearest` is `ERR_VISUAL_LIMIT_EXCEEDED` at semantic validation where the count is statically known (`repeater.count`, `particles.capacity`), because a pairwise search over more items than that cannot be made to fit the per-tick budget PRD 120 sets. `index` has no such limit; it is linear in the item count. +**The `256` pairwise-population bound, and where it is checked.** A system carrying `links` with rule `distance` or `nearest` may not have a population above `256`, because a pairwise search over more items than that cannot be made to fit the per-tick budget PRD 120 sets. The bound is **deliberately conservative**: it is checked against the population's *upper* bound, not against the live count, so a `particles` system with `capacity: 400` is rejected even if it never holds more than ten particles at once. That is the intended behavior — a ceiling that only failed once the scene was already too heavy would be a ceiling that fires in front of the viewer — and it is stated here rather than left to look like an over-eager check. + +It is enforced at two stages, and at both it is `ERR_VISUAL_LIMIT_EXCEEDED`: + +* **Import**, where the bound is a literal: `particles.capacity`, and a `repeater.count` that is a literal number. +* **Instantiation**, where it is not: a `repeater.count` authored as a ValueSpec is checked the moment it resolves, at the repeater's instantiation boundary (17.14), before any copy is created. A spawned system's is checked per spawn. + +`index` has no such limit; it is linear in the item count. ### 18.9 New diagnostic codes @@ -2324,15 +2793,15 @@ Automated, and executable without a display measurement: 7. A life ramp interpolates `from` to `to` under each of the four curves at age `0`, midpoint, and `1`; a ramp without `lifetime` is `ERR_SCHEMA_VALIDATION`; an `exponential` ramp through zero falls back to linear and raises `WARN_AUTOMATION_FALLBACK`. 8. `rate` emission is exact: at a constant rate the cumulative count after `t` logical seconds is `floor(rate * t)` under three different tick lengths, and a burst emits its whole `count` on the first tick at or after its `at`. 9. Emission with neither `lifetime` nor `limit` is `ERR_UNBOUNDED_EMISSION`; `count` alone without `lifetime` passes; exceeding `capacity` evicts the oldest item and leaves the live count at `capacity`. -10. Each of the nine distributions places a known item set at the documented positions from a fixed seed; `even` and `grid` consume no procedural samples; an index-driven placement on a continuous `rate` emission is `ERR_INVALID_DISTRIBUTION`; an unknown distribution `type` is `ERR_INVALID_DISTRIBUTION_TYPE`. +10. Each of the nine distributions places a known item set at the documented positions from a fixed seed, and the final PRNG position after the placement matches the draw-count table of 18.3 exactly; every `even` mode and a `grid` with absent or zero `jitter` consume no procedural samples, while a `grid` with non-zero `jitter` consumes two per item and an added `depth` sub-block consumes one more, drawn last; a `depth` sub-block's three curves each place a known item at the documented `z`; an index-driven placement on a continuous `rate` emission is `ERR_INVALID_DISTRIBUTION`; a `path` distribution naming a sibling key rather than carrying inline `commands` is `ERR_INVALID_REFERENCE`; an unknown distribution `type` is `ERR_INVALID_DISTRIBUTION_TYPE`. 11. A repeater resolves `repeat.index`, `repeat.count`, and `repeat.fraction` per copy, samples a `choose` once per copy in ascending index, and is `ERR_INVALID_REFERENCE` when `repeat.*` is used outside a repeater or when an external path names a copy. 12. Each of the seventeen behaviors advances a known object to a documented state after a fixed number of logical ticks; an unknown `type` is `ERR_INVALID_BEHAVIOR_TYPE`; a `property` outside the channel set, and `size.width` on an `ellipse`, are each `ERR_INVALID_BEHAVIOR_TARGET`; `point-wander` on a `rectangle` is `ERR_INVALID_BEHAVIOR_TARGET`; `9` behaviors on one object is `ERR_VISUAL_LIMIT_EXCEEDED`. 13. Behavior composition follows array order: two `drift` behaviors accumulate on `position`, two `oscillate` behaviors on `transform.scale.x` multiply, and swapping the array order changes the result only where the rule says it should. -14. `morph` between two `spline` objects of equal `mode` and point count interpolates each point; differing point counts, differing `type`, and differing spline `mode` are each `ERR_MORPH_INCOMPATIBLE`; `to` naming a missing key is `ERR_INVALID_REFERENCE`. -15. The noise function of 18.7 reproduces documented sample values at fixed lattice coordinates from a fixed seed; two runs of one seed agree exactly; `octaves` of `5`, `persistence` above `1`, and a `scale` of `0` are each rejected with the documented code; an unknown field `type` is `ERR_INVALID_FIELD_TYPE`; `9` declared fields and `5` referenced fields are each `ERR_VISUAL_LIMIT_EXCEEDED`. -16. `curl` mode is divergence-free to within the documented tolerance over a sampled grid, so items following it do not accumulate at a point. +14. `morph` between two `spline` objects of equal `mode` and point count interpolates each point; differing point counts, differing `type`, and differing spline `mode` are each `ERR_MORPH_INCOMPATIBLE`, as are a `morph` onto a `rectangle` and one between two `path` objects whose command sequences differ; a `morph` onto a target that is itself moving tracks the target's current points on each tick rather than a snapshot, and a mutual `morph` pair is `ERR_CYCLIC_DEPENDENCY`; `to` naming a missing key is `ERR_INVALID_REFERENCE`. +15. The noise function of 18.7 reproduces documented sample values to `1e-9` at fixed **lattice and non-lattice** coordinates from a fixed seed, and the permutation shuffle consumes exactly `255` samples, leaving the documented stream position; two runs of one seed agree exactly; a `value`-mode field without `direction`, and a `direction` under `curl` or `gradient`, are each rejected with the documented code, as are `octaves` of `5`, `persistence` above `1`, and a `scale` of `0`; an unknown field `type` is `ERR_INVALID_FIELD_TYPE`; `9` declared fields and `5` referenced fields are each `ERR_VISUAL_LIMIT_EXCEEDED`. +16. `curl` mode is divergence-free to within `1e-4 * amplitude / scale` over a sampled grid taken at non-lattice as well as lattice positions, so items following it do not accumulate at a point; `gradient` and `value` produce their documented vectors at the same points; and the four noise-using behaviors of 18.6 each draw exactly two samples at instantiation and none thereafter. 17. Trail history is sampled on the logical clock: the same trail geometry results at three different frame rates over the same logical span; a trail of `129` samples is `ERR_VISUAL_LIMIT_EXCEEDED`; a removed item's history is discarded in the same tick. -18. Link enumeration is deterministic and de-duplicated in ascending pair order under each of the three rules; `distance` links exactly the pairs within `maxDistance`; exceeding `maxLinks` drops the tail of that order rather than an arbitrary subset; `links` on an `emitter` is `ERR_UNKNOWN_FIELD`; a `distance` rule over a statically known population above `256` is `ERR_VISUAL_LIMIT_EXCEEDED`. +18. Link enumeration is deterministic and de-duplicated in ascending pair order under each of the three rules; `distance` links exactly the pairs within `maxDistance`; `nearest` breaks equidistant ties by ascending creation ordinal; the `index` rule re-indexes the live ordering densely, so a death in the middle of a chain closes the gap rather than splitting it; exceeding `maxLinks` drops the tail of that order rather than an arbitrary subset; `fadeWithDistance: true` without `maxDistance` is `ERR_SCHEMA_VALIDATION`; `links` on an `emitter` is `ERR_UNKNOWN_FIELD`; a `distance` rule over a literal population above `256` is `ERR_VISUAL_LIMIT_EXCEEDED` at import and over a procedurally resolved `repeater.count` above `256` at that repeater's instantiation boundary. 19. A binding, `set`, or `override` addressing any property introduced in this section — a behavior field, a field strength, an emitter `rate` — is `ERR_UNSUPPORTED_TARGET`, and the section 8.1 table remains unchanged by this slice. User-observed, and **not** satisfiable by the above: @@ -2395,7 +2864,16 @@ The second scope is what makes automation work on a spawned system: a track writ `at` is a duration literal only, never a procedural `TimeSpec`, for the reason 16.1 gives: the strictly-increasing ordering rule must stay decidable at import. Point *values* remain full ValueSpecs and resolve once, at the owning scope's instantiation boundary, sampled in depth-first document order after that scope's other fields. -**Loop modes** (PRD 85). `loop` is `{ "mode": "repeat" | "ping-pong", "count": = 1> | "infinite" }`, `count` defaulting to `infinite`. The loop period is the `at` of the last point. `repeat` restarts from the first point, so a track whose first and last values differ steps discontinuously at the wrap; that is the author's choice, not a defect. `ping-pong` plays the curve forward then backward, and one `count` unit is one *complete* forward-and-back cycle, so that a finite count always ends where it began. Before the first point the track holds the first point's value under either mode. After a finite `count` is exhausted the track holds the value it ended on and stops advancing. An `infinite` loop is what PRD 85 means by loop count being infinite for persistent visual behavior; it is legal only on a persistent scope, and an `infinite` loop on a spawned system with a finite `lifetime` (19.2) is legal and simply ends with the system. +**Loop modes** (PRD 85). `loop` is `{ "mode": "repeat" | "ping-pong", "count": = 1> | "infinite" }`, `count` defaulting to `infinite`. The loop period is the `at` of the last point. `repeat` restarts from the first point, so a track whose first and last values differ steps discontinuously at the wrap; that is the author's choice, not a defect. `ping-pong` plays the curve forward then backward, and one `count` unit is one *complete* forward-and-back cycle, so that a finite count always ends where it began. Before the first point the track holds the first point's value under either mode. After a finite `count` is exhausted the track holds the value it ended on and stops advancing. **`infinite` is legal in every scope, and one rule covers all four cases.** A loop never outlives the scope that declares it, so an unbounded loop is bounded by the lifetime of its owner and needs no scope-dependent prohibition: + +| Declaring scope | What bounds an `infinite` loop | +| --- | --- | +| `visuals.automation` | Exhibit deactivation | +| A `persistent` system | Exhibit deactivation, with the system | +| A `spawned` system with `spawn.lifetime` | Expiry of that lifetime, then the release ramp (19.2) | +| A `spawned` system with no `spawn.lifetime` | An explicit `remove`, owner termination, or deactivation, whichever comes first (19.2) | + +No combination of scope and lifetime is rejected, and no diagnostic is raised for any of them. The fourth row is the case earlier text left contradictory — declaring `infinite` legal "only on a persistent scope" while also declaring it legal on a finite-lifetime spawned system — and it resolves the same way as the other three: an indefinite spawned instance is still released by a `remove` or by its owner, and its tracks are disposed with it. This is what PRD 85 means by an infinite loop count for persistent visual behavior. **The automatable registry.** A track may address these properties and no others. A `target` outside this registry is `ERR_UNSUPPORTED_TARGET`; a `target` naming an undeclared layer, system, object, or effect index is `ERR_INVALID_REFERENCE`. @@ -2405,7 +2883,9 @@ The second scope is what makes automation work on a spawned system: a track writ | `layers.` | `opacity`, `parallax` | | `camera` | `x`, `y`, `zoom`, `rotation`, `focalLength` | | `effects[]` | Every numeric parameter of the effect's own table in 19.4 | -| A system | `visible` is **not** automatable (it is boolean); for a `graphic` system, any numeric `transform`, `style`, or geometry property of an object in its `content` tree, addressed by container keys; for `particles` and `emitter`, `rate`, `position.x`, `position.y`, `acceleration.x`, `acceleration.y`, `acceleration.z`, and `drag`; for `repeater`, the numeric fields of its `step` block | +| 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. @@ -2428,26 +2908,41 @@ The reason is the one 16.2 gives for audio. Automation is declared *inside* the Boolean targets take no automation and no modulation stage: those stages are absent, not identity hooks (8.1). `visuals.layers..visible` is deliberately **not** in the table; a layer is hidden by animating its `opacity` to `0`, which is continuous and needs no second mechanism. -**Limits.** At most `128` automation tracks and `2048` total automation points per exhibit, counted across both scopes and across every live spawned instance. Exceeding either is `ERR_VISUAL_LIMIT_EXCEEDED`. These are the "animation records" of PRD 89 and are restated in the centralized table of 19.5. +**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`. Added to the 17.7 system field table by this section. | -| `lifetime` | DurationSpec | No | — | `spawned` only. Logical duration from instantiation to the start of release. Absent means the instance runs until removed or its owner terminates. | -| `release` | DurationSpec | No | `0ms` | `spawned` only. `0ms` to `10s`. | -| `ownership` | enum | No | inherited | `spawned` only. `persistent` transfers the instance to the performance root (10.1). Any other value is `ERR_SCHEMA_VALIDATION`. | -| `inputs` | object | No | `{}` | `spawned` only. Declared parameters, with the shape, types, and construct scope 18.1 fixes for visual components. | -| `cancelWithScenario` | boolean | No | `true` | `spawned` only. `false` requires `ownership: "persistent"`, or `ERR_UNSUPPORTED_TARGET`. | +| `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 lifecycle field on a `persistent` system is `ERR_UNKNOWN_FIELD`, per the strict unknown-field policy. This resolves the deferral 17.7 records. +A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names at the top level of a system of either lifecycle, per the strict unknown-field policy. This resolves the deferral 17.7 records, and the container is what keeps instance duration and item duration from sharing one key. -**A spawned system is a template, not a drawn system.** A `visuals.systems.` with `lifecycle: "spawned"` is declared, validated, and counted against the authoring limits at import, and is **not** instantiated or drawn at activation. It is instantiated only by a `spawn` action naming it, once per action, with the supplied values bound to its `inputs`. Its instantiation boundary (17.14) is the spawn moment; every ValueSpec in it, and every point value of its own `automation` array, resolves there. Two spawns of one template therefore sample independently and reproducibly, from the stream child key `#` (9.3), with the ordinal monotonic per template from `0`. +**A template's own `inputs` reference scope.** Inside a spawned template — its type-specific fields, its objects, and its `automation` point values — a ValueSpec may read `{ "ref": "inputs." }` for a parameter declared in `spawn.inputs`, exactly as a component's `content` reads its own exposed parameters (18.1). The two scopes never overlap: a component's `content` sees the component's parameters, a template's body sees the template's, and a component instantiated inside a template sees only its own. `inputs.*` outside both is `ERR_INVALID_REFERENCE`, it is not a section 1.3 document namespace, and it grants no section 8.1 capability. -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 namespace section 1.3 reserves; per 8.1 that namespace is an action-addressing namespace and never a ValueSpec, binding, or automation target. +**A spawned system is a template, not a drawn system.** A `visuals.systems.` with `lifecycle: "spawned"` is declared, validated, and counted against the authoring limits at import, and is **not** instantiated or drawn at activation. It is instantiated only by a `spawn` action naming it, once per action, with the supplied values bound to the parameters its `spawn.inputs` declares. Its instantiation boundary (17.14) is the spawn moment; every ValueSpec in it, and every point value of its own `automation` array, resolves there. Two spawns of one template therefore sample independently and reproducibly, from the stream child key `#` (9.3), with the ordinal monotonic per template from `0`. + +The `spawn` and `remove` **action shapes** belong to the Action Model and are not fixed here, exactly as section 16 fixes the sound-instance lifecycle without redefining the `sound` action. This subsection fixes the *resource* contract that those actions operate on. A spawned instance is addressed through the `instances.*` runtime **action-addressing** namespace, which section 8.1 defines and explicitly excludes from ValueSpec, binding, and automation use. Section 1.3 reserves document namespaces; `instances.*` is not one of them, and citing it there would grant by implication a reach that 8.1 denies outright. **States.** A visual system instance occupies exactly one state: @@ -2469,15 +2964,35 @@ 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 `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 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 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 `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`. +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`. -**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 `ownership: "persistent"` and the action requests it. A scenario-owned instance is released during that scenario's cleanup; `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. +**The release factor.** Every system instance carries a **release factor**: a number, initialized to `1` at construction, that multiplies into the effective alpha of every object the instance draws (17.12). It is not a property, is not addressable, and is not the authored `style.opacity` of anything. It holds at `1` for the instance's whole life until the instance enters `RELEASING`, where it ramps linearly to `0` over `spawn.release`, and it never rises again — an instance cannot leave `RELEASING`. A `spawn.release` of `0ms` sets it to `0` for the single `RELEASING` tick, so that tick draws nothing and the teardown path is still the one path. + +Because it is a separate multiplier, release never rewrites an authored value and never restores one: an object authored at `style.opacity: 0.4` fades from `0.4` to `0` and does not pop to `1` on the way. That reading — that release must reset opacity to full — only follows if release is implemented by writing the opacity property, which is exactly what this factor exists to avoid, and it is why 0.1 adds no public system-level opacity property to carry it. + +**Ownership** follows 10.1 without amendment. A spawned instance inherits the dispatch context of the action that created it — a scenario instance, or the performance root — unless it declares `spawn.ownership: "persistent"` and the action requests it. A scenario-owned instance is released during that scenario's cleanup; `spawn.cancelWithScenario: false` on a persistent-owned instance is what lets a visual outlive the scenario that started it, which is the case PRD 86 names. A persistent system declared in `visuals.systems` is owned by the performance root, is created at activation, and is released only at deactivation. + +**`spawn.ownership` and `spawn.cancelWithScenario` are two different relationships.** Transferring an instance to the performance root moves where its *resources* are reclaimed; it does not by itself say anything about the scenario that created it, which is why a flag would otherwise be redundant. This contract keeps both, explicitly: + +| Relationship | What it decides | Set by | +| --- | --- | --- | +| **Resource ownership** | Which owner's cleanup reclaims the instance, and whose deadline (10.2) applies | `spawn.ownership` | +| **Originating-scenario relationship** | Whether the instance is released when the scenario that spawned it is cancelled or completes | `spawn.cancelWithScenario`, retained independently of ownership | + +An inherited-ownership instance is reclaimed by its scenario, so its originating relationship is necessarily live and `spawn.cancelWithScenario: false` on it is `ERR_UNSUPPORTED_TARGET` — the documented false case, unchanged. A `persistent`-owned instance keeps the originating relationship by default (`true`), so it is still released with its scenario while being reclaimed by the root; setting it `false` severs that one relationship and is what lets a visual outlive the scenario that started it. Neither field is addressable at runtime; both are authored on the template. + +**Edge cases of the state machine, stated in prose and not only in a trace.** + +* A `remove` on an instance already in `RELEASING`, `FINISHED`, `DISPOSED`, or `FAILED` is a **no-op**: it raises no diagnostic, does not restart the release ramp, and does not consume a dispatch unit beyond the action itself. Removal is idempotent because an owner's cleanup and an authored `remove` can reach the same instance in one tick. +* `CREATED -> FINISHED` is the path of an instance removed, or whose owner terminated, before its first tick: it never entered a layer, so there is nothing to fade and `RELEASING` is skipped. +* `CREATED -> FAILED` and `ACTIVE -> FAILED` are construction and runtime failure. A `FAILED` instance's resources are reclaimed exactly as for `DISPOSED`, immediately and in the same tick; from that moment it counts against no ceiling of 19.5 and holds no automation record (19.1). It is not a scenario failure by itself. +* Nothing leaves a terminal state. `DISPOSED` and `FAILED` are terminal, and an instance in either is not re-spawnable — a new `spawn` creates a new instance with the next ordinal. **A persistent system's `visible` is not its lifecycle.** A hidden system still advances its behaviors, automation, particles, and emissions, and still counts against every ceiling in 19.5 (17.7). Hiding is a compositing decision; removal is a lifecycle decision. An exhibit that wants the work to stop must remove the system, not hide it. -**Ceiling.** At most `64` spawned instances live at once across every template, counted from `CREATED` until `DISPOSED`. A spawn that would exceed it is refused — not evicted — and raises `WARN_VISUAL_CEILING` once, naming the template and the ceiling. 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. +**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 @@ -2490,21 +3005,71 @@ Release is unconditional and runs on `lifetime` expiry, on an explicit `remove`, | `zoom` | ValueSpec\ | No | `1` | `0.01` to `100` | Uniform scale about the projection center. | | `rotation` | ValueSpec\ | No | `0` | — | Degrees, positive toward `+y` (17.2). | | `projection` | enum | No | `orthographic` | — | `orthographic` or `perspective`. Any other token is `ERR_SCHEMA_VALIDATION`. | -| `focalLength` | ValueSpec\ | No | `1000` | above `0` | Scene units. `ERR_OUT_OF_BOUNDS` at or below `0`. | +| `focalLength` | ValueSpec\ | No | `1000` | `1` to `100000` | Scene units. See the bound note below. | The scene center is `(width / 2, height / 2)` for `virtual`, `(0.5, 0.5)` for `normalized`, and the display center for `viewport` (17.4). A camera default that is not the scene center would make an exhibit that never mentions the camera look different from one that declares its defaults, which is the kind of surprise a contract exists to prevent. -**The projection center** is the center of the display rectangle after `fit` resolution (17.4). Every camera operation and the perspective factor of 17.6 act about that one point. +**`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. -**The camera matrix is normative.** For a layer with parallax `p` (17.5), the view matrix applied to that layer's composited content is, in composition order: +**Named spaces.** Three coordinate spaces appear below and are never mixed: + +| Space | Units | Origin | +| --- | --- | --- | +| **Local** | Scene units of the declared coordinate space (17.4) | The object's own origin (17.9) | +| **Scene** | Scene units | The scene rectangle's top-left (17.4) | +| **CSS** | CSS pixels of the display surface | The display rectangle's top-left | +| **Device** | Device pixels of the backing store | The backing store's top-left | + +The display rectangle is `Wd x Hd` CSS pixels, and the **projection center** `c` is `(Wd / 2, Hd / 2)` in CSS space — the center of the display rectangle after `fit` resolution (17.4), not the center of the scene. Every camera operation and the perspective factor of 17.6 act about that one point. + +**The fit matrix `F` maps scene space to CSS space.** The scene rectangle is `Ws x Hs`: `width x height` for `virtual`, `1 x 1` for `normalized`, and `Wd x Hd` for `viewport`. With ```text -V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p x (x - c_x), -p x (y - c_y)) +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 ``` -where `c` is the projection center. Parallax multiplies the camera *translation* only, never its zoom or rotation, 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 to the display and is the mechanism for a fixed overlay. +the fit matrix carries the centering offset that `contain` and `cover` both need and that neither can be expressed without: -Under `perspective`, each object additionally receives the uniform factor `focalLength / (focalLength + z)` about the projection center, exactly as 17.6 states, applied after `V`. Under `orthographic`, `z` still drives sorting, parallax, and fog but never scale. Culling of objects at `z <= -focalLength` is unchanged from 17.6 and raises no diagnostic. +```text +ox = (Wd - sx * Ws) / 2 oy = (Hd - sy * Hs) / 2 +F = T(ox, oy) x S(sx, sy) +``` + +`ox` and `oy` are the letterbox bars under `contain` and are negative — the crop — under `cover`; under `stretch` and in `viewport` they are `0`. Checking the algebra only at `sx = 1` hides this offset entirely, which is exactly why it is written out. + +**The camera matrix `V` is normative, and it operates in CSS space.** The camera's `x` and `y` are authored in **scene** units, so they are carried into CSS space before the translation is formed. With `q = F * (x, y)` the camera center in CSS space, and `p` the layer's `parallax` (17.5): + +```text +V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c)) +``` + +Subtracting `q` from `c` without mapping through `F` first would subtract scene units from CSS units — the two agree only when `sx = sy = 1`, which is precisely the case a naive test uses. + +**The full chain.** For a local-space point of an object with effective depth `zEffective` (17.6), on a layer with parallax `p`: + +```text +x_device = B x P x V x F x M_effective x x_local + +M_effective = M_parent x M_local (17.11) +P = T(c) x S(k) x T(-c), k = focalLength / (focalLength + zEffective) + under perspective; P = I under orthographic +B = S(dpr_effective) (19.5) +``` + +Read right to left: local geometry composes up its group tree, the fit places it in the display, the camera moves the display, perspective foreshortens about the projection center, and the backing-store scale is applied **once**, last, by `B`. A renderer that folds `dpr` into `F` and again into `B`, or that applies the camera before the fit, does not conform. Culling of objects at `zEffective <= -focalLength` is unchanged from 17.6 and raises no diagnostic. Under `orthographic`, `zEffective` still drives sorting, parallax, and fog but never scale. + +**Parallax multiplies the camera translation only** — never its zoom or rotation, as the position of `p` inside `V` shows — so a distant layer drifts more slowly than a near one without also being scaled or tilted differently, which is what makes the deep parallax field of PRD 130 item 1 hold together. A layer with `parallax: 0` is **pinned against camera translation**: the trailing `T` becomes the identity while `S(zoom)` and `R(-rotation)` still apply. It is the mechanism for a fixed overlay in a panning scene, and an exhibit that also wants that overlay unzoomed and unrotated declares a camera that does not zoom or rotate. Nothing in 0.1 exempts a layer from the whole camera. + +**Nonuniform `stretch`, and scalar appearance dimensions.** Under `stretch`, `F` carries a nonuniform scale that is applied **after** an object's own rotation, so a rotated square becomes a rotated parallelogram and a circle becomes an axis-aligned ellipse. That is the documented consequence of asking for an aspect-ratio change, not a defect, and it is the reason `contain` is the default. Appearance dimensions that are scalars with no axis — `strokeWidth`, `strokeDash` lengths, `strokeDashOffset`, `pointSize`, `style.blur`, `glow.radius`, `shadow.blurRadius`, `shadow.offsetX`, `shadow.offsetY`, and `text.size` and `letterSpacing` — cannot follow a nonuniform matrix, so they take the single uniform factor + +```text +g = zoom * k * sqrt(sx * sy) +``` + +which equals `zoom * k * s` whenever the fit is uniform, and is the geometric mean of the two axes when it is not. `dpr_effective` is not in `g`: it is applied by `B` to the whole frame, including these dimensions, and counting it twice would double every stroke on a retina display. **`projection` is not automatable and not externally addressable.** Switching projection mid-run has no continuous meaning: there is no value between `orthographic` and `perspective`, so an interpolating mechanism cannot express the change and a stepping one would snap the whole scene. `x`, `y`, `zoom`, `rotation`, and `focalLength` are automatable and are in the 8.1 table by 19.1; `projection` is authored once. This is the direct reading of PRD 87, which says camera *properties* may be automated or bound to state and modulators, in a document whose other enums are likewise fixed at authoring. @@ -2540,6 +3105,18 @@ Every entry is `{ "type": , "enabled": ValueSpec, ...parameters } Every numeric parameter above is a ValueSpec\, is automatable by 19.1, and is an 8.1 binding and override target by the table in that subsection. `color` parameters and `type` are authored once. A parameter outside its range is `ERR_OUT_OF_BOUNDS` when it is a literal at import and clamped with no diagnostic when it resolves out of range at instantiation, which is the staging rule 14.5 fixes for audio and the same rule applies here. +**Scene-unit radii become device pixels by one stated conversion.** `blur.radius` and `bloom.radius` are authored in scene units, and the frame they act on is measured in device pixels, so the conversion is part of the contract: + +```text +r_device = r_scene * zoom * sqrt(sx * sy) * dpr_effective +``` + +This is the uniform appearance factor `g` of 19.3 with `dpr_effective` (19.5) folded in, and with the two terms a post-effect **cannot** have deliberately absent: an effect has no object, so the perspective factor `k` is `1`; and it runs after every layer is composited, so no layer's `parallax` enters it. Borrowing either — taking the depth of the nearest object, or the parallax of the last layer drawn — would make a full-frame effect depend on scene content and is specifically excluded. Under nonuniform `stretch` the geometric mean `sqrt(sx * sy)` is what a radius with no axis can take, exactly as for a stroke width. Where the effect is computed at a reduced resolution factor `q` (below), the radius used inside that buffer is `r_device * q`, so the upsampled result matches the full-resolution one to within the approximation the warning already declares. + +**Effect parameters resolve once, at activation.** Every ValueSpec in `visuals.effects`, including each entry's `enabled`, is resolved at exhibit activation — the effects chain is exhibit-scoped, so activation is its instantiation boundary (17.14). Sampling order is array order, and within one entry document field order after `type`, so a fixture's stream position after the chain is fixed. `color` parameters and `type` are authored literals, not ValueSpecs (17.12). The resolved value is the base the shared pipeline of 8.1 and the automation of 19.1 then compose over; `enabled` has no automation stage, being boolean, but is a binding and `override` target through its entry's row. + +**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. @@ -2561,7 +3138,7 @@ PRD 120 requires that resource ceilings be centralized rather than scattered thr | 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`, raised once per ceiling per second, never once per shed item | +| **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. @@ -2578,9 +3155,18 @@ An authoring bound fails loudly because the author wrote a number and should lea | 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, to a floor of `1` | -| Automation records, all scopes | `128` tracks / `2048` points | Authoring bound (19.1), not a shed | -| Logical ticks of shed before the runtime reports degradation | `120` consecutive | Raise `WARN_VISUAL_CEILING` naming the sustained ceiling | +| Backing store | `4096 x 4096` device pixels | Lower the multiplier until it fits, below `1` where a CSS display larger than `4096` requires it (see below) | +| Automation records, live | `128` tracks / `2048` points | Refuse the spawn atomically (19.1). Never drop a track from an admitted instance. The same numbers are also an authoring bound over the *declared* records, checked at import | +| Logical ticks of shed before the runtime reports degradation | `120` consecutive | Raise `WARN_VISUAL_CEILING` for that key with a `sustained` detail, under the same cadence (below) | + +**One diagnostic cadence, for every ceiling and every shed.** Three rate rules appeared across sections 17-19 — "once" per refused spawn, "once per ceiling per second", and a report after `120` sustained ticks — and they are one rule: + +* Every resource diagnostic is keyed by the pair **(code, subject)**, where the subject is the ceiling for `WARN_VISUAL_CEILING` and the effect instance, paint instance, or object for `WARN_VISUAL_APPROXIMATION`. A refused spawn's subject is the spawned-instance ceiling, not the template, so a burst of refusals across templates is one key. +* A key is raised **at most once per logical second**, measured on the clock of 9.1, however many items were shed or spawns refused in it. This replaces every bare "once" written elsewhere for a resource condition; a per-instance "once for the life of the instance" cadence still governs the *capability* warnings of 17.12 and 19.4, which report a renderer fact rather than a resource state. +* A key counts **consecutive ticks on which it shed**. On reaching `120`, the next raise for that key carries a `sustained` detail naming the ceiling and the tick count. It does not bypass the per-second limit and it does not mint a second code: one condition, one code, one cadence, with metadata for the difference. +* One tick with no shed on a key resets both that key's consecutive counter and its rate limit, so a recovery followed by a new overload reports promptly rather than being suppressed by the previous burst. + +Approximation shedding — buffer refusal (17.12) and a skipped post-effect pass (19.4) — uses this cadence too, with `WARN_VISUAL_APPROXIMATION` as the code. **Ceilings from the other subsystems, restated for centralization.** @@ -2590,7 +3176,6 @@ An authoring bound fails loudly because the author wrote a number and should lea | Continuous sounds | `16` | 16.6 | | Expanded audio nodes and routes per sound | Per section 15.14 | 15.14 | | Audio component nesting | `8` | 15.15 | -| Resonator modes | Per section 14 | 15.10 | | 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 | @@ -2609,12 +3194,33 @@ An authoring bound fails loudly because the author wrote a number and should lea | Declared fields / referenced per system | `8` / `4` | 18.7 | | Trail `length` | `128` | 18.8 | | `maxLinks` per system | `1024` | 18.8 | +| `nearest` links per item | `8` | 18.8 | +| Pairwise-linked population | `256` | 18.8 | +| Path commands per object | `512` | 17.13 | +| Text characters after resolution | `256` | 17.13 | +| `strokeDash` entries | `8` | 17.12 | +| Burst entries per system | `16` | 18.2, 18.4 | +| Grid `columns` and `rows` | `256` | 18.3 | +| Custom oscillator partials | `64` | 14.7 | +| Resonator modes | `16` | 15.10 | +| Visual automation points per track | `256` | 19.1 | +| Declared visual systems | `64` | 17.3 | +| Expanded static visual objects | `16384` | 17.3 | | Post-effect entries | `4` | 19.4 | | Blur and bloom radius | `32` scene units | 19.4 | | Spawned instances | `64` | 19.2 | +**Static draw load is bounded too.** Every ceiling above bounds a *dynamic* population; none of them bounds the work an exhibit declares outright. A document with sixty repeaters of `1024` copies each, or a component expanded ten thousand times, is legal under every other limit in this table and would never be shed, because 19.5's frame-time governance never sheds an authored persistent object or a declared system. Two authoring bounds close that gap: + +* **Declared visual systems:** at most `64` entries in `visuals.systems`, counting spawned templates once each. More is `ERR_VISUAL_LIMIT_EXCEEDED`. +* **Expanded static visual objects:** at most `16384` across the whole document, counted after component expansion and repeater expansion. A `graphic` system contributes its object tree; a `component` object contributes its own expansion; a `repeater` contributes `count` times the object count of its `repeat` tree, using the *literal* `count` where it is one and its `1024` bound where it is procedural; a `particles` system or `emitter` contributes its `render` or `emit` tree once, because its live population is already bounded above. More is `ERR_VISUAL_LIMIT_EXCEEDED`, at import where every count is literal and at the system's instantiation boundary otherwise. + +Both values are provisional in exactly the sense the aggregate table's values are: their *shape* — an authoring bound that rejects at import rather than a shed that degrades in front of the viewer — is normative now, and the numbers are confirmed or replaced by the slice 4h GC6 measurement. + Scenario instances and timeline expansions are the two entries of PRD 120's list that no contract has yet fixed; they are set with the scenario contract in Phase 6 and belong in this table when they are. Naming the gap here is what keeps the table the single center rather than a snapshot. +**Large displays, and the one place the multiplier goes below `1`.** The two rows above conflict on a display wider or taller than `4096` CSS pixels: no multiplier at or above `1` can keep the backing store within `4096 x 4096`. The backing-store cap wins, and the multiplier is lowered **below** `1` — the frame is rendered at reduced resolution into the largest conforming backing store and upsampled to the display — raising `WARN_VISUAL_APPROXIMATION` once per resolution change, not once per frame. The multiplier is never raised above `min(devicePixelRatio, 2)`, so a device reporting a `devicePixelRatio` below `1` keeps it. Refusing to draw, or exceeding the platform's maximum surface size, are both worse answers than a soft frame, and PRD 119 requires the runtime to stay usable as resources run short. + **Lowering, never raising.** A runtime may lower any ceiling in either table on a weaker device, exactly as 16.6 permits for voices. A document can never raise one: there is no field, parameter, or capability by which an exhibit requests a larger budget, and a document that could would be able to defeat the protection PRD 89 exists to provide. **Frame-time governance** (PRD 119). The runtime monitors frame interval and sheds work in the order of the aggregate table above — particles first, then emitted items, then links, then trail history, then compositing buffers, then post-effect passes — before it lowers the device-pixel-ratio multiplier, and it never sheds an authored persistent object or a declared system. The order is normative so that a degraded frame is the same degraded frame everywhere. The *thresholds* that trigger governance are provisional and are derived from the slice 4h GC6 measurement, in the same relationship 16.7 has with the audio protection values: the shape is normative now, the numbers are confirmed by measurement. Until 4h runs, every value in the aggregate table is a considered estimate and nothing more, and no evidence record may describe them as measured. @@ -2641,9 +3247,9 @@ Automated, and executable without a display measurement: 3. A `visuals.automation` track's `at` is measured from activation and a `visuals.systems..automation` track's from that system's instantiation boundary: two spawns of one template `4s` apart produce identical curves offset by `4s`. 4. A track naming a target in the other scope, another system, an undeclared layer, or an out-of-range effect index is `ERR_INVALID_REFERENCE`; a target outside the 19.1 registry — a `color`, a `visible`, a field `strength`, a behavior parameter — is `ERR_UNSUPPORTED_TARGET`. 5. Two tracks on one expanded visual target are `ERR_AUTOMATION_CONFLICT`; a behavior writing a channel a track targets on the same object is `ERR_AUTOMATION_CONFLICT`; the same behavior on an object with no track on that channel passes and composes by the 18.6 rule. -6. `129` tracks or `2049` total points across both scopes and live spawned instances is `ERR_VISUAL_LIMIT_EXCEEDED`; `128` and `2048` pass. +6. The authoring bound counts declared records once per declaration: `129` declared tracks or `2049` declared points is `ERR_VISUAL_LIMIT_EXCEEDED` at import, and `128` and `2048` pass however many times a template is later spawned. The live budget is separate: repeated spawns of a legal template up to the boundary succeed, the spawn that would cross it is refused **atomically** — no track allocated, no partial instance, no spawn ordinal consumed, `WARN_VISUAL_CEILING` under the 19.5 cadence, not a scenario failure — and disposing a live instance frees its records so a later spawn succeeds again. No admitted instance ever runs with a subset of its declared tracks. 7. Each of the four new section 8.1 rows takes binding, override, and — where the row permits — automation and modulation, through the shared pipeline, with masking and release behaving as 8.3 and 8.4 require; a `set` or `override` on any per-object, particle, emitter, behavior, or field property is `ERR_UNSUPPORTED_TARGET`, and trace 19 of 18.10 still passes unchanged. -8. A lifecycle field on a `persistent` system is `ERR_UNKNOWN_FIELD`; `cancelWithScenario: false` without `ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. +8. A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of `lifetime`, `release`, `ownership`, `inputs`, or `cancelWithScenario` at the top level of a system of either lifecycle; `spawn.cancelWithScenario: false` without `spawn.ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. A spawned `particles` system with both a top-level `lifetime` and a `spawn.lifetime` resolves them independently: its particles expire on the first and its instance releases on the second, and a spawned `repeater` with `spawn.lifetime` and no top-level `lifetime` is legal. 9. Every permitted state transition of 19.2 is exercised and every forbidden one rejected; a `release` of `0ms` still passes through `RELEASING` for one tick; a second `remove` on a `DISPOSED` instance is a no-op. 10. Two spawns of one template resolve independently from `#`, and one seed reproduces both exactly across two runs and across two frame rates. 11. A scenario-owned spawned instance is released at scenario cleanup; a `persistent`-owned instance with `cancelWithScenario: false` survives it; a cleanup that exceeds the 10.2 deadline force-disposes with `WARN_CLEANUP_FORCED`. @@ -2653,7 +3259,7 @@ Automated, and executable without a display measurement: 15. Each of the seven effects produces its documented change on a known frame; array order is honored (`bloom` before and after `color-adjust` differ); a disabled effect costs no pass; a `type` outside the seven is `ERR_INVALID_EFFECT_TYPE`; a fifth entry is `ERR_VISUAL_LIMIT_EXCEEDED`. 16. `grain` consumes no procedural stream: with grain enabled, two runs of one seed produce identical decision traces, and disabling grain does not shift any later procedural sample. 17. A `blur` or `bloom` computed at reduced resolution raises `WARN_VISUAL_APPROXIMATION` once per effect instance and not once per frame; an unavailable effect is skipped, not substituted. -18. Each aggregate ceiling of 19.5 sheds by its documented rule under a synthetic overload: the shed is deterministic, the exhibit keeps running, `WARN_VISUAL_CEILING` is raised at most once per ceiling per second, and no authored persistent object or declared system is shed. +18. Each aggregate ceiling of 19.5 sheds by its documented rule under a synthetic overload: the shed is deterministic, the exhibit keeps running, and no authored persistent object or declared system is shed. The single diagnostic cadence holds under all four of its cases — many refusals within one second raise one `WARN_VISUAL_CEILING` for that key; `120` consecutive shedding ticks add the `sustained` detail without minting a second code and without bypassing the per-second limit; one clear tick resets both the counter and the rate limit; and a second overload after that recovery reports promptly. Buffer refusal (17.12) and a skipped post-effect pass (19.4) follow the same cadence with `WARN_VISUAL_APPROXIMATION`, while the capability warnings of 17.12's conic fallback and 19.4's reduced-resolution `blur` stay once per instance. 19. No document can raise a ceiling: every field, parameter, and action that names a count is checked to clamp or reject, never to widen. User-observed, and **not** satisfiable by the above: @@ -2661,4 +3267,14 @@ User-observed, and **not** satisfiable by the above: 20. The PRD 130 visual acceptance challenge passes in full, judged on a real display. This is trace 15 of 17.16 and trace 20 of 18.10, restated for locality; it is one gate, not three. 21. The early combined GC6 benchmark of slice 4h at `1920 x 1080` runs its 30-second warm-up and 120-second window on recorded hardware, and its results either confirm the aggregate ceilings of 19.5 or replace them. +**Which slice carries which contract.** Sections 17, 18, and 19 were all written before slice 4d, so a section 17 rule that cites 19.3 is a forward reference within one contract, not a circular dependency between implementations. What each slice must carry, and what it must not claim: + +| Slice | Contracts it must implement | Contracts it must carry as data without executing | +| --- | --- | --- | +| **4d** | Scene and `fit` (17.4), the composition chain, camera matrix, `parallax`, projection, and the `focalLength` bound of 19.3 as a **static** view; layers, transforms, the fourteen primitives, appearance, compositing and the buffer pool (17.5-17.13); depth, sortable units, and fog (17.6) | The `visuals.automation` and system `automation` arrays, `spawn`, `fields`, `effects`, and every procedural system's schema: parsed, validated, and rejected correctly, drawn not at all | +| **4e** | Components, particles, distributions, emitters, repeaters, behaviors, fields, trails, links (18.1-18.8), with 4d's renderer drawing them | Automation, lifecycle, and effects execution | +| **4f** | Automation into the shared 8.1 pipeline, lifecycle and `spawn`/`remove`, camera and effect *execution*, ceilings and shedding (19.1-19.5) | — | + +Trace 6 of 17.16 — the perspective factor at three depths — stays in 4d. Depending on 19.3's static contract is what makes it testable; dropping it to avoid the dependency would remove the one automated check on the composition chain, which is where the unit-mismatch defects of this review lived. A parsed stub is never a passed runtime trace: a slice reports a trace as passed only when the behavior it names actually ran. + Traces 1-19 belong to slice 4f. Traces 20 and 21 close slice 4h. Phase 4 is not accepted until both do, no matter how many automated traces pass, and the ceilings of 19.5 are not measured until trace 21 says so. diff --git a/tools/verify-spec-contract.py b/tools/verify-spec-contract.py new file mode 100644 index 0000000..a817dea --- /dev/null +++ b/tools/verify-spec-contract.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Contract verification harness for XZBT_0-1_Format_Specification.md. + +Checks, over the whole document: + 1. every ERR_*/WARN_*/INFO_* code used resolves to a row of the section 7 table; + 2. every section 7 row is used at least once outside the table; + 3. every numeric cross-reference (e.g. "section 19.5", "19.5", "17.16.14") resolves + to a real heading or a numbered trace item; + 4. code fences balance; + 5. markdown table rows are well formed (consistent column count per table, + discounting escaped pipes). + +Exit status is non-zero when any check fails. +""" +import io, os, re, sys, collections + +SPEC = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "docs", "XZBT_0-1_Format_Specification.md") + +def main(): + text = io.open(SPEC, encoding="utf-8").read() + lines = text.split("\n") + problems = [] + + # ---- fences ------------------------------------------------------- + fence_lines = [i for i, l in enumerate(lines, 1) if l.strip().startswith("```")] + if len(fence_lines) % 2: + problems.append("unbalanced code fences: %d fence markers" % len(fence_lines)) + in_fence = set() + for a, b in zip(fence_lines[0::2], fence_lines[1::2]): + in_fence.update(range(a, b + 1)) + + # ---- headings ----------------------------------------------------- + headings = {} + for i, l in enumerate(lines, 1): + m = re.match(r"^#{2,4}\s+(\d+(?:\.\d+)*)\.?\s", l) + if m: + headings[m.group(1)] = i + # numbered trace items inside a "Required traces" subsection + trace_items = collections.defaultdict(set) + current = None + for i, l in enumerate(lines, 1): + m = re.match(r"^#{2,4}\s+(\d+(?:\.\d+)*)\.?\s", l) + if m: + current = m.group(1) + continue + if current and re.match(r"^(\d+)\.\s", l) and i not in in_fence: + trace_items[current].add(re.match(r"^(\d+)\.\s", l).group(1)) + + # ---- section 7 diagnostic table ------------------------------------ + declared = set() + sec7 = headings.get("7") + if not sec7: + problems.append("section 7 heading not found") + else: + i = sec7 + while i < len(lines) and not re.match(r"^## 8\.", lines[i]): + m = re.match(r"^\|\s*`((?:ERR|WARN|INFO)_[A-Z0-9_]+)`\s*\|", lines[i]) + if m: + declared.add(m.group(1)) + i += 1 + + used = collections.Counter() + for i, l in enumerate(lines, 1): + if sec7 and sec7 <= i < sec7 + 60 and re.match(r"^\|\s*`(?:ERR|WARN|INFO)_", l): + continue + for code in re.findall(r"`((?:ERR|WARN|INFO)_[A-Z0-9_]+)`", l): + used[code] += 1 + + for code in sorted(used): + if code not in declared: + problems.append("undeclared diagnostic code used: %s" % code) + declared_only = sorted(c for c in declared if c not in used) + + # ---- cross references --------------------------------------------- + xrefs = collections.Counter() + for i, l in enumerate(lines, 1): + if i in in_fence: + continue + if re.match(r"^\|?\s*`?(?:ERR|WARN|INFO)_", l): + pass + scan = re.sub(r"`[^`]*`", " ", l) # inline code spans hold data, not references + for m in re.finditer(r"(?