How a child's .lpp painting compiles into declarative, executable-code-free room art. · Rendered from docs/LPR-FORMAT.md in the project repository · view as Markdown

.lpr — Lucas Painting Runtime, version 1

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 revisionscode shapes to review
van-gogh-sunflower-parlor-1a124
van-gogh-starry-studio-1b215
van-gogh-cypress-bedroom-1c84
space-museum-corridor-starlight-v533
sky-floor-water-v122
corpus4618

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
projectmarkswarp fieldsstate
sky-floor-water-v102complete
van-gogh-sunflower-parlor-1a842 gaps
van-gogh-cypress-bedroom-1c023 gaps
space-museum-corridor-starlight-v5003 gaps
van-gogh-starry-studio-1b005 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 contentphoto: { 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.

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:

Marks run on the GPU too

The first QA found three bugs worth naming, because each is a trap the next behaviour will meet:

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:

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:

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:

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 stripsGPU shader
seamsone per strip (~670)none — per pixel
CPU per framerepaint a 1024² canvastwo uniforms
GPU upload per frame~4 MBnone
animation ratethe paint scheduler's 8 Hzthe 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

  1. Warp fields — 9 of the 18 shapes are warps (liquid, water-ripples, fade-veil). .lpr v1 cannot carry them at all, and The Sky's water floor is entirely warps.
  2. photoSampleripple, growth, firefly and the star brushes all read the shell photograph while drawing.
  3. A source-alpha behaviorfade-veil animates the picture's own opacity (100% to 2%, hold, return). Nothing in v1 or in the inherited renderer expresses it.
  4. 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)

Capability negotiation

A package declares what it needs; a renderer declares what it has; a mismatch is a refusal, never a partial picture.

CapabilityMeaningv1
marksinstanced marks with per-mark colour and transformyes
curvePathmarks that follow an authored pathno
photoSampleper-mark colour sampled from the source photographno
warpFieldwhole-surface liquid displacementno

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.

PaintingMarks keptLeft out
van-gogh-sunflower-parlor-1a618ripple, liquid-warp
van-gogh-starry-studio-1b553 of 6140curve-current
van-gogh-cypress-bedroom-1c97growth, 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?

JSONcontent/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 sourcecommitted .lpr.json
space-museum-celestial-rain-v51.7 MB4.0 KB
sky-floor-water-v56.8 MB20 KB
space-museum-corridor-starlight-v72.2 MB156 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:

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:

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:

  1. role — a shape reviewed as a warp can never be accepted as a mark source, whatever a document claims.
  2. version, with both alive at once. Behaviors are versioned rather than mutated: behaviors.js branches on version >= 2 and markShader.js lists photo-nebula-breath@2 as 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.
  3. The byte-exact recompile is the tripwire. npm run check recompiles every .lpp and compares to the committed .lpr.json character 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 .gitattributes pin matters — the guard only works on stable bytes.)
  4. 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.