ActorGenerator is the cast with bodies: generateActor{mpl} takes
{summary,rng,archetype,personality,identity} and returns a serialisable
ActorSpec — same audio-tilts-centre / seed-picks-within rule as
Personality/Identity, forked rng so adding an actor never shifts later
decisions. Five archetypes (monolith/swarm/walker/vehicle/structure),
per-track actor set on look.actors, HUD helper included. Stage C will
grow as a library on this without infra changes.
Mesh twin of Identity.form: actors/meshes.js builds BufferGeometry from
the same assembly (cast SDF → Shape → ExtrudeGeometry, box/capsule/
torus/sphere primitives, symmetry folding radial/mirror/stack). Shared
with the shader impostor path — one character, two projectors.
Renderer depth targets: createDepthTarget / createTarget{depthTexture}
for WebGL DepthTexture plumbing.
Compositor shared rig: one PerspectiveCamera + DepthTexture so a ground
mesh can occlude a subject mesh from another layer. 4/scale dolly,
Personality.camera drift/sway/spin, framing shift — matches particles.js
and shader epilogue behaviour. ModelLayer (kind:model) with
build/update(actorSpec) and sharedCamera injection; createLayer dispatches
on model. Shader contract gains MODEL_PREAMBLE.
LookGenerator now derives actors before scenes; ArcDriver._actorFor +
_layerFor wires ActorSpec into ModelLayer; schema validates kind:model
and actor archetype; lint determinism gate covers actors/.
Gate: lint 107 files clean, 70 shader literals, 68 scenes green; vite
build 294 modules; ActorGenerator determinism + mesh smoke tests pass.
Co-Authored-By: Claude <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', 'actors'];
|
|
// 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');
|