# `.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**: `.lpp` is 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@1` pins 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@1` is 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-current` varies 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 `AdditiveBlending`
  is `(SrcAlpha, One)`, so it multiplies by alpha itself; emitting
  `vColour * alpha` squared it and the faintest marks — the whole nebula —
  disappeared.
- **`softIdx` is 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-stars` numbers its marks `point.index % 8` while
  painting and `floor(mark.index / 10) % 8` while animating, so replaying
  `move()` literally collapsed eight colour families into two. The grouping
  is now reconstructed from what each mark actually IS: a new `dot` begins 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 shared
  `params` table 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_BEHAVIORS` is 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's `nebulaFadeCycle`, 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:

- **`ring` was missing from the shape table.** The bright-star brush paints
  halos with it, and v1 never listed it.
- **`compileLpp` was packing every shape as 0.** A mark names its shape
  (`"streak"`) and the buffer stores an index; passing the name to
  `packMarks` made `Number("streak")` NaN, clamped to `dot`. 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 `.lpp` compiled 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).
  `LINKS` is 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 as `links`, and the package declares the `strokeLinks` capability 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 by
  `MAX_STROKE_LINKS` so 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). `objectScale` in the `.lpp` sits 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 as `alives[].scale`,
  clamped to `ALIVE_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_MODES` in
  schema.js is a WHITELIST, not a pass-through: the value ends up in a canvas
  `globalCompositeOperation`, and a package carries data — it must never be
  able to name an arbitrary operation. `screen` is 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

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. **`photoSample`** — `ripple`, `growth`, `firefly` and the star brushes
   all read the shell photograph while drawing.
3. **A source-alpha behavior** — `fade-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

```jsonc
{
  "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.** `seed` is 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. `allowPartial` produces a package anyway,
  but every omission is listed in `refusals` inside 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

```bash
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:

```jsonc
{ "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.js` entry, CPU reference
  in `behaviors.js`, GLSL in `markShader.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:

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.
