.lpr — Lucas Painting Runtime, version 1
- Status: v1 implemented 2026-08-02 (
src/inception/lpr/), then paused the same day at the owner's request:.lppis the extensible authoring format and he is building real examples first, so the runtime format should be shaped by what those examples actually contain. Nothing here is wired into the museum runtime — the inherited renderer still draws every room — so the pause costs nothing. - Authority: execution plan §5. This page is the format's own contract.
v2 in progress — resumed 2026-08-02 with a real corpus
The pause ended when the owner had five .lpp projects in the repo (content/live-projects/): three inherited Van Gogh rooms, the archived corridor starlight sky, and sky-floor-water-v1 for The Sky's floor. Run
node scripts/lpp-report.mjs
to see what they actually need. The two questions the pause was about are now answered by what the corpus shows.
1. One seam: the compiler reads .lpp
src/inception/lpr/lppProject.js turns an archive's two JSON files into plain data — stage, ordered layers, strokes, marks, warp fields, brushes. It touches no filesystem and no zip library, so every rule in it is a Node test. The curated export is now just another consumer of the same reading.
2. Identity is the CODE SHAPE, so parameters can never break a package
This is the part the corpus decided. A .lpp keys brushes by the sha256 of their source, so moving one slider mints a brand-new revision hash. The inherited importer needs a hand-written table entry per revision and already carries 56. That is the "un-representable" failure mode in practice: authoring moves, and the runtime silently cannot follow.
But the revisions are overwhelmingly the same code with different numbers. So a brush's reviewable identity here is shape:<sha256> — the hash of its source with every top-level const NAME = value blanked out — and its parameters are read off the revision separately:
| brush revisions | code shapes to review | |
|---|---|---|
| van-gogh-sunflower-parlor-1a | 12 | 4 |
| van-gogh-starry-studio-1b | 21 | 5 |
| van-gogh-cypress-bedroom-1c | 8 | 4 |
| space-museum-corridor-starlight-v5 | 3 | 3 |
| sky-floor-water-v1 | 2 | 2 |
| corpus | 46 | 18 |
Six ripple revisions are one review. Fourteen starry-liquify-spin revisions are one review. Turning a slider can no longer require a code change, which is exactly what "extensibility without breakage" had to mean; changing the code still faces review, which is what safety requires.
registry.js holds that table: signature -> { behavior, version, role } and nothing else. Parameter ranges are deliberately NOT duplicated there — each brush states its own in its @ui line, so the compiler validates against the artist's bounds and the schema keeps only a safety envelope. A role is part of the review: a shape reviewed as a warp can never be accepted as a mark source, whatever a document claims.
3. What a renderer does with something it has never heard of
Nothing — because it never sees it. Resolution happens once, in the compiler. An unreviewed shape becomes a declared gap: the package sets complete: false and lists, per code shape, which brushes and how many revisions were left out and why. validateLpr refuses a package that claims completeness while listing gaps, or hides gaps while claiming incompleteness. A renderer then refuses an incomplete package by default, and capability negotiation is unchanged.
Behavior versions carry the rest: a package names water-current@1, and a renderer that only implements a later revision can say precisely what it cannot draw instead of guessing.
v2 status (2026-08-02)
.lpr v2 carries warp fields — the first capability the corpus asked for, and the one The Sky's floor is made of. A warp travels as its behavior, its parameters, and the authored mask verbatim ({ encoding: "artlab-rle-v1", data }), opaque to this format and decoded by whichever renderer claims warpField.
npm run lpr # compile the corpus, declaring gaps
npm run lpr:verify # recompile and compare; fails if a package is stale
| project | marks | warp fields | state |
|---|---|---|---|
| sky-floor-water-v1 | 0 | 2 | complete |
| van-gogh-sunflower-parlor-1a | 8 | 4 | 2 gaps |
| van-gogh-cypress-bedroom-1c | 0 | 2 | 3 gaps |
| space-museum-corridor-starlight-v5 | 0 | 0 | 3 gaps |
| van-gogh-starry-studio-1b | 0 | 0 | 5 gaps |
The owner's newest painting is the first that compiles end to end.
photoSample is carried too. A ripple ring re-reads its colour from the source photograph at its moving outer edge on every frame, so the colour cannot be baked into the mark: instead the package names the photograph by content — photo: { source, sha256, width, height } — and validateLpr refuses a photo-sampling package that does not. Colours are only reproducible against that exact image, so the hash is part of the contract, and no image bytes travel inside the package.
Replaying a warp field
warpField.js holds the two reviewed warp behaviors as pure functions — (position, time, parameters) -> numbers — transcribed from the brush sources, which is why the constants look arbitrary (0.47, 0.73, 0.61 are the artist's). Purity is the point: a GPU implementation can later be compared against these mark for mark.
water-current@1pins the left and right edges of the painted box and moves everything between them, so a moving source image can never open a crack along the edge of a stroke. Tests assert the pinning at both edges, that the middle actually moves, that displacement stays inside the artist's wave height, and that the same clock gives the same answer.source-veil@1is not a displacement: it drives the picture's own opacity around a hold / fade / hold / fade cycle, feathering in from the stroke's untouched edge. Tests assert the cycle wraps, that the fade is monotone (no flicker on the way out), and that a negative clock is the same cycle.- The mask decodes from its authored run-length pairs or not at all — half a warp field is worse than none.
On screen
warpFrame.js turns a field into a plan — source rectangles with pixel offsets, and rectangles to erase the picture by — pure data, so a frame can be asserted in Node without a canvas and the GPU renderer has something concrete to reproduce. drawWarpFrame consumes the plan and contributes no arithmetic of its own.
Three details that are easy to get wrong — the first two versions of this renderer got two of them wrong, and the owner's verdict was immediate:
- Strip direction is declared, not guessed.
water-currentvaries with x alone, so its strips are columns; slicing it into rows would draw a plausible-looking painting that is not the authored one. - Strip width has nothing to do with the mask. The 96 x 64 grid says WHERE a stroke was painted; displacement varies smoothly and must be sampled far more densely. Resampling one strip per mask column meant 13px blocks each shifted by a constant — sliding tiles, not water. Strips are 4 canvas pixels regardless of the mask.
- Displacement is authored in the painting's own pixels. A face drawn at 1024 from a 1254 painting must move proportionally less, or the water is too violent for its own picture.
Marks run on the GPU too
The first QA found three bugs worth naming, because each is a trap the next behaviour will meet:
- Do not premultiply under additive blending. Three's
AdditiveBlendingis(SrcAlpha, One), so it multiplies by alpha itself; emittingvColour * alphasquared it and the faintest marks — the whole nebula — disappeared. softIdxis an index, not a byte. It selects one of the brush's four softness levels. Dividing by 255 made every mark hard-edged, which is why the stars read as flat coloured discs with, in the owner's words, "no star in the middle". A star needs a concentrated core term on top of its halo, not a single falloff curve.- When a brush's paint-time and animate-time code disagree, trust the mark.
natural-bright-starsnumbers its markspoint.index % 8while painting andfloor(mark.index / 10) % 8while animating, so replayingmove()literally collapsed eight colour families into two. The grouping is now reconstructed from what each mark actually IS: a newdotbegins a new star, and core / ray / halo come from the shape.
The three sky brushes were reviewed on 2026-08-03 and they share the property that makes this cheap: their move() returns no displacement and samples no photograph — only size, alpha and colour, as pure functions of the mark's index, its seed and the clock. So nothing is repainted. The marks upload once as instance attributes and animate in a vertex shader.
markShader.js carries the transcription: eight Messier colour families, eight bright-star families with their eight breathing curves, and the nebula's two-sine breath. natural-bright-stars paints a star as several marks — a core, diffraction rays and sometimes a halo — so the fragment shader draws dot, streak and ring differently, and a streak is stretched and turned by its authored angle.
The whole 4096 x 2048 sky is 85 instanced billboards and one uniform a frame. For comparison, the same painting on the canvas path would repaint and re-upload roughly 32 MB per refresh.
A reviewed code change is a VERSION, and versions have their own knobs
v7 of the sky (owner, 2026-08-03) rewrote Photo Nebula Breath: the two-sine breath is still there, multiplied by an endless envelope — dark rest → fade in → bright hold → fade out → repeat, each cloud entering at its own point in the loop — with six new @ui controls and a floor that keeps some light when a cloud is dark. New code, new meaning, so the registry maps that shape to photo-nebula-breath version 2 rather than editing version 1. The v5 package still names @1 and still looks exactly as it shipped.
Two consequences worth keeping:
- Parameters belong to a version, not to a behaviour. The schema entry may carry
paramsByVersion;paramsFor(kind, version)is what the compiler and the validator read. Adding the six loop controls to the sharedparamstable would have made every v1 package "missing" values it never had, and replaying v2's data as v1 is now a validation error rather than a loop that silently stops. - The shader dispatches on
behavior@version.MARK_BEHAVIORSis keyed that way, so a version the GPU draws differently gets its own slot; drawing it differently is the entire point of the version.nebulaLoopLevel()is the JS twin of the shader'snebulaFadeCycle, and the test asserts the property the owner asked for: over six cycles and four seeds, every cloud reaches full brightness and returns to rest. Nothing can dim a cloud for good.
The v7 sky is 770 marks (48 distant stars, 20 bright-star marks, 702 of cloud) against v5's 85, still one uniform a frame. Its compiled package is 73 KB of JSON inlined in the bundle, and its shell is byte-identical to the panorama the museum shows — the clouds' colours are baked from that exact picture, which a test checks by hash.
Two things this surfaced:
ringwas missing from the shape table. The bright-star brush paints halos with it, and v1 never listed it.compileLppwas packing every shape as 0. A mark names its shape ("streak") and the buffer stores an index; passing the name topackMarksmadeNumber("streak")NaN, clamped todot. Every halo and every diffraction ray had been flattened into a plain dot. A test now asserts the sky carries more than one shape.
Alive layers: a whole image as one moving object
The celestial-rain painting (2026-08-04) introduced a third kind of content beside marks and warp fields: alive layers. The author extracts real pixels from a source photograph into whole-canvas image layers and writes a move(t, object) that transforms the ENTIRE layer — for the rain, four "speed groups" of celestial objects each fall their own distance per cycle, with a smoothstep fade at both ends hiding the reset, so at t = 0 the four layers reassemble the extracted source composition.
The format treats them with exactly the brush discipline:
- The move() source is a code shape, blanked-consts hash and all, and it must be reviewed into the registry (role
"alive") before it can ship. Before alive layers existed in the pipeline, this.lppcompiled to a package that drew NOTHING while reporting complete — the silent drop the format forbids. Now an unreviewed alive shape is a gap. - Every layer image is named by content hash (
alives[].image), like the sampled photograph: the runtime redraws those files verbatim, so a different file is silently a different painting. The four groups' code is identical to the comment line, so the registry holds four signatures pointing at one behavior —celestial-fall@1— exactly like liquid-warp's two shapes. - Runtime:
resolveAliveLayer(kind, params, seconds)in behaviors.js is the pure reference; the canvas surface draws shell → alive layers (offset and faded per frame) → marks. Capability:aliveLayer. - Strokes may name a constellation link distance (v4, owner 2026-08-05).
LINKSis a per-STROKE setting, not brush code — the same brush links or does not depending on how the stroke was painted — so it rides on the stroke aslinks, and the package declares thestrokeLinkscapability so a renderer that cannot draw lines refuses instead of quietly shipping loose dots. The semantics are Art Lab's own contract, transcribed: lines between marks closer than LINKS px, alpha falling with distance, bounded byMAX_STROKE_LINKSso a dense stroke can never draw a quadratic number of lines. Lines go under the marks and add light, so a link is the thread between two lights rather than a bar across them. - A stroke's seed reaches its behaviour as
mark.strokeSeed(v4). That is what makes sixteen arc strokes light in azimuth order — the travelling spiral in the transit chamber's polar exit, with nothing rotating at all. - A cut-out object carries the size the author drew it at (owner 2026-08-06).
objectScalein the.lppsits beside the mask, not among the brush's ALL-CAPS constants, because it is a studio control rather than code — which is exactly how it went missing: the fish shrunk from 0.55 to 0.3 in the studio arrived in the museum at 1, while the movement changes in the same save arrived correctly. It now travels asalives[].scale, clamped toALIVE_SCALE(0.05–4) so a package can never ship a cut-out at ten times its own size, and the renderer multiplies it into every pose the behavior computes. The field travels only when the studio recorded one, so packages authored before the control stay byte-identical and mean "as cut out". Lesson worth keeping: whatever the studio lets an author change, the compiler has to read — a control the format ignores looks to the author like a broken save. - Layers may name a blend mode (v3, owner 2026-08-05).
BLEND_MODESin schema.js is a WHITELIST, not a pass-through: the value ends up in a canvasglobalCompositeOperation, and a package carries data — it must never be able to name an arbitrary operation.screenis what the rain needs: its extracted objects keep a near-black matte, and drawn source-over that matte cuts square dark holes into the background it falls over. Screening adds light and cannot subtract. The field travels only when it is not the default, so every package written before blend modes stays byte-identical.
Warps run on the GPU, not on a canvas
The owner's second verdict — "still slow, and it looks fractured, all stripes" — was the technique, not the tuning. Warping a picture is a per-pixel operation: every output pixel reads the photograph from a displaced position. A CPU renderer can only approximate that by copying strips, so it leaves a seam at every strip edge, shows un-displaced photo where a strip moved away, and pays a full canvas repaint plus a 4 MB upload per refresh. No amount of narrowing the strips fixes the first three.
warpShader.js does it properly. The photograph is uploaded once; the mask travels as one 96 x 64 RGBA texture (red = the current's coverage, green = the veil's, blue = depth inside the veil's stroke); and the fragment shader samples photo at vUv - displacement, letting the sampler interpolate. Per frame the CPU sets two numbers: the clock and the veil's alpha.
| Canvas2D strips | GPU shader | |
|---|---|---|
| seams | one per strip (~670) | none — per pixel |
| CPU per frame | repaint a 1024² canvas | two uniforms |
| GPU upload per frame | ~4 MB | none |
| animation rate | the paint scheduler's 8 Hz | the display's 60 Hz |
A warp-only package therefore takes no scheduler turn at all — it is not a painting surface. The Canvas2D path stays as the reference the two are compared against, and a test recomputes the shader's arithmetic in JS and asserts it matches waterCurrent at several points, so the two cannot drift apart.
The two fields are independent
A warp brush and a veil brush are separate authored fields, and the runtime must not couple them. The current always runs; the veil only changes the picture's opacity. I briefly made the veil calm the water instead — reading "identical layer source and shell" as evidence that fading was invisible — and the owner's report was exact: "it stops and goes quiet after a few seconds, and nothing fades to transparent." Coupling them replaced the authored moment with a dead pause.
Fading the picture out is the effect: where the veil is down, whatever lies behind the surface shows through. On The Sky's floor that is open space, which is the point. On the GPU it is one line — the fragment's alpha — so it is per-pixel and feathered, not a stack of erased rectangles.
A room face wears a package by naming it: faces[].lpr alongside the image that is its photograph. LprArtworkSurface presents the same canvas / update / paint-cost surface as an inherited Live Painting, so the paint scheduler, the Live-effects slider and the quality presets govern it without knowing the difference. Before drawing a frame it hashes the image it was handed and refuses if it is not the photograph the package names — replayed colours are only the authored ones against the authored picture.
A trap worth knowing about: the legacy opcodes are curated-shaped
The six mark opcodes inherited from v1 were parameterised by the curated intermediate, not by the brushes. They disagree: the curated lissajous-heartbeat carries speed, while the brush that drew it declares BEAT_A, BEAT_B, LOOP and FLOW. Mapping a .lpp code shape onto one of them would silently mis-parameterise the art — the exact failure the one- seam rule exists to prevent. So each is re-derived from the brush source when it comes up for review, the way ripple was, and until then its shapes stay in the queue.
What the corpus is asking for, in order
- Warp fields — 9 of the 18 shapes are warps (
liquid,water-ripples,fade-veil)..lprv1 cannot carry them at all, and The Sky's water floor is entirely warps. photoSample—ripple,growth,fireflyand the star brushes all read the shell photograph while drawing.- A source-alpha behavior —
fade-veilanimates the picture's own opacity (100% to 2%, hold, return). Nothing in v1 or in the inherited renderer expresses it. - Canvas-relative geometry — the inherited warp path hard-codes 960 x 640; the corpus contains 1440 x 720 and 1254 x 1254.
What it is for
.lpp stays the authoring file: photographs, masks, editor state, and Function Brush source. It is rich, private, and full of code.
.lpr is what a published painting ships as. One rule shapes everything else: a package carries data, never code. No JavaScript, no shader source, no expressions to evaluate. A renderer replays a package by reading numbers, so a stranger's artwork can never run on a classmate's machine.
Shape of a package
{
"format": "lucas-painting-runtime",
"version": 1,
"id": "van-gogh-starry-studio-1b",
"canvas": { "width": 960, "height": 640 },
"clock": { "timeAnchor": 0, "timeOrigin": "scene-enter", "unit": "seconds" },
"capabilities": ["marks"], // what a renderer must support
"layers": [ { "visible": true, "opacity": 1, "blendMode": "source-over",
"timeScale": 1, "timeOffsetMs": 0 } ],
"strokes": [ { "layer": 0, "behavior": "twinkle",
"params": { "speed": 30, "twinkleRate": 2 }, "count": 553 } ],
"buffers": [ { "count": 553, "columns": [...], "floats": "<base64>",
"bytes": "<base64>" } ],
"stats": { "marks": 553, "strokes": 84 },
"provenance": { "source": "...", "sourceSha256": "...", "lppSha256": "..." },
"refusals": [], // what the compiler would not take
"hash": "sha256 of everything above"
}
Marks are typed columns, one buffer per stroke: eight Float32 columns (x y size alpha angle born life seed) and six byte columns (red green blue shape softIdx glow). Six thousand marks as JSON objects would be megabytes to parse before the first frame; as columns they are one allocation that a GPU can later consume as instance attributes unchanged.
Hard boundaries (plan §5.2)
- No executable content. Behaviors are opcodes from a fixed table in
src/inception/lpr/schema.js; every parameter has a declared range. - Budgets. 20 000 marks, 400 strokes, 8 layers, 4096 px canvas, 64 distinct behaviors. Over the line is a compile error.
- A fixed clock, no runtime randomness.
seedis baked per mark, so the same package plays identically everywhere — which is what makes a screenshot a valid conformance test. - Deterministic output. Floats are rounded before packing and the package is hashed over a canonically-sorted serialization, so compiling the same source twice is byte-identical.
- No silent degradation. The compiler refuses a painting it cannot replay and names the reason.
allowPartialproduces a package anyway, but every omission is listed inrefusalsinside the file.
Capability negotiation
A package declares what it needs; a renderer declares what it has; a mismatch is a refusal, never a partial picture.
| Capability | Meaning | v1 |
|---|---|---|
marks | instanced marks with per-mark colour and transform | yes |
curvePath | marks that follow an authored path | no |
photoSample | per-mark colour sampled from the source photograph | no |
warpField | whole-surface liquid displacement | no |
Canvas2DReferenceRenderer advertises ["marks"], so it refuses any package needing the other three rather than drawing part of the artwork.
Behaviors in v1
lissajous-heartbeat, twinkle, galaxy, firefly, color-liquify-splash, color-liquify-breakout — all pure functions of (mark, params, seconds) in src/inception/lpr/behaviors.js. That file is the specification of motion: the GPU renderer must reproduce it in a shader and is judged against it with the same fixtures.
Deferred, and why: curve-current needs a path buffer, ripple and growth need per-mark colours sampled from the source photograph, and liquid-warp displaces the whole surface. Each needs a data model v1 does not have. Adding one is a version bump, not an undocumented extension.
Consequence today: none of the three inherited paintings compiles in full. Strict mode reports exactly what is missing; npm run lpr:compile uses partial mode and the committed packages record their gaps.
| Painting | Marks kept | Left out |
|---|---|---|
van-gogh-sunflower-parlor-1a | 618 | ripple, liquid-warp |
van-gogh-starry-studio-1b | 553 of 6140 | curve-current |
van-gogh-cypress-bedroom-1c | 97 | growth, liquid-warp |
Closing that gap is the rest of Phase 2: a path buffer and a baked photo-sample column would bring all three to complete packages.
Working with it
npm run lpr:compile # write content/lpr/*.lpr.json (partial mode)
npm run lpr:verify # recompile and compare, byte for byte, writing nothing
node scripts/compile-lpr.mjs # strict: refuse anything v1 cannot replay
The verify is wired into npm run check (as live:check, the same --partial --verify command) and has been since the format settled. It is the tripwire described under "Regression" below: it must stay there.
Plain answers to the questions that keep coming up (2026-08-10)
Asked and answered in plain language, because the sections above describe the mechanism without ever saying what it costs to use.
Is a .lpr binary or JSON?
JSON — content/lpr/*.lpr.json, pretty-printed, LF-pinned (.gitattributes), hash-stamped. Not naïve JSON, though: the bulk per-mark data rides inside it as base64 typed columns (see "Shape of a package"), and the photograph is named, never carried — only {source, sha256, width, height}. That is where the size goes:
| package | .lpp source | committed .lpr.json |
|---|---|---|
space-museum-celestial-rain-v5 | 1.7 MB | 4.0 KB |
sky-floor-water-v5 | 6.8 MB | 20 KB |
space-museum-corridor-starlight-v7 | 2.2 MB | 156 KB (1447 marks) |
A .lpp is a ZIP (PK\x03\x04, stored) carrying the project, the images and the brush source code. A .lpr carries no code at all.
Why does converting take effort? It is not distrust of the author
Because .lpp is a program and .lpr is data. In the art lab a brush is JavaScript that runs per mark, per frame. The museum wraps six 1024² faces plus a full-screen chamber and has to hold 60 fps on an iPad, so it cannot run authored JS per mark per frame — that is exactly the gate both engines failed in the bake-off, and the reason this format exists.
So compiling replaces your brush's code with an opcode plus numbers, and the renderer holds a hand-written shader per opcode. All four rain layers of the chamber compile to this shape:
{ "behavior": "celestial-fall", "behaviorVersion": 1,
"params": { "travel": 230, "cycle": 10.8, "start": 0.1, "fadeIn": 0.075, "fadeOut": 0.16 },
"image": { "source": "space-rain-speed-group-1.png", "sha256": "…" } }
Five numbers and a named image. Hence the cost model:
- A brush whose motion already has an opcode (renamed, refactored, re-tuned, restructured) → one line in
registry.js. No code. - A genuinely new motion → a new opcode:
schema.jsentry, CPU reference inbehaviors.js, GLSL inmarkShader.js, tests. That is code, once.
23 behaviors exist; the five committed packages use 10. So yes — the more paintings that go through, the cheaper the next one gets, and the ceiling is the vocabulary rather than the registry. Painting with brushes already in the registry is fully automatic today.
A new brush never blocks anyone either: lpr:compile runs --partial, so an unreviewed shape becomes a declared gap and only that layer goes dark.
The registry key is the code, not the brush name
brushSignature (lpr/lppProject.js) is shape:sha256(normalizeBrushSource(source)), and normalization is one rule: every top-level const UPPER_NAME = value; becomes const UPPER_NAME = <v>;. Therefore:
- Drag a slider → same hash. Values are excluded, so retuning is never a new brush. This is the whole point: keyed by revision the inherited importer needed 56 entries for a handful of brushes; keyed by shape this corpus needs ~18.
- Change one character of anything else — including a comment → new hash. The key is deliberately over-sensitive. It never assumes two brushes are the same; it asks a human.
- Two brushes with different NAMES and the same logic share one entry, automatically. Name and id are recorded (
ids,revisions) only so a gap message can name the brush; they are never the key. Renaming is free.
The v7 nebula shows both edges: a renamed rewrite iterated over 46 revisions produced 30 distinct hashes, all then mapped by hand onto the same photo-nebula-breath@2, because once comments and the paint-time brush() were set aside the move()/fadeCycle math was identical. Thirty hashes, one behavior, no new shader.
Regression, when many brushes share one behavior
Four guards, and one honest gap:
role— a shape reviewed as a warp can never be accepted as a mark source, whatever a document claims.version, with both alive at once. Behaviors are versioned rather than mutated:behaviors.jsbranches onversion >= 2andmarkShader.jslistsphoto-nebula-breath@2as its own path, so the v5 sky keeps @1's look permanently while v7 gets @2's endless loop. Sharing a behavior name is not sharing its math.- The byte-exact recompile is the tripwire.
npm run checkrecompiles every.lppand compares to the committed.lpr.jsoncharacter for character, so any registry or compiler change that would alter an existing painting fails before it can be committed. (This is why the LF.gitattributespin matters — the guard only works on stable bytes.) - Two independent implementations, cross-checked. A CPU reference (
behaviors.js) and a GPU shader (markShader.js), with tests asserting they agree — "the shader states the same arithmetic the brushes do", "the loop in the shader is the loop in the reference" — plus locks against the real committed packages.
The gap: guard 3 cannot see shader edits. Changing the GLSL for photo-nebula-breath@2 leaves every package byte-identical, so check stays green while every painting using @2 quietly looks different — only the tests in guard 4 stand in the way. Acceptable today because those tests assert the arithmetic itself. If the vocabulary grows a lot, add a reference-render snapshot per behavior (render a known package to a small offscreen buffer and hash the pixels) so a shader edit has to declare itself the way a compiler edit already does.
Why this unlocks the GPU renderer
The reference renderer's cost is CPU painting plus a canvas upload — two thirds of an in-room frame today (docs/PERFORMANCE.md). Because motion is a pure function over typed columns, the same package can instead be drawn as instanced quads inside the 3D engine's own GPU context, with no canvas in the loop at all. The format exists to make that port a mechanical exercise rather than a rewrite.