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.

+`; + const path = resolve(root, 'prototypes/phase4/XZBT-visual-acceptance.html'); + writeFileSync(path, html); return { path, count: fixtures.length, html }; +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = buildVisualAcceptance(); console.log(`Built ${result.path}; ${result.count} fixtures.`); +}