Ten new scenes, with 'minimal' first: the family was empty, so intros and breakdowns fell through to flow/organic and every track opened at full density. Quiet sections now land on a restful family 48/48 times across 24 seeds, 28 of them minimal. New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow (flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid (geometric); Ridge Terrain (structural); Scan Tear (glitch). Four real bugs, three of which the existing gates could not have caught: 1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses KHR_parallel_shader_compile, so draws against an unlinked program render wrong. The heaviest scene had its first TEN frames differ from every later render of the same frames. Preview hides this entirely; export renders each frame once, so those frames would ship broken. Added Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame — and the exporter now primes before encoding. Rendering a throwaway frame and reading it back is NOT sufficient; measured, it left 3-5 frames wrong. 2. Moiré Grid declared a param on u_width, which the shader contract already uses for stereo width. GLSL redefinition, and the only symptom was a black frame. Lint now rejects any param uniform colliding with the contract. 3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured in turns meant bar-crossing frequency was bars x rate (82 bars put a slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made every bar switch band simultaneously. Rotation is now in segment units so the rate IS the crossing frequency, bands interpolate, and the range is capped where the flash meter measures zero. 4. Particle Field was being chosen as a primary background despite being mostly empty by design. Scenes now declare role: 'accent'; those are never primary and are judged on variance rather than luminance. Three checks were themselves wrong and were rebuilt: mean-distance metrics unfairly fail sparse scenes for being tasteful rather than static, so "animates" and "no duplicates" now use max channel delta. PLAN.md §1 gains two refinements: programs must be primed before the first frame, and even same-machine the heaviest shaders vary by one LSB under differing GPU load — so the per-scene criterion is max delta <= 1 rather than an identical hash. A real bug scores in the tens there. Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms, worst flash rate 0/s, worst determinism delta 1/255. Adds README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.8 KiB
JavaScript
178 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Static gates that don't need a GPU:
|
|
//
|
|
// 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
|
|
// 2. Scene schema lint — the params block and the shader source must agree,
|
|
// in BOTH directions. A declared param that no shader reads is dead weight;
|
|
// a uniform the shader reads that nothing declares is a silent zero, which
|
|
// is the single most annoying way for a scene to look subtly wrong.
|
|
//
|
|
// Run: npm run lint:scenes
|
|
|
|
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
import { join, relative, dirname } from 'path';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const SRC = join(root, 'src');
|
|
|
|
let failures = 0;
|
|
const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; };
|
|
const ok = (msg) => console.log(` ✓ ${msg}`);
|
|
|
|
function walk(dir, out = []) {
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) walk(full, out);
|
|
else if (entry.endsWith('.js')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- grep gate
|
|
|
|
const FORBIDDEN = [
|
|
{ pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
|
|
{ pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
|
|
{ pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
|
|
{ pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
|
|
];
|
|
|
|
// Directories whose output must be a pure function of (seed, params, frame).
|
|
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
|
|
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
|
const ALLOWED = new Set(['engine/perf.js']);
|
|
|
|
console.log('\ndeterminism grep');
|
|
{
|
|
let checked = 0;
|
|
for (const dir of DETERMINISTIC_DIRS) {
|
|
const full = join(SRC, dir);
|
|
let files;
|
|
try { files = walk(full); } catch { continue; }
|
|
for (const file of files) {
|
|
const rel = relative(SRC, file).replace(/\\/g, '/');
|
|
if (ALLOWED.has(rel)) continue;
|
|
checked++;
|
|
const source = readFileSync(file, 'utf8');
|
|
const lines = source.split('\n');
|
|
lines.forEach((line, i) => {
|
|
if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them
|
|
for (const f of FORBIDDEN) {
|
|
if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name} — ${f.why}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
|
|
}
|
|
|
|
// ------------------------------------------------------------- scene lint
|
|
|
|
const CONTRACT_UNIFORMS = new Set([
|
|
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
|
|
'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
|
|
'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
|
|
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
|
|
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
|
|
'u_sectionEnergy', 'u_buildSlope',
|
|
]);
|
|
|
|
console.log('\nscene schema lint');
|
|
{
|
|
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
|
|
const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);
|
|
|
|
if (!scenes.length) fail('no scenes registered');
|
|
|
|
const seenNames = new Set();
|
|
|
|
for (const module of scenes) {
|
|
const id = module.name || '<unnamed>';
|
|
|
|
for (const err of validateModule(module)) fail(err);
|
|
|
|
if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
|
|
seenNames.add(id);
|
|
|
|
if (module.family && !FAMILIES[module.family]) {
|
|
fail(`${id}: unknown family '${module.family}'`);
|
|
}
|
|
if (module.kind !== 'fragment') continue;
|
|
|
|
const src = module.shader || '';
|
|
const declared = new Map();
|
|
for (const [name, def] of Object.entries(module.params || {})) {
|
|
if (def.uniform) declared.set(def.uniform, name);
|
|
}
|
|
|
|
// Collision with the shader contract. A param that reuses a contract
|
|
// uniform name (u_width, u_time, u_seed...) is a GLSL redefinition error,
|
|
// and the whole scene renders as a black frame with no other symptom.
|
|
for (const [uniform, param] of declared) {
|
|
if (CONTRACT_UNIFORMS.has(uniform)) {
|
|
fail(`${id}: param '${param}' uses '${uniform}', which the shader ` +
|
|
`contract already declares — pick another name`);
|
|
}
|
|
}
|
|
|
|
// Direction 1: every declared uniform is actually read by the shader.
|
|
for (const [uniform, param] of declared) {
|
|
const used = new RegExp(`\\b${uniform}\\b`).test(src);
|
|
if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
|
|
}
|
|
|
|
// Direction 2: every u_* the shader reads is declared somewhere.
|
|
const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
|
|
for (const uniform of referenced) {
|
|
if (CONTRACT_UNIFORMS.has(uniform)) continue;
|
|
if (declared.has(uniform)) continue;
|
|
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
|
|
}
|
|
|
|
// Rate params: anything the shader multiplies absolute time by must be
|
|
// flagged `rate: true`, which stops reactivity and drift from touching it.
|
|
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
|
|
// in, a wobble of 0.05 throws the phase by three units between frames.
|
|
// That measured as 6 flashes/second on Classic Wave, twice the WCAG 2.3.1
|
|
// ceiling, and it gets worse the longer the track runs.
|
|
const timeProducts = [
|
|
...src.matchAll(/u_time\s*\*\s*(u_[A-Za-z0-9_]+)/g),
|
|
...src.matchAll(/(u_[A-Za-z0-9_]+)\s*\*\s*u_time/g),
|
|
];
|
|
for (const match of timeProducts) {
|
|
const uniform = match[1];
|
|
const paramName = declared.get(uniform);
|
|
if (!paramName) continue;
|
|
if (!module.params[paramName].rate) {
|
|
fail(`${id}: '${paramName}' (${uniform}) multiplies u_time but is not marked ` +
|
|
`\`rate: true\` — reactivity or drift on it will cause phase jumps`);
|
|
}
|
|
}
|
|
|
|
// A scene with a palette param should actually use the palette helpers,
|
|
// otherwise the look generator cannot recolour it.
|
|
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
|
|
if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
|
|
fail(`${id}: declares a palette but never calls pal()/palRamp()`);
|
|
}
|
|
// Hardcoded saturated colours are the thing that made the party-stage
|
|
// shaders resist recolouring; flag the obvious cases.
|
|
const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
|
|
const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
|
|
if (hasPalette && suspicious.length > 2) {
|
|
fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
|
|
}
|
|
}
|
|
|
|
if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
|
|
else console.log(` (${scenes.length} scenes checked)`);
|
|
}
|
|
|
|
console.log('');
|
|
if (failures) {
|
|
console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
|
|
process.exit(1);
|
|
}
|
|
console.log('all static gates passed\n');
|