// Phase 4 Stage 0 — shared pre-renderer alignment. // // Covers the parts of sections 17-19 (revision 0.8) that the schema, the // validator, and the shared resolution pipeline must agree on before slice 4d // writes any renderer code: the `visuals` container surface, the scene and // layer rules, the `spawn` lifecycle container, procedural fields, the // post-effect chain, visual automation and its two scopes, and the four // section 8.1 visual target families. // // It draws nothing. Traces of 17.16, 18.10, and 19.7 that require a renderer // belong to slices 4d, 4e, and 4f and are not claimed here. import assert from 'node:assert/strict'; import test from 'node:test'; import { ResolutionEngine } from '../src/runtime/resolution.js'; import { SeededRNG } from '../src/runtime/rng.js'; import { RuntimeFault, parseDuration } from '../src/runtime/types.js'; import { validateExhibit } from '../src/runtime/validator.js'; import { POST_EFFECTS, VISUAL_LIMITS, matchVisualTarget, postEffectPassCount } from '../src/runtime/visual-contract.js'; function document(visuals, overrides = {}) { return { xzbt: '0.1', meta: { id: 'visual-study', name: 'Visual Study' }, runtime: { seed: 42 }, state: { energy: { type: 'number', initial: 0.5, min: 0, max: 1 } }, visuals, ...overrides }; } const scene = { coordinateSpace: 'virtual', width: 1600, height: 900 }; const graphic = (extra = {}) => ({ type: 'graphic', content: { band: { type: 'rectangle', size: { width: 10, height: 2 } } }, ...extra }); function codes(doc) { return validateExhibit(doc).errors.map((error) => error.code); } function expectClean(doc, label) { const result = validateExhibit(doc); assert.deepEqual(result.errors, [], `${label} should validate: ${JSON.stringify(result.errors)}`); } // --------------------------------------------------------------------------- // 17.3-17.5 — the container, the scene, and layers // --------------------------------------------------------------------------- test('the visuals container accepts exactly its seven declared fields (17.3, V1)', () => { expectClean(document({ scene, automation: [] }), 'exhibit-scope automation'); assert.ok(codes(document({ scene, passes: [] })).includes('ERR_UNKNOWN_FIELD')); assert.ok(codes(document({})).includes('ERR_SCHEMA_VALIDATION'), 'scene is required when visuals is present'); }); test('virtual space requires width and height, and no other space accepts them (17.4)', () => { assert.ok(codes(document({ scene: { coordinateSpace: 'virtual', width: 1600 } })).includes('ERR_SCHEMA_VALIDATION')); assert.ok(codes(document({ scene: { coordinateSpace: 'normalized', width: 1 } })).includes('ERR_UNKNOWN_FIELD')); expectClean(document({ scene: { coordinateSpace: 'normalized' } }), 'normalized scene'); }); test('viewport accepts an absent fit and rejects only an explicit non-stretch one (17.4, V17)', () => { expectClean(document({ scene: { coordinateSpace: 'viewport' } }), 'absent fit in viewport'); expectClean(document({ scene: { coordinateSpace: 'viewport', fit: 'stretch' } }), 'explicit stretch in viewport'); assert.ok(codes(document({ scene: { coordinateSpace: 'viewport', fit: 'contain' } })).includes('ERR_SCHEMA_VALIDATION')); }); test('the layer map decides whether a system must name a layer (17.5, 17.7, V17)', () => { // Absent map: the implicit layer, and naming one is a dangling reference. expectClean(document({ scene, systems: { horizon: graphic() } }), 'implicit layer'); assert.ok(codes(document({ scene, systems: { horizon: graphic({ layer: 'far' }) } })).includes('ERR_INVALID_REFERENCE')); // Present map: naming one is required, and no first-layer fallback exists. expectClean(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) } }), 'declared layer'); assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic() } })).includes('ERR_INVALID_REFERENCE')); assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'near' }) } })).includes('ERR_INVALID_REFERENCE')); // Present but empty is not a layer set. assert.ok(codes(document({ scene, layers: {} })).includes('ERR_SCHEMA_VALIDATION')); }); test('more than sixteen layers is a visual limit (17.5, 19.5)', () => { const layers = Object.fromEntries(Array.from({ length: 17 }, (_, index) => [`l${index}`, {}])); assert.ok(codes(document({ scene, layers })).includes('ERR_VISUAL_LIMIT_EXCEEDED')); delete layers.l16; expectClean(document({ scene, layers }), 'sixteen layers'); }); // --------------------------------------------------------------------------- // 17.7 / 19.2 — the spawn container (A1) // --------------------------------------------------------------------------- test('spawned lifecycle fields live only in the spawn container (17.7, 19.2, A1)', () => { const spawned = (spawn, extra = {}) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn, ...extra } } }); expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'spawn container'); // A persistent system has no spawn container at all. assert.ok(codes(document({ scene, systems: { horizon: graphic({ spawn: { lifetime: '2s' } }) } })).includes('ERR_UNKNOWN_FIELD')); // The four spawn-only names never appear at a system's top level. for (const field of ['release', 'ownership', 'inputs', 'cancelWithScenario']) { const doc = document({ scene, systems: { horizon: graphic({ [field]: field === 'cancelWithScenario' ? true : 'x' }) } }); assert.ok(codes(doc).includes('ERR_UNKNOWN_FIELD'), `${field} at system top level`); } }); test('a top-level lifetime is the per-item duration, and only two types have one (17.7, A1)', () => { const particles = { type: 'particles', render: { type: 'point' }, count: 10, lifetime: '4s' }; expectClean(document({ scene, systems: { motes: particles } }), 'particle lifetime'); // The same key on a graphic or repeater is an unknown field, not an instance duration. assert.ok(codes(document({ scene, systems: { horizon: graphic({ lifetime: '4s' }) } })).includes('ERR_UNKNOWN_FIELD')); // A spawned particles system carries both, independently. expectClean(document({ scene, systems: { motes: { ...particles, lifecycle: 'spawned', spawn: { lifetime: '30s' } } } }), 'both lifetimes'); }); test('a spawn duration is a literal or a non-negative number of milliseconds (6.1, C5)', () => { const spawned = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } }); // Both authored forms of the same duration validate. expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'literal durations'); expectClean(spawned({ lifetime: 2000, release: 250 }), 'millisecond durations'); expectClean(spawned({ lifetime: 0 }), 'zero milliseconds'); // Everything else at that field is ERR_INVALID_DURATION, negatives included. for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '2 s', '1m30s', true, {}]) { assert.ok(codes(spawned({ lifetime: value })).includes('ERR_INVALID_DURATION'), `lifetime ${String(value)}`); } }); test('parseDuration reads both forms and rejects the rest (6.1, C5)', () => { assert.equal(parseDuration('2s'), 2000); assert.equal(parseDuration(2000), 2000); assert.equal(parseDuration(0), 0); for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '1m30s', null, true]) { assert.throws(() => parseDuration(value), (error) => error instanceof RuntimeFault && error.code === 'ERR_INVALID_DURATION', `parseDuration ${String(value)}`); } }); test('severing the scenario relationship requires persistent ownership (19.2, V23)', () => { const doc = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } }); assert.ok(codes(doc({ cancelWithScenario: false })).includes('ERR_UNSUPPORTED_TARGET')); expectClean(doc({ cancelWithScenario: false, ownership: 'persistent' }), 'persistent-owned survivor'); expectClean(doc({ cancelWithScenario: true }), 'default relationship'); }); // --------------------------------------------------------------------------- // 18.7 — procedural fields (A2) // --------------------------------------------------------------------------- test('a value-mode noise field requires direction and no other mode accepts it (18.7, A2)', () => { const field = (extra) => document({ scene, fields: { current: { type: 'noise', scale: 200, ...extra } } }); expectClean(field({ mode: 'value', direction: 90 }), 'value mode with direction'); assert.ok(codes(field({ mode: 'value' })).includes('ERR_SCHEMA_VALIDATION')); assert.ok(codes(field({ mode: 'curl', direction: 90 })).includes('ERR_UNKNOWN_FIELD')); expectClean(field({}), 'curl by default'); assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS')); assert.ok(codes(document({ scene, fields: { current: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE')); }); // --------------------------------------------------------------------------- // 19.3 / 19.4 — camera and post-effects // --------------------------------------------------------------------------- test('focalLength has a closed range with a real lower bound (19.3, V20)', () => { expectClean(document({ scene, camera: { focalLength: 1 } }), 'the documented minimum'); assert.ok(codes(document({ scene, camera: { focalLength: 0 } })).includes('ERR_OUT_OF_BOUNDS')); assert.ok(codes(document({ scene, camera: { focalLength: 0.5 } })).includes('ERR_OUT_OF_BOUNDS')); assert.ok(codes(document({ scene, camera: { zoom: 200 } })).includes('ERR_OUT_OF_BOUNDS')); assert.ok(codes(document({ scene, camera: { projection: 'isometric' } })).includes('ERR_SCHEMA_VALIDATION')); }); test('the post-effect chain is closed at seven types and four entries (19.4)', () => { expectClean(document({ scene, effects: [{ type: 'bloom', threshold: 0.6 }] }), 'a bloom entry'); assert.ok(codes(document({ scene, effects: [{ type: 'chromatic' }] })).includes('ERR_INVALID_EFFECT_TYPE')); assert.ok(codes(document({ scene, effects: [{ type: 'blur', threshold: 1 }] })).includes('ERR_UNKNOWN_FIELD')); assert.ok(codes(document({ scene, effects: [{ type: 'blur', radius: 64 }] })).includes('ERR_OUT_OF_BOUNDS')); const five = Array.from({ length: 5 }, () => ({ type: 'fade' })); assert.ok(codes(document({ scene, effects: five })).includes('ERR_VISUAL_LIMIT_EXCEEDED')); }); test('blur and bloom cost two frame passes each, everything else one (19.4)', () => { assert.equal(postEffectPassCount([{ type: 'blur' }, { type: 'bloom' }, { type: 'fade' }, { type: 'grain' }]), 6); // Four entries of two passes is exactly the eight-pass ceiling of 19.5. assert.equal(postEffectPassCount(Array.from({ length: 4 }, () => ({ type: 'blur' }))), VISUAL_LIMITS.runtime.postEffectPassesPerFrame); }); // --------------------------------------------------------------------------- // 19.1 — visual automation // --------------------------------------------------------------------------- const track = (target, extra = {}) => ({ target, points: [{ at: '0ms', value: 0 }, { at: '2s', value: 1 }], ...extra }); test('exhibit-scope automation reaches scene, layers, camera, and effects (19.1)', () => { expectClean(document({ scene, layers: { far: {} }, camera: {}, effects: [{ type: 'fade' }], automation: [ track('camera.zoom'), track('layers.far.opacity'), track('effects[0].amount'), track('scene.depthFog.density') ], systems: {} }), 'every exhibit-scope family'); assert.ok(codes(document({ scene, automation: [track('camera.projection')] })).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(document({ scene, layers: { far: {} }, automation: [track('layers.near.opacity')] })).includes('ERR_INVALID_REFERENCE')); assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[3].amount')] })).includes('ERR_INVALID_REFERENCE')); assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[0].color')] })).includes('ERR_UNSUPPORTED_TARGET')); }); test('the two declaration scopes never reach across (19.1)', () => { const doc = document({ scene, systems: { horizon: { ...graphic(), automation: [track('camera.zoom')] } } }); assert.ok(codes(doc).includes('ERR_INVALID_REFERENCE'), 'system scope cannot address the camera'); const other = document({ scene, systems: { horizon: graphic(), motes: { ...graphic(), automation: [track('systems.horizon.band.z')] } } }); assert.ok(codes(other).includes('ERR_INVALID_REFERENCE'), 'no track addresses another system'); assert.ok(codes(document({ scene, systems: { horizon: graphic() }, automation: [track('systems.horizon.band.z')] })).includes('ERR_INVALID_REFERENCE')); }); test('a repeater exposes no automatable property, step included (19.1, V11)', () => { const repeater = (automation) => document({ scene, systems: { wall: { type: 'repeater', repeat: { type: 'point' }, count: 4, automation } } }); assert.ok(codes(repeater([track('step.x')])).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(repeater([track('count')])).includes('ERR_UNSUPPORTED_TARGET')); // A particle system does expose its live channels. const particles = document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 4, automation: [track('rate'), track('drag')] } } }); expectClean(particles, 'particle live channels'); }); test('a graphic system automates numeric object properties by container key (19.1)', () => { const system = { type: 'graphic', content: { hull: { type: 'group', children: { band: { type: 'rectangle', size: { width: 4, height: 2 } } } } } }; expectClean(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.transform.rotation')] } } }), 'nested numeric property'); assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.style.blend')] } } })).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.missing.z')] } } })).includes('ERR_INVALID_REFERENCE')); }); test('an infinite loop is legal in every scope (19.1, V12)', () => { const loop = { mode: 'ping-pong', count: 'infinite' }; expectClean(document({ scene, camera: {}, automation: [track('camera.zoom', { loop })] }), 'exhibit scope'); expectClean(document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, automation: [track('rate', { loop })] } } }), 'persistent system'); // Both spawned cases: with a declared instance lifetime and without one. const spawned = (spawn) => document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, lifecycle: 'spawned', spawn, automation: [track('rate', { loop })] } } }); expectClean(spawned({ lifetime: '8s' }), 'finite-lifetime spawned'); expectClean(spawned({}), 'indefinite spawned'); }); test('one track per target, strictly increasing point times (19.1)', () => { const doc = document({ scene, camera: {}, automation: [track('camera.zoom'), track('camera.zoom')] }); assert.ok(codes(doc).includes('ERR_AUTOMATION_CONFLICT')); const backwards = { target: 'camera.zoom', points: [{ at: '2s', value: 0 }, { at: '1s', value: 1 }] }; assert.ok(codes(document({ scene, camera: {}, automation: [backwards] })).includes('ERR_INVALID_RANGE_ORDER')); const procedural = { target: 'camera.zoom', points: [{ at: { random: { min: 0, max: 1 } }, value: 0 }, { at: '1s', value: 1 }] }; assert.ok(codes(document({ scene, camera: {}, automation: [procedural] })).includes('ERR_INVALID_DURATION')); }); test('declared automation records are an authoring bound counted once each (19.1, V7)', () => { const many = Array.from({ length: 129 }, (_, index) => track(`effects[${index % 4}].amount`)); const doc = document({ scene, effects: Array.from({ length: 4 }, () => ({ type: 'fade' })), automation: many }); assert.ok(codes(doc).includes('ERR_VISUAL_LIMIT_EXCEEDED')); }); // --------------------------------------------------------------------------- // 8.1 / 19.1 — the four visual target families // --------------------------------------------------------------------------- test('matchVisualTarget separates unsupported targets from dangling references (19.1, V2)', () => { const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] }); for (const path of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.systems.horizon.visible', 'visuals.effects[0].radius']) { assert.equal(matchVisualTarget(doc, path).reason, undefined, path); } assert.equal(matchVisualTarget(doc, 'visuals.layers.near.opacity').reason, 'reference'); assert.equal(matchVisualTarget(doc, 'visuals.effects[2].radius').reason, 'reference'); assert.equal(matchVisualTarget(doc, 'visuals.layers.far.visible').reason, 'unsupported', 'layer visibility is deliberately absent'); assert.equal(matchVisualTarget(doc, 'visuals.camera.projection').reason, 'unsupported'); assert.equal(matchVisualTarget(doc, 'visuals.systems.horizon.content.band.style.opacity').reason, 'unsupported'); assert.equal(matchVisualTarget(doc, 'parameters.activity'), null); }); test('the stage table matches the four section 8.1 rows (19.1)', () => { const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] }); assert.deepEqual(matchVisualTarget(doc, 'visuals.camera.zoom').stages, { binding: true, automation: true, override: true, modulation: true }); assert.deepEqual(matchVisualTarget(doc, 'visuals.layers.far.opacity').stages, { binding: true, automation: true, override: true, modulation: false }); // A boolean target takes no automation and no modulation: absent, not identity hooks. assert.deepEqual(matchVisualTarget(doc, 'visuals.systems.horizon.visible').stages, { binding: true, automation: false, override: true, modulation: false }); assert.deepEqual(matchVisualTarget(doc, 'visuals.effects[0].radius').stages, { binding: true, automation: true, override: true, modulation: false }); }); test('bindings reach the four families and nothing else (8.1, 19.1, V2)', () => { const base = { scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] }; const bound = (target) => document(base, { bindings: [{ source: 'state.energy', target }] }); for (const target of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.effects[0].radius']) { expectClean(bound(target), target); } assert.ok(codes(bound('visuals.systems.horizon.content.band.style.opacity')).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(bound('visuals.fields.current.strength')).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(bound('visuals.layers.near.opacity')).includes('ERR_INVALID_REFERENCE')); // A boolean target and a numeric source do not match. assert.ok(codes(bound('visuals.systems.horizon.visible')).includes('ERR_TYPE_MISMATCH')); }); // --------------------------------------------------------------------------- // Resolution — bases, clamps, and stage exposure // --------------------------------------------------------------------------- function engine(visuals, overrides = {}) { const doc = document(visuals, overrides); return new ResolutionEngine(doc, new SeededRNG(42)); } test('visual target bases resolve from the document, or from their documented defaults (19.3)', () => { const resolver = engine({ scene, layers: { far: { opacity: 0.4 } }, systems: { horizon: graphic({ layer: 'far' }) }, camera: { zoom: 2 }, effects: [{ type: 'blur', radius: 6 }] }); assert.equal(resolver.get('visuals.camera.zoom'), 2); assert.equal(resolver.get('visuals.camera.focalLength'), 1000, 'documented default'); assert.equal(resolver.get('visuals.camera.x'), 800, 'the scene center, so a declared default looks identical'); assert.equal(resolver.get('visuals.layers.far.opacity'), 0.4); assert.equal(resolver.get('visuals.systems.horizon.visible'), true); assert.equal(resolver.get('visuals.effects[0].radius'), 6); // A parameter the effect's own table does not declare exposes no capability. assert.throws(() => resolver.get('visuals.effects[0].threshold'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET'); }); test('a resolved camera value clamps into the closed range rather than failing (19.3, V20)', () => { const resolver = engine({ scene, camera: { focalLength: { ref: 'state.energy' } } }); // state.energy is 0.5, below the documented minimum of 1. assert.equal(resolver.get('visuals.camera.focalLength'), 1); }); test('a layer opacity binding runs through the shared pipeline and clamps to [0, 1] (8.1)', () => { const resolver = engine( { scene, layers: { far: { opacity: 0.2 } } }, { bindings: [{ source: 'state.energy', target: 'visuals.layers.far.opacity', scale: 4 }] } ); assert.equal(resolver.get('visuals.layers.far.opacity'), 1, '0.5 * 4 clamps to the row range'); }); test('the automation stage is refused where the 8.1 row does not expose it (8.1, 19.1)', () => { const resolver = engine({ scene, systems: { horizon: graphic() }, camera: {} }); assert.doesNotThrow(() => resolver.requireStage('visuals.camera.zoom', 'automation')); assert.throws(() => resolver.requireStage('visuals.systems.horizon.visible', 'automation'), (error) => error instanceof RuntimeFault && error.code === 'ERR_UNSUPPORTED_TARGET'); assert.throws(() => resolver.requireStage('visuals.layers.far.opacity', 'modulation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET'); assert.throws(() => resolver.requireStage('parameters.nope', 'automation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET'); }); test('effect parameter ranges in the contract match the 19.4 tables', () => { assert.equal(POST_EFFECTS.bloom.numeric.intensity.max, 2); assert.equal(POST_EFFECTS.grain.numeric.speed.max, 60); assert.equal(POST_EFFECTS.scanlines.numeric.spacing.min, 1); assert.equal(VISUAL_LIMITS.authoring.declaredSystems, 64); assert.equal(VISUAL_LIMITS.authoring.expandedStaticObjects, 16384); }); // --------------------------------------------------------------------------- // Fixture and schema integrity // --------------------------------------------------------------------------- test('the minimal visual exhibit validates end to end', async () => { const { readFileSync } = await import('node:fs'); const { parseAndValidateExhibit } = await import('../src/runtime/validator.js'); const source = readFileSync(new URL('../exhibits/minimal-visual.xzbt', import.meta.url), 'utf8'); const result = parseAndValidateExhibit(source, 'minimal-visual.xzbt'); assert.deepEqual(result.errors, [], JSON.stringify(result.errors)); // Its three bindings reach three of the four families through the pipeline. const resolver = new ResolutionEngine(result.document, new SeededRNG(42)); assert.equal(resolver.get('visuals.camera.zoom'), 1, 'bound to parameters.depth'); assert.equal(resolver.get('visuals.layers.near.opacity'), 0.35); assert.ok(Math.abs(resolver.get('visuals.effects[0].amount') - 0.21) < 1e-12); assert.equal(resolver.get('visuals.systems.flare.visible'), true); }); test('every $ref in the JSON Schema resolves to a declared definition', async () => { const { readFileSync } = await import('node:fs'); const schema = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url), 'utf8')); const declared = new Set(Object.keys(schema.definitions)); const missing = []; const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (typeof node !== 'object' || node === null) return; for (const [key, value] of Object.entries(node)) { if (key === '$ref' && typeof value === 'string') { const name = value.replace('#/definitions/', ''); if (!declared.has(name)) missing.push(value); } else walk(value); } }; walk(schema); assert.deepEqual(missing, []); // 17.3: the visuals container declares exactly its seven fields. assert.deepEqual(Object.keys(schema.properties.visuals.properties).sort(), ['automation', 'camera', 'effects', 'fields', 'layers', 'scene', 'systems']); // 17.8: a visual object carries no `id`; its container key is its identity. assert.equal(schema.definitions.VisualObject.properties.id, false); });