A silhouette is the same picture from every angle, so a scene that turns one is
showing you the same shape rotated. That is the ceiling the cast has been under:
it can be notched and hollowed and it still cannot be walked around.
So the identity generates an ASSEMBLY — two to six parts, each a primitive with
an offset, a scale, a rotation and a boolean op, all under a symmetry. The
symmetry is the load-bearing half: parts unioned at random positions read as
debris, the same parts folded read as designed, and only a designed object is
worth calling a protagonist. Prism parts are the existing 2D profile extruded,
so the solid and the silhouette stay one character rather than two generators
running side by side.
It travels as data like every other artifact: three vec4 rows per part in
u_formPart, plus the scalars. Scenes declare `consumes: ['form']` and get
castSDF3, castMarch, castSolid, castChorusSolid and castLit; with no identity
they fall back to the flat profile extruded, so the helpers are safe to call
unconditionally. The chorus is the same rows with fewer parts and its own
proportions — a relative, not a second generator, and no extra uniforms.
Three scenes carry it. Effigy is new and holds the object still while it turns.
Floating Geometry and Swarm were already loops of stamps and are now loops of
bodies; Swarm is what the chorus solid exists for. That is 34.8% of videos
containing a 3D cast, against 11.9% when only Effigy had it.
Measured, the outline does change rather than merely spin: over one turn the lit
area of Effigy's subject varies 14-113% against Soloist's 3-51% for the same
rotation. Whether that reaches the variety blocks is not yet measured, and
HOWTO-variety says so rather than claiming the win.
Four costs, each found by measuring rather than by reading:
* every pixel evaluated every instance's field — Swarm at 59ms/frame against a
60ms ceiling. Bounding-sphere reject first, now 12.3ms.
* instances overlap several deep at the top of the size range, and marching
all of them made Floating Geometry's own gate run for minutes. First-wins
instead of last-wins, which was arbitrary either way.
* a normal inside the march loop multiplies four copies of the SDF by the step
count, because GLSL unrolls a fixed bound. Hoisted out.
* the helpers in the shared preamble made all 68 scenes compile what 3 of them
call. FORM_PREAMBLE is appended per scene instead.
The lint's backtick check was green through two of my own breakages: quoting a
name in a doc comment adds backticks in PAIRS, so parity survives and the
pair-scanner just re-partitions the file. It now finds where a shader literal
opens and requires the next backtick to be a real terminator — which
immediately found a second stray pair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
426 lines
21 KiB
JavaScript
426 lines
21 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',
|
|
'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt',
|
|
'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe',
|
|
'u_sigHorizon', 'u_sigDepth', 'u_sigWash',
|
|
'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold',
|
|
'u_sigFrameScale', 'u_sigFrameShift',
|
|
// The identity artifacts. See look/Identity.js. A scene reads these through
|
|
// castMain/inkMask/stageNode rather than directly, but a migrated scene may
|
|
// legitimately branch on one — Prism Bloom takes its facet count from the
|
|
// cast's side count — so they belong in the contract set.
|
|
'u_castSides', 'u_castRound', 'u_castElong', 'u_castTilt',
|
|
'u_castNotchN', 'u_castNotchD', 'u_castHollow',
|
|
'u_chorusSides', 'u_chorusRound', 'u_chorusElong', 'u_chorusTilt',
|
|
'u_chorusNotchN', 'u_chorusNotchD', 'u_chorusHollow',
|
|
'u_inkWeight', 'u_inkEdge', 'u_inkFill', 'u_inkHatchAngle',
|
|
'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize',
|
|
'u_latKind', 'u_latJitter', 'u_latSpread',
|
|
'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
|
|
'u_focusN', 'u_focusR', 'u_focusPull', 'u_impact',
|
|
'u_formCount', 'u_formSym', 'u_formSymN', 'u_formBlend', 'u_formDepth',
|
|
'u_formPart', 'u_formChorusN', 'u_formChorusSym', 'u_formChorusSymN',
|
|
'u_formChorusFlat', 'u_formChorusThin',
|
|
]);
|
|
|
|
/**
|
|
* What counts as honouring a personality trait, in shader source.
|
|
*
|
|
* The disqualification rule in look/Personality.js is only as good as these
|
|
* declarations: a scene that claims `shape` and draws circles anyway will be
|
|
* cast in the hexagon video and be the one shot that looks filmed elsewhere.
|
|
* So the claim is machine-checked against the source rather than trusted.
|
|
*/
|
|
// A scene that draws the song's CAST is expressing `shape` more completely than
|
|
// sigShape ever did — the form is the subject rather than a hint applied to one
|
|
// — so castMain/castChorus count as evidence.
|
|
//
|
|
// The INK counts under `style` too, but only since surface treatment moved out
|
|
// of the scenes. It did not when the two were independent — an earlier version
|
|
// listed it here, Eclipse Field claimed a trait it had stopped honouring, and
|
|
// the runtime gate caught it. What changed is that sigGrain went to the post
|
|
// chain and sigEdge became inkStroke, leaving the trait with nothing of its own
|
|
// to express, so inkStroke and inkMask now read u_sigLine and u_sigSoft
|
|
// directly. A scene drawing in the song's hand honours the track's line weight
|
|
// by construction, and the runtime probe confirms it rather than taking it on
|
|
// trust.
|
|
//
|
|
// The same idea as TRAIT_EVIDENCE, for the identity artifacts: a scene that
|
|
// declares it consumes the cast has to actually draw it. Without this,
|
|
// `consumes` is a comment, and the migration becomes unverifiable the moment it
|
|
// is more than a handful of files.
|
|
const ARTIFACT_EVIDENCE = {
|
|
// The solid. Distinct from `cast` rather than a superset of it: these names
|
|
// deliberately do not match the cast pattern below, so a scene that marches
|
|
// the object is not also made to declare the silhouette it never stamps.
|
|
form: /\b(castSDF3|castChorus3|castNormal3|castChorusNormal3|castMarch|castSolid|castChorusSolid|castLit)\s*\(/,
|
|
cast: /\bcast(Main|Chorus|SDF|Form)\s*\(/,
|
|
ink: /\bink(Mask|Value|Pattern|Stroke)\s*\(/,
|
|
staging: /\b(stageNode|stageScale)\s*\(/,
|
|
};
|
|
|
|
const TRAIT_EVIDENCE = {
|
|
// Marching the solid is the fullest expression of `shape` there is: the
|
|
// prism parts ARE the signature profile, given a body.
|
|
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF|Form|SDF3|Chorus3|March|Solid|ChorusSolid))\s*\(/,
|
|
camera: /\bsigCamera\s*\(/,
|
|
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
|
|
style: /\b(sigEdge|sigGrain|sigFolded|ink(Mask|Stroke|Value|Pattern))\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
|
|
};
|
|
|
|
// Every shader in this project lives inside a JS template literal, so a
|
|
// backtick anywhere in one silently closes it. Five times now that has cost a
|
|
// debugging round: in the preamble, where it produces a check page that hangs
|
|
// on "starting…" with an empty console, and in scenes, where at least the
|
|
// module fails to parse loudly. A GLSL comment is the natural place to reach
|
|
// for backticks when quoting a param name, which is exactly why this keeps
|
|
// happening.
|
|
//
|
|
// HOW THIS IS CHECKED, and why the obvious way does not work. The first version
|
|
// counted backticks for parity and scanned each `...` pair for stray ones. Both
|
|
// tests pass when the mistake comes in a PAIR — quoting `prop` in a doc comment
|
|
// adds two, parity survives, and the pair-scanner simply re-partitions the file
|
|
// into different "literals" and finds nothing inside them. That version was in
|
|
// place, green, while the contract was broken.
|
|
//
|
|
// So the check is anchored instead: find where a shader literal OPENS, then
|
|
// require that the next backtick is a real terminator — one followed by the
|
|
// comma, semicolon or brace that closes the declaration. A backtick anywhere in
|
|
// between is the bug, whatever the file's parity says.
|
|
//
|
|
// Checked across the contract AND every scene, since the scene case is the one
|
|
// a mechanical pass over sixty files will keep reintroducing.
|
|
/**
|
|
* Index of the backtick that closes the template literal starting at `from`, or
|
|
* -1. Interpolations are skipped wholesale, nested templates and all — the
|
|
* preamble builds its uniform block with `${LIST.map((u) => \`…\`)}`, and those
|
|
* inner backticks are legal.
|
|
*/
|
|
function endOfLiteral(src, from) {
|
|
let i = from;
|
|
let depth = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
if (c === '\\') { i += 2; continue; }
|
|
if (depth === 0 && c === '`') return i;
|
|
if (c === '$' && src[i + 1] === '{') { depth++; i += 2; continue; }
|
|
if (depth > 0) {
|
|
if (c === '{') depth++;
|
|
else if (c === '}') depth--;
|
|
else if (c === '`') {
|
|
const inner = endOfLiteral(src, i + 1);
|
|
if (inner < 0) return -1;
|
|
i = inner;
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
console.log('\nshader literals');
|
|
{
|
|
const targets = [join(SRC, 'engine/shader-contract.js'), ...walk(join(SRC, 'scenes'))];
|
|
let clean = 0;
|
|
let literals = 0;
|
|
for (const file of targets) {
|
|
const src = readFileSync(file, 'utf8');
|
|
const rel = relative(SRC, file).replace(/\\/g, '/');
|
|
const problems = [];
|
|
|
|
// Where a shader string is declared: `shader: \`` or `const X = \``.
|
|
const opens = /(?:shader\s*:|[A-Za-z_$][\w$]*\s*=)\s*`/g;
|
|
let m;
|
|
while ((m = opens.exec(src)) !== null) {
|
|
const from = m.index + m[0].length;
|
|
const end = endOfLiteral(src, from);
|
|
if (end < 0) { problems.push('unterminated template literal'); break; }
|
|
|
|
const body = src.slice(from, end);
|
|
// Only shader strings are governed; an ordinary template literal is
|
|
// free to contain whatever it likes.
|
|
if (!/\bvec4 scene|precision highp|void main|gl_Position/.test(body)) continue;
|
|
literals++;
|
|
|
|
const after = src.slice(end + 1, end + 4);
|
|
if (!/^\s*[,;)\]}]/.test(after)) {
|
|
const line = src.slice(0, end).split('\n').pop();
|
|
problems.push(`closed early at: ${line.trim()}`);
|
|
}
|
|
opens.lastIndex = end + 1;
|
|
}
|
|
|
|
if (problems.length) {
|
|
fail(`${rel}: backtick inside a shader literal — it closes the string:\n` +
|
|
problems.map((p) => ` ${p}`).join('\n'));
|
|
} else clean++;
|
|
}
|
|
if (clean === targets.length) ok(`${literals} shader literals close where they should`);
|
|
}
|
|
|
|
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') {
|
|
// 3D modules honour traits in JS, against the `personality` handed
|
|
// to update(); there is no shader source to grep, so the evidence
|
|
// check is just that they read it at all.
|
|
if ((module.traits || []).length && !/personality/.test(String(module.update))) {
|
|
fail(`${id}: declares traits but update() never reads \`personality\``);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const src = module.shader || '';
|
|
// Comment-free copy for rules that ask "does the code do X". A scene
|
|
// that mentions prev() in a comment explaining why it does NOT rely on
|
|
// prev() should not be failed for relying on prev().
|
|
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
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)`);
|
|
}
|
|
|
|
// Personality traits: declaring one is a promise to express it.
|
|
for (const trait of module.traits || []) {
|
|
const evidence = TRAIT_EVIDENCE[trait];
|
|
if (evidence && !evidence.test(src)) {
|
|
fail(`${id}: declares trait '${trait}' but the shader never uses it — ` +
|
|
`either express it or drop the claim, or the scene will be cast ` +
|
|
`in tracks built on something it ignores`);
|
|
}
|
|
}
|
|
|
|
for (const artifact of module.consumes || []) {
|
|
const evidence = ARTIFACT_EVIDENCE[artifact];
|
|
if (evidence && !evidence.test(src)) {
|
|
fail(`${id}: declares it consumes '${artifact}' but the shader never calls it — ` +
|
|
`an artifact that is declared and ignored is worse than one that is ` +
|
|
`not declared, because the casting code will believe it`);
|
|
}
|
|
}
|
|
// The reverse: using an artifact without declaring it hides the scene
|
|
// from the migration status report and from anything that selects on
|
|
// capability later.
|
|
for (const [artifact, evidence] of Object.entries(ARTIFACT_EVIDENCE)) {
|
|
if (evidence.test(src) && !(module.consumes || []).includes(artifact)) {
|
|
fail(`${id}: uses the '${artifact}' artifact but does not declare it in \`consumes\``);
|
|
}
|
|
}
|
|
|
|
// A dead camera: `p = sigCamera(p)` and then nothing reads p again.
|
|
// This passed the evidence grep above, passed review, and shipped — the
|
|
// Phase 9 render gate later measured the scene's response to the camera
|
|
// at exactly zero. Cheaper to catch here than on a GPU.
|
|
const cameraAssign = code.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
|
|
if (cameraAssign) {
|
|
const target = cameraAssign[1];
|
|
const after = code.slice(code.indexOf(cameraAssign[0]) + cameraAssign[0].length);
|
|
const reads = new RegExp(`\\b${target}\\b`).test(after);
|
|
if (!reads) {
|
|
fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` +
|
|
`the trait is declared but the image cannot change`);
|
|
}
|
|
}
|
|
|
|
// A scene whose only content is the previous frame is black on its first
|
|
// frames and different after a seek than after playback.
|
|
if (/\bprev\s*\(/.test(code)) {
|
|
const bodyBeforePrev = code.slice(0, code.search(/\bprev\s*\(/));
|
|
if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) {
|
|
fail(`${id}: reads prev() without generating a base image first — ` +
|
|
`it will be black until feedback converges and will not survive a seek`);
|
|
}
|
|
}
|
|
|
|
// Loop cost. GLSL needs a constant bound, so the pattern here is a
|
|
// generous fixed bound plus an early break on the param that actually
|
|
// decides the count — that break is what keeps the cost proportional to
|
|
// what the look asked for. A big bound WITHOUT one runs every iteration
|
|
// on every pixel at 4K, which the budget check will catch on a GPU and
|
|
// this catches in a second.
|
|
for (const loop of code.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
|
|
const bound = Number(loop[1]);
|
|
const body = code.slice(code.indexOf(loop[0]) + loop[0].length, code.indexOf(loop[0]) + loop[0].length + 600);
|
|
const breaksEarly = /\bbreak\s*;/.test(body);
|
|
// Opt-out for a genuinely fixed-cost loop — sampling a curve at a
|
|
// fixed resolution has nothing to break on. The author states it,
|
|
// and the measured 4K budget check still governs.
|
|
const at = src.indexOf(loop[0].replace(/\s+/g, ' ')) >= 0
|
|
? src.indexOf(loop[0].replace(/\s+/g, ' '))
|
|
: src.search(new RegExp(`for\\s*\\(\\s*int\\s+\\w+\\s*=\\s*0\\s*;\\s*\\w+\\s*<\\s*${bound}\\b`));
|
|
const optOut = at >= 0 && /\/\/\s*lint:\s*fixed-cost/.test(
|
|
src.slice(Math.max(0, at - 220), at));
|
|
if (optOut) continue;
|
|
|
|
if (bound > 64) {
|
|
fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`);
|
|
} else if (bound > 16 && !breaksEarly) {
|
|
fail(`${id}: loop of ${bound} with no early break — bound it on the param ` +
|
|
`(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`);
|
|
}
|
|
}
|
|
|
|
// 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');
|