diff --git a/docs/evidence/phase4/2026-09-06-phase4f-execution.md b/docs/evidence/phase4/2026-09-06-phase4f-execution.md
new file mode 100644
index 0000000..c5beb52
--- /dev/null
+++ b/docs/evidence/phase4/2026-09-06-phase4f-execution.md
@@ -0,0 +1,24 @@
+# Slice 4f: automation, lifecycle, effects and ceilings
+
+Implemented September 6, 2026. Production integration is in `visual-engine.js`, `visual-automation.js`, `visual-lifecycle.js`, `visual-effects.js`, `visual-subsystem.js`, the shared resolver and action executor.
+
+Automation uses the audio interpolation implementation and adds visual repeat/ping-pong time folding. Public numeric visual targets enter the existing binding/automation/override/modulation/clamp stages. Internal graphic and procedural channels remain private. Tracks resolve once in scope order, including independent spawn scope sampling; behavior collisions reject at import. Viewport camera defaults continue to participate in the pipeline.
+
+Spawn and remove actions use PRD 28's `target`, `with`, optional `lifetime`, and `instances.*` addressing. Instances enter the layer on their next tick, retain independent resource-owner and originating-scenario relationships, fade without rewriting authored opacity, and reclaim tracks and item state at disposal/failure. Instance, track and point budget refusal is atomic and does not consume an ordinal. Runtime failure is isolated to its system. Scenario orchestration remains Phase 6; its cleanup integration surface is `VisualEngine.cleanup(owner)`.
+
+All seven effects execute against frame pixels in authored order. Grain has no access to seeded streams. Blur/bloom use a separable approximation and report it once per effect. Unavailable effects skip with a diagnostic. The specification's stray sentence exposing boolean `enabled` conflicted with the four-family numeric target table; it now explicitly follows that table and remains once-resolved.
+
+Aggregate particle, emitter, trail, link and field budgets enforce the section 19.5 shedding rules. Field work is ordered nearest-first; particle/item eviction chooses the largest population and its oldest item. Existing buffer/backing-store enforcement and new effect pass and spawn/automation admission use centralized values. Diagnostic accounting counts logical ticks rather than repeated refusals in a tick.
+
+`test/phase4-execution.test.mjs` adds twenty execution tests: curves/modes, loops, shared override release, sampling order, lifecycle edges, zero release, ownership/forced cleanup, atomic budgets, actions, known effect pixels and order, stream independence, unavailable effects, diagnostic recovery, population/history/link/field overloads, resolved count limits and failure isolation. Existing camera/projection and structural validation tests remain in the renderer/contract suites.
+
+No hardware performance result is claimed. Ceiling values and frame-time governance thresholds remain provisional until 4h. Post-effect pixel checks establish operation, not perceptual equivalence on a real display. No live audio acceptance or full scenario execution is claimed.
+
+## Final verification
+
+All **223 checks pass** across fourteen test files, including the twelve GC2 fixture checks and 109 Phase 4 tests. The sandbox blocks Node's default child-process test isolation (`spawn EPERM`), so each file was run separately with `node --test --test-isolation=none`. Running GC2 in the same process as other suites is insufficient because its legacy runner exits the process; the separate invocations avoid silently skipping later tests.
+
+Production and acceptance build determinism and bundled JavaScript syntax pass; `git diff --check` is clean. Generated artifact SHA-256 values:
+
+- `XZBT.html`: `46619d8ff2fbadf188136409d5cfe658bf5492ba0ba111d58ad488e95b343770`
+- `prototypes/phase4/XZBT-visual-acceptance.html`: `4298121998e7532f0824139c48ccab6b8c022ccf6a8079cf3672cded4bc6db74`
diff --git a/prototypes/phase4/README.md b/prototypes/phase4/README.md
new file mode 100644
index 0000000..86c78f0
--- /dev/null
+++ b/prototypes/phase4/README.md
@@ -0,0 +1,9 @@
+# Phase 4 visual challenge
+
+Open `XZBT-visual-acceptance.html` directly in Chrome. It embeds the production visual runtime and all fourteen PRD 130 challenge fixtures plus the first visuals for Exhibits A–D; no server or network is needed.
+
+Select a composition, let it run, pause or restart its seed, and record whether the named visual is convincingly expressed. Challenge 10 can be spawned repeatedly. Challenge 12 has a **synthetic audio signal** slider; Exhibit D has a state slider. These exercise real binding paths but do not establish live audio or full reference-exhibit acceptance. Text labels remain static under the 0.1 contract.
+
+Export observations with browser, display, hardware details and any artifacts you see. Visual judgment remains user-performed. This page is not the frozen GC6 benchmark; slice 4h still requires its 30-second warm-up and 120-second measurement on recorded reference hardware.
+
+Rebuild using `npm run build:visual-acceptance`. Individual JSON exhibits are written to `exhibits/visual-challenges` and `exhibits/exhibit-{a,b,c,d}.xzbt` and can also be imported into `XZBT.html`.
diff --git a/prototypes/phase4/XZBT-visual-acceptance.html b/prototypes/phase4/XZBT-visual-acceptance.html
new file mode 100644
index 0000000..bef9997
--- /dev/null
+++ b/prototypes/phase4/XZBT-visual-acceptance.html
@@ -0,0 +1,9931 @@
+
+
XZBT Visual Challenge
+
Visual Challenge / XZBT 0.1
+
This page supplies synthetic audio signals for reproducible visual checks. It does not establish live audio acceptance or benchmark results. Judge the composition on your display and record observations below.
+
\ No newline at end of file
diff --git a/src/runtime/app.js b/src/runtime/app.js
index fa578eb..4483aa0 100644
--- a/src/runtime/app.js
+++ b/src/runtime/app.js
@@ -33,6 +33,10 @@ export class XZBTApplication {
onUpdate: (engine) => {
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
},
+ onTick: (step) => {
+ if (this.activation.current?.record.id !== record.id) return;
+ this.visual.advance(step);
+ },
// The renderer runs at display rate; the simulation stays on the fixed
// logical tick (9.1), so a frame draws the most recent tick's state.
onFrame: (engine) => {
@@ -179,6 +183,7 @@ export class XZBTApplication {
rng: current.performance.rng
});
canvas.hidden = engine === null;
+ current.performance.actions.visual = engine;
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
} catch (error) {
canvas.hidden = true;
@@ -244,11 +249,14 @@ export class XZBTApplication {
this.audio.setMasterVolume(Number(element('master-volume').value));
}
await this.audio.unlock();
+ current.performance.setAudio(this.audio);
this.renderAudio();
}
async disposeAudio() {
if (!this.audio) return;
+ const current = this.activation.current;
+ if (current) current.performance.setAudio(null);
await this.audio.dispose();
this.audio = null;
this.renderAudio();
diff --git a/src/runtime/performance.js b/src/runtime/performance.js
index 361a374..a605803 100644
--- a/src/runtime/performance.js
+++ b/src/runtime/performance.js
@@ -1,6 +1,7 @@
import { ActionExecutor } from './actions.js';
import { SeededRNG } from './rng.js';
import { ResolutionEngine } from './resolution.js';
+import { CadenceSubsystem } from './cadence.js';
export class CommonGrammarPerformance {
constructor(record, rootSeed, options = {}) {
@@ -12,12 +13,22 @@ export class CommonGrammarPerformance {
// Called once per animation frame, after any logical ticks. A frame draws
// the most recent tick's state and advances nothing (9.1, 18.2).
this.onFrame = options.onFrame ?? (() => {});
+ // Called once per logical tick, with the fixed step of 9.1. Procedural
+ // motion advances here and nowhere else.
+ this.onTick = options.onTick ?? (() => {});
this.engine = new ResolutionEngine(record.document, this.rng, {
diagnostics: this.diagnostics,
initialParameters: options.initialParameters,
onParameterChange: options.onParameterChange
});
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
+ this.cadence = new CadenceSubsystem({
+ document: record.document,
+ rng: this.rng,
+ resolution: this.engine,
+ audio: options.audio ?? null,
+ diagnostics: this.diagnostics
+ });
this.state = 'prepared';
this.resources = new Set();
this.frameRequest = null;
@@ -57,7 +68,10 @@ export class CommonGrammarPerformance {
const step = 1000 / 60;
let ticks = 0;
while (this.accumulator + 1e-9 >= step && ticks < 8) {
+ this.actions.resetBudget();
this.engine.advance(step);
+ this.cadence.advance(step);
+ this.onTick(step, this.engine);
this.accumulator -= step;
ticks += 1;
}
@@ -70,6 +84,11 @@ export class CommonGrammarPerformance {
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
}
+ setAudio(audio) {
+ this.actions.audio = audio;
+ this.cadence.setAudio(audio);
+ }
+
setParameter(id, value) {
const result = this.engine.setParameter(id, value);
this.onUpdate(this.engine);
diff --git a/src/runtime/visual-automation.js b/src/runtime/visual-automation.js
new file mode 100644
index 0000000..1819534
--- /dev/null
+++ b/src/runtime/visual-automation.js
@@ -0,0 +1,37 @@
+import { RuntimeFault } from './types.js';
+import { sampleAutomationTrack, automationValueAt, applyAutomationMode, resolveNumericStages } from './audio-automation.js';
+import { visualGetPath, visualSetPath } from './visual-motion.js';
+
+export function visualLocalTracks(definitions, root, evaluate, warn) {
+ return (definitions ?? []).map(definition => {
+ const track = sampleAutomationTrack(definition, evaluate, warn);
+ let object = root, property = definition.target;
+ if (Array.isArray(root)) {
+ const parts = property.split('.');
+ const first = parts.shift();
+ object = root.find(node => node.key === first);
+ if (!object) {
+ throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${first}' not found.`);
+ }
+ while (object?.children?.some(node => node.key === parts[0])) {
+ const child = parts.shift();
+ object = object.children.find(node => node.key === child);
+ }
+ if (!object) {
+ throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${definition.target}' not found.`);
+ }
+ property = parts.join('.');
+ object = object.baseRaw;
+ }
+ const authored = visualGetPath(object, property);
+ const base = authored ?? (/opacity|scale|zoom/.test(property) ? 1 : 0);
+ return { track, object, property, base };
+ });
+}
+
+export function advanceVisualTracks(tracks, milliseconds) {
+ for (const { track, object, property, base } of tracks ?? []) {
+ const value = resolveNumericStages(base, { automation: lower => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) });
+ visualSetPath(object, property, property === 'drag' ? Math.max(0, Math.min(1, value)) : property === 'rate' ? Math.max(0, value) : value);
+ }
+}
diff --git a/src/runtime/visual-effects.js b/src/runtime/visual-effects.js
new file mode 100644
index 0000000..d33368e
--- /dev/null
+++ b/src/runtime/visual-effects.js
@@ -0,0 +1,94 @@
+// Frame-only post processing. No scene or procedural random stream is accessible here.
+import { parseColor } from './visual-math.js';
+
+function visualBoxBlur(source, width, height, radius) {
+ const maxDeviceRadius = Math.min(Math.max(width, height), 128);
+ const r = Math.min(maxDeviceRadius, Math.max(0, Math.round(radius)));
+ if (!r) return new Uint8ClampedArray(source);
+ const horizontal = new Float32Array(source.length), output = new Uint8ClampedArray(source.length);
+ // Sliding windows keep work linear in pixel count even for large projected radii.
+ for (let axis = 0; axis < 2; axis += 1) {
+ const input = axis ? horizontal : source, target = axis ? output : horizontal;
+ const major = axis ? width : height, minor = axis ? height : width;
+ const offset = (a, b, c) => ((axis ? b * width + a : a * width + b) * 4 + c);
+ for (let a = 0; a < major; a += 1) for (let c = 0; c < 4; c += 1) {
+ let sum = 0;
+ for (let k = -r; k <= r; k += 1) sum += input[offset(a, Math.max(0, Math.min(minor - 1, k)), c)];
+ for (let b = 0; b < minor; b += 1) {
+ target[offset(a, b, c)] = sum / (2 * r + 1);
+ sum += input[offset(a, Math.min(minor - 1, b + r + 1), c)] - input[offset(a, Math.max(0, b - r), c)];
+ }
+ }
+ }
+ return output;
+}
+
+export function applyVisualEffect(pixels, width, height, effect, seconds = 0) {
+ const output = new Uint8ClampedArray(pixels);
+ const color = parseColor(effect.color ?? '#000000');
+ if (effect.type === 'blur') return visualBoxBlur(pixels, width, height, effect.deviceRadius ?? effect.radius ?? 4);
+ if (effect.type === 'bloom') {
+ const bright = new Uint8ClampedArray(pixels.length);
+ for (let i = 0; i < pixels.length; i += 4) {
+ const luminance = (pixels[i] * 0.2126 + pixels[i + 1] * 0.7152 + pixels[i + 2] * 0.0722) / 255;
+ const contribution = luminance > (effect.threshold ?? 0.7) ? 1 : 0;
+ for (let c = 0; c < 3; c += 1) bright[i + c] = pixels[i + c] * contribution;
+ bright[i + 3] = pixels[i + 3];
+ }
+ const glow = visualBoxBlur(bright, width, height, effect.deviceRadius ?? effect.radius ?? 8);
+ for (let i = 0; i < pixels.length; i += 4) for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, pixels[i + c] + glow[i + c] * (effect.intensity ?? 0.6)));
+ return output;
+ }
+ const theta = (effect.hueRotate ?? 0) * Math.PI / 180, cs = Math.cos(theta), sn = Math.sin(theta);
+ for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
+ const i = (y * width + x) * 4;
+ let amount = effect.amount ?? 0, target = [color.r, color.g, color.b];
+ switch (effect.type) {
+ case 'fade': break;
+ case 'vignette': {
+ const d = Math.hypot((x + 0.5 - width / 2) / (width / 2), (y + 0.5 - height / 2) / (height / 2)) / Math.SQRT2;
+ const edge = Math.max(0.000001, effect.softness ?? 0.5);
+ const u = Math.max(0, Math.min(1, (d - (effect.radius ?? 0.75)) / edge));
+ amount = (effect.amount ?? 0.5) * u * u * (3 - 2 * u); break;
+ }
+ case 'scanlines': {
+ const phase = ((y / (effect.spacing ?? 3) + seconds * (effect.speed ?? 0)) % 1 + 1) % 1;
+ amount = phase < (effect.thickness ?? 0.5) ? (effect.amount ?? 0.3) : 0;
+ target = [0, 0, 0]; break;
+ }
+ case 'grain': {
+ const cell = effect.scale ?? 1;
+ let hash = Math.imul(Math.floor(x / cell) + 1, 374761393) ^ Math.imul(Math.floor(y / cell) + 1, 668265263) ^ Math.imul(Math.floor(seconds * (effect.speed ?? 24)), 1274126177);
+ hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
+ const noise = ((hash ^ (hash >>> 16)) >>> 0) / 4294967296 - 0.5;
+ for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] + noise * 255 * (effect.amount ?? 0.15);
+ continue;
+ }
+ case 'color-adjust': {
+ const [r, g, b] = [pixels[i], pixels[i + 1], pixels[i + 2]].map(v => v / 255);
+ const hue = [
+ (0.213 + cs * 0.787 - sn * 0.213) * r + (0.715 - cs * 0.715 - sn * 0.715) * g + (0.072 - cs * 0.072 + sn * 0.928) * b,
+ (0.213 - cs * 0.213 + sn * 0.143) * r + (0.715 + cs * 0.285 + sn * 0.140) * g + (0.072 - cs * 0.072 - sn * 0.283) * b,
+ (0.213 - cs * 0.213 - sn * 0.787) * r + (0.715 - cs * 0.715 + sn * 0.715) * g + (0.072 + cs * 0.928 + sn * 0.072) * b
+ ];
+ const l = hue[0] * 0.2126 + hue[1] * 0.7152 + hue[2] * 0.0722;
+ for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, (((l + (hue[c] - l) * (effect.saturation ?? 1)) * (effect.brightness ?? 1) - 0.5) * (effect.contrast ?? 1) + 0.5) * 255));
+ continue;
+ }
+ default: throw new Error(`Unavailable visual effect '${effect.type}'.`);
+ }
+ for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] * (1 - amount) + target[c] * amount;
+ }
+ return output;
+}
+
+export function renderVisualEffects(context, plan, warn = () => {}) {
+ for (const effect of plan.effects ?? []) {
+ try {
+ const frame = context.getImageData(0, 0, plan.backing.width, plan.backing.height);
+ frame.data.set(applyVisualEffect(frame.data, frame.width, frame.height, effect, plan.logicalMilliseconds / 1000));
+ context.putImageData(frame, 0, 0);
+ if (['blur', 'bloom'].includes(effect.type)) warn(effect, 'Separable blur approximation.');
+ } catch { warn(effect, 'Effect unavailable on this rendering surface; skipped.'); }
+ }
+}
diff --git a/test/phase4-execution.test.mjs b/test/phase4-execution.test.mjs
new file mode 100644
index 0000000..ee5ad98
--- /dev/null
+++ b/test/phase4-execution.test.mjs
@@ -0,0 +1,277 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { VisualEngine } from '../src/runtime/visual-engine.js';
+import { ResolutionEngine } from '../src/runtime/resolution.js';
+import { SeededRNG } from '../src/runtime/rng.js';
+import { ActionExecutor } from '../src/runtime/actions.js';
+import { VisualDiagnosticCadence } from '../src/runtime/visual-diagnostics.js';
+import { VisualInstance } from '../src/runtime/visual-lifecycle.js';
+import { applyVisualEffect, renderVisualEffects } from '../src/runtime/visual-effects.js';
+import { validateExhibit } from '../src/runtime/validator.js';
+
+const point = { type: 'point', position: { x: 10, y: 20 }, style: { fill: '#ffffff', pointSize: 3 } };
+const track = (target, a = 0, b = 1, extra = {}) => ({ target, points: [{ at: '0ms', value: a }, { at: '1s', value: b }], ...extra });
+function setup(visuals, extra = {}) {
+ const document = { xzbt: '0.1', meta: { id: 'execution-test', name: 'Execution test' }, runtime: { seed: 42 },
+ visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals }, ...extra };
+ const rng = new SeededRNG(42), resolution = new ResolutionEngine(document, rng);
+ const engine = new VisualEngine(document, { rng, resolution });
+ const tick = ms => { resolution.advance(ms); engine.advance(ms); };
+ return { document, engine, resolution, rng, tick, frame: () => engine.planFrame({ width: 100, height: 100 }) };
+}
+const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-8, `${actual} != ${expected}`);
+
+test('19.7.1 shared curves and modes drive actual graphic geometry', () => {
+ for (const interpolation of ['step', 'linear', 'smooth', 'exponential']) for (const mode of ['absolute', 'offset', 'scale']) {
+ const run = setup({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('p.position.x', 2, 8, { interpolation, mode })] } } });
+ run.tick(500);
+ const value = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5;
+ close(run.frame().layers[0].nodes[0].center[0], mode === 'offset' ? 10 + value : mode === 'scale' ? 10 * value : value);
+ }
+});
+
+test('19.7.2 repeat and finite ping-pong loops hold the correct endpoint', () => {
+ for (const mode of ['repeat', 'ping-pong']) {
+ const run = setup({ camera: { zoom: 1 }, automation: [track('camera.zoom', 1, 3, { loop: { mode, count: 2 } })], systems: {} });
+ run.tick(1500); close(run.frame().camera.zoom, 2);
+ run.tick(3500); close(run.frame().camera.zoom, mode === 'repeat' ? 3 : 1);
+ }
+});
+
+test('4e rendered graphic motion, nested repeater motion, and morph reach geometry', () => {
+ const run = setup({ systems: {
+ s: { type: 'graphic', content: {
+ p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 10, y: 0 } }] },
+ a: { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 10, y: 0 }], behaviors: [{ type: 'morph', to: 'b', duration: '1s' }] },
+ b: { type: 'polyline', visible: false, points: [{ x: 0, y: 10 }, { x: 20, y: 10 }] }
+ } },
+ r: { type: 'repeater', count: 1, repeat: { ...point, behaviors: [{ type: 'drift', velocity: { x: 20, y: 0 } }] } }
+ } });
+ run.tick(500);
+ const nodes = run.frame().layers[0].nodes;
+ close(nodes[0].center[0], 15);
+ close(nodes[1].subpaths[0].start[1], 5);
+ close(nodes[2].children[0].center[0], 20);
+});
+
+test('19.7.4 behavior and automation collision is rejected at import', () => {
+ const run = setup({ systems: {} });
+ run.document.visuals.systems.s = { type: 'graphic', content: { p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 1 } }] } }, automation: [track('p.position.x')] };
+ assert.ok(validateExhibit(run.document).errors.some(error => error.code === 'ERR_AUTOMATION_CONFLICT'));
+});
+
+test('19.7.4 cross-scope, undeclared, and out-of-registry targets raise documented errors', () => {
+ const doc = visuals => ({ xzbt: '0.1', meta: { id: 't', name: 'T' }, runtime: { seed: 42 }, visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals } });
+
+ // Cross-scope target from exhibit to system: ERR_INVALID_REFERENCE
+ const r1 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('systems.s.p.position.x')] });
+ assert.ok(validateExhibit(r1).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
+
+ // Cross-scope target from system to camera: ERR_INVALID_REFERENCE
+ const r2 = doc({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('camera.zoom')] } } });
+ assert.ok(validateExhibit(r2).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
+
+ // Undeclared layer in exhibit scope: ERR_INVALID_REFERENCE
+ const r3 = doc({ layers: { main: {} }, systems: { s: { type: 'graphic', layer: 'main', content: { p: point } } }, automation: [track('layers.missing.opacity')] });
+ assert.ok(validateExhibit(r3).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
+
+ // Out-of-range effect index: ERR_INVALID_REFERENCE
+ const r4 = doc({ effects: [{ type: 'blur', radius: 4 }], systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('effects[5].radius')] });
+ assert.ok(validateExhibit(r4).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
+
+ // Out-of-registry target (scene.background is a color, not automatable): ERR_UNSUPPORTED_TARGET
+ const r5 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('scene.background')] });
+ assert.ok(validateExhibit(r5).errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'));
+});
+
+test('19.7.3 system-scope track at is measured from instantiation (two spawns offset by 4s)', () => {
+ const visuals = {
+ systems: {
+ s: {
+ type: 'graphic',
+ lifecycle: 'spawned',
+ content: { p: { ...point, position: { x: 0, y: 0 } } },
+ automation: [track('p.position.x', 0, 100)]
+ }
+ }
+ };
+ const run = setup(visuals);
+ const a = run.engine.spawn('s');
+ run.tick(4000);
+ const b = run.engine.spawn('s');
+ // At t=4000ms, b has age 0. Advance 500ms further (halfway through the 1s track).
+ run.tick(500);
+ // b's age is 500ms, so its position.x should be 50.
+ close(b.system.objects[0].position.x, 50);
+});
+
+test('19.7.7 override masks moving visual automation and releases to its current value', () => {
+ const run = setup({ automation: [track('camera.zoom', 1, 3)], systems: {} });
+ const actions = new ActionExecutor(run.resolution);
+ const [result] = actions.execute([{ type: 'override', target: 'visuals.camera.zoom', value: 5, scope: 'duration', duration: '10s', release: '0ms' }]);
+ run.tick(500); close(run.frame().camera.zoom, 5);
+ run.resolution.overrides.beginRelease(result.instanceId); run.tick(100);
+ close(run.frame().camera.zoom, 2.2);
+});
+
+test('19.7.8-10 spawned templates activate next tick, fade, dispose and reproduce', () => {
+ const visuals = { systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { release: '1s' }, content: { p: { ...point, position: { x: { random: { min: 0, max: 100 } }, y: 20 }, style: { opacity: 0.4 } } } } } };
+ const first = setup(visuals), second = setup(visuals);
+ const a = first.engine.spawn('s'), b = second.engine.spawn('s');
+ assert.equal(first.frame().layers[0].nodes.length, 0);
+ first.tick(10); second.tick(10);
+ assert.deepEqual(first.frame().layers, second.frame().layers);
+ first.engine.remove(a.id); first.tick(500);
+ close(first.frame().layers[0].nodes[0].alpha, 0.2);
+ first.tick(500); assert.equal(a.state, 'DISPOSED');
+ first.engine.remove(a.id); assert.equal(first.engine.instances.size, 0);
+ assert.equal(b.state, 'ACTIVE');
+});
+
+test('19.7.9 every lifecycle edge is enforced, zero release lasts a tick', () => {
+ const edges = { CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'], RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: [] };
+ for (const [from, allowed] of Object.entries(edges)) for (const to of Object.keys(edges)) {
+ const instance = new VisualInstance({ system: { objects: [] } }); instance.state = from;
+ if (allowed.includes(to)) assert.doesNotThrow(() => instance.transition(to));
+ else assert.throws(() => instance.transition(to));
+ }
+ const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content: { p: point } } } });
+ const instance = run.engine.spawn('s'); run.tick(10); run.engine.remove(instance.id);
+ assert.equal(instance.state, 'RELEASING'); assert.equal(instance.factor, 0);
+ run.tick(10); assert.equal(instance.state, 'DISPOSED');
+});
+
+test('19.7.11 ownership, originating scenario and forced cleanup are separate', () => {
+ const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { ownership: 'persistent', cancelWithScenario: false, release: '10s' }, content: { p: point } } } });
+ const inherited = run.engine.spawn('s', { owner: 'scenario' });
+ const persistent = run.engine.spawn('s', { owner: 'scenario', ownership: 'persistent' });
+ run.tick(10); run.engine.cleanup('scenario');
+ assert.equal(inherited.state, 'RELEASING'); assert.equal(persistent.state, 'ACTIVE');
+ run.tick(5100); assert.equal(inherited.state, 'DISPOSED');
+ assert.ok(run.engine.cadence.raised.some(w => w.code === 'WARN_CLEANUP_FORCED'));
+});
+
+test('19.7.6/12 live spawn and automation budgets refuse atomically without consuming ordinal', () => {
+ for (const manyTracks of [false, true]) {
+ const content = Object.fromEntries(Array.from({ length: 3 }, (_, i) => [`p${i}`, point]));
+ const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content, automation: manyTracks ? [0, 1, 2].map(i => track(`p${i}.position.x`)) : [] } } });
+ const count = manyTracks ? 42 : 64;
+ const instances = Array.from({ length: count }, () => run.engine.spawn('s'));
+ assert.ok(instances.every(Boolean));
+ assert.equal(run.engine.spawn('s'), null); assert.equal(run.engine.spawn('s'), null);
+ assert.equal(run.engine.spawnOrdinals.get('s'), count);
+ assert.equal(run.engine.cadence.raised.length, 1);
+ run.engine.remove(instances[0].id); run.tick(10);
+ assert.equal(run.engine.spawn('s').id, `instances.s#${count}`);
+ }
+});
+
+test('spawn/remove actions use PRD target/with shape and instance addresses', () => {
+ const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { inputs: { x: { type: 'number', default: 1 } } }, content: { p: { ...point, position: { x: { ref: 'inputs.x' }, y: 20 } } } } } });
+ const actions = new ActionExecutor(run.resolution); actions.visual = run.engine;
+ const [result] = actions.execute([{ type: 'spawn', target: 'visuals.systems.s', with: { x: 77 } }]);
+ run.tick(10); close(run.frame().layers[0].nodes[0].center[0], 77);
+ actions.execute([{ type: 'remove', target: result.instanceId }]); run.tick(10);
+ assert.equal(run.frame().layers[0].nodes.length, 0);
+});
+
+test('19.7.15 each effect changes known pixels, preserves alpha and respects order', () => {
+ const source = new Uint8ClampedArray([255, 255, 255, 255, 60, 40, 20, 255, 0, 0, 0, 255]);
+ for (const effect of [
+ { type: 'fade', amount: 0.5 }, { type: 'vignette', radius: 0, amount: 1, softness: 0.1 },
+ { type: 'scanlines', amount: 1 }, { type: 'grain', amount: 1 },
+ { type: 'color-adjust', brightness: 0.5 }, { type: 'blur', radius: 1 }, { type: 'bloom', radius: 1, intensity: 1 }
+ ]) {
+ const actual = applyVisualEffect(source, 3, 1, effect);
+ assert.notDeepEqual(actual, source, effect.type);
+ assert.equal(actual[3], 255);
+ }
+ const bloom = data => applyVisualEffect(data, 3, 1, { type: 'bloom', radius: 1 });
+ const adjust = data => applyVisualEffect(data, 3, 1, { type: 'color-adjust', brightness: 0.4 });
+ assert.notDeepEqual(bloom(adjust(source)), adjust(bloom(source)));
+ assert.deepEqual([...applyVisualEffect(new Uint8ClampedArray([0, 0, 0, 255]), 1, 1, { type: 'fade', color: '#804020', amount: 1 })], [128, 64, 32, 255]);
+});
+
+test('19.7.16-17 effects do not consume procedural randomness and unavailable passes warn', () => {
+ const run = setup({ effects: [{ type: 'grain', enabled: false }, { type: 'blur', radius: 2 }], systems: {} });
+ const before = run.rng.stream('visual', 'later').nextFloat();
+ for (let i = 0; i < 10; i++) run.frame();
+ const reference = new SeededRNG(42); close(before, reference.stream('visual', 'later').nextFloat());
+ close(run.rng.stream('visual', 'later').nextFloat(), reference.stream('visual', 'later').nextFloat());
+ assert.equal(run.frame().effects.length, 1);
+ renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
+ renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
+ assert.equal(run.engine.cadence.raised.length, 1);
+});
+
+test('19.7.18 diagnostic cadence counts ticks, not refusals, and recovers promptly', () => {
+ const cadence = new VisualDiagnosticCadence();
+ for (let tick = 0; tick <= 120; tick++) {
+ for (let i = 0; i < 4; i++) cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', tick * 1000 / 60);
+ cadence.endTick();
+ }
+ assert.equal(cadence.keys.get('WARN_VISUAL_CEILING test').consecutive, 121);
+ assert.ok(cadence.raised.at(-1).sustained);
+ cadence.endTick(); cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', 2010);
+ assert.equal(cadence.raised.at(-1).at, 2010);
+});
+
+test('19.7.18 particle overload evicts oldest from largest populations; declared systems survive', () => {
+ const run = setup({ systems: Object.fromEntries([0, 1, 2].map(i => [`s${i}`, { type: 'particles', capacity: 4096, count: 4096, render: { type: 'point' } }])) });
+ assert.equal(run.engine.systems.length, 3);
+ assert.equal(run.engine.systems.reduce((sum, system) => sum + system.procedural.items.length, 0), 8192);
+ assert.ok(run.engine.systems.every(system => system.procedural.items[0].ordinal > 0));
+});
+
+test('19.7.18 emitted items, trail histories, and link tails obey aggregate ceilings', () => {
+ const run = setup({ systems: Object.fromEntries([0, 1, 2, 3, 4].map(i => [`s${i}`, { type: 'emitter', capacity: 512, count: 512, emit: { type: 'point' } }])) });
+ const systems = run.engine.systems.map(system => system.procedural);
+ assert.equal(systems.reduce((n, system) => n + system.items.length, 0), 2048);
+ for (const system of systems) {
+ for (const item of system.items) item.trail = Array.from({ length: 17 }, (_, i) => ({ t: i, x: 0, y: 0 }));
+ system.linkPairs = () => Array.from({ length: 1024 }, (_, ordinal) => ({ ordinal }));
+ }
+ run.engine.enforcePopulations();
+ assert.equal(systems.reduce((n, system) => n + system.items.reduce((m, item) => m + item.trail.length, 0), 0), 32768);
+ assert.equal(run.engine.systems.reduce((n, system) => n + system.links.length, 0), 4096);
+ assert.equal(run.engine.systems.at(-1).links.length, 0);
+});
+
+test('19.7.18 fields evaluate nearest items first and remaining forces become zero', () => {
+ const run = setup({
+ fields: Object.fromEntries([0, 1, 2, 3].map(i => [`f${i}`, { type: 'directional', strength: 1, direction: 0 }])),
+ systems: { far: { type: 'particles', count: 4096, capacity: 4096, z: 100, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] },
+ near: { type: 'particles', count: 4096, capacity: 4096, z: 0, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] } }
+ });
+ run.tick(1000 / 60);
+ assert.equal(run.engine.fieldSet.evaluations, 32768);
+ assert.equal(run.engine.fieldSet.refused, true);
+ assert.ok(run.engine.systems[1].procedural.items.every(item => item.vx > 0));
+ assert.equal(run.engine.systems[0].procedural.items.at(-1).vx, 0);
+});
+
+test('19.7.19 resolved counts cannot enlarge pools or repeater limits', () => {
+ assert.throws(() => setup({ systems: { s: { type: 'repeater', count: { random: { min: 1025, max: 1026 } }, repeat: point } } }), error => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
+});
+
+test('runtime failure reclaims a spawned instance without stopping another system', () => {
+ const run = setup({ systems: { s: { type: 'particles', lifecycle: 'spawned', count: 1, render: point }, healthy: { type: 'graphic', content: { p: point } } } });
+ const instance = run.engine.spawn('s');
+ instance.system.procedural.advance = () => { throw new Error('injected failure'); };
+ assert.doesNotThrow(() => run.tick(10));
+ assert.equal(instance.state, 'FAILED'); assert.equal(instance.system.procedural.items.length, 0);
+ assert.equal(run.engine.instances.size, 0); assert.equal(run.frame().layers[0].nodes.length, 1);
+});
+
+test('viewport default camera center still participates in automation and bindings', () => {
+ const run = setup({ scene: { coordinateSpace: 'viewport' }, automation: [track('camera.x', 0, 100)], systems: {} });
+ run.tick(250); close(run.frame().camera.x, 25);
+});
+
+test('19.7.3 exhibit automation samples point values in one scope stream', () => {
+ const value = { random: { min: 1, max: 2 } };
+ const run = setup({ automation: [track('camera.zoom', value, value), track('camera.rotation', value, value)], systems: {} });
+ const stream = new SeededRNG(42).stream('visual', 'automation');
+ const expected = Array.from({ length: 4 }, () => 1 + stream.nextFloat());
+ assert.deepEqual([...run.resolution.automation.values()].flatMap(track => track.points.map(point => point.value)), expected);
+});
diff --git a/tools/build-visual-acceptance.mjs b/tools/build-visual-acceptance.mjs
new file mode 100644
index 0000000..97c584b
--- /dev/null
+++ b/tools/build-visual-acceptance.mjs
@@ -0,0 +1,71 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bundleRuntime } from './build-xzbt.mjs';
+import { visualChallenges, visualExhibits } from './visual-challenge-fixtures.mjs';
+import { validateExhibit } from '../src/runtime/validator.js';
+
+export function buildVisualAcceptance() {
+ const root = fileURLToPath(new URL('..', import.meta.url));
+ const fixtures = [...visualChallenges, ...visualExhibits];
+ mkdirSync(resolve(root, 'exhibits/visual-challenges'), { recursive: true });
+ mkdirSync(resolve(root, 'prototypes/phase4'), { recursive: true });
+ for (const fixture of fixtures) {
+ const result = validateExhibit(fixture.document);
+ if (!result.valid) throw new Error(JSON.stringify(result.errors));
+ writeFileSync(resolve(root, fixture.challenge ? 'exhibits/visual-challenges' : 'exhibits', `${fixture.id}.xzbt`), JSON.stringify(fixture.document, null, 2) + '\n');
+ }
+ const data = JSON.stringify(fixtures).replace(/
+XZBT Visual Challenge
+
Visual Challenge / XZBT 0.1
+
This page supplies synthetic audio signals for reproducible visual checks. It does not establish live audio acceptance or benchmark results. Judge the composition on your display and record observations below.