Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A high-performance multi-platform system driving large LED installations and DMX

## The Process

Every change follows the same timeline: **main → branch → build → test → document → commit → merge → release**. The **product owner** (PO) is the person initiating a branch — any contributor can be one. The PO initiates every event and every gate list — never start one unprompted; if unsure, ask ("Feature work is done; run pre-commit, or do you want to look first?"). A conditional check runs only when its objective trigger matches; an applicable-but-skipped check needs a one-line reason in the commit/PR/release notes. Each cycle produces visible output, and each cycle subtracts: remove code and docs that no longer earn their place, or know why nothing can go — `backlog/` and `history/` shrink too. External contributors follow the same timeline: fork, branch, PR into main — the same checks and review apply.
Every change follows the same timeline: **main → branch → build → test → document → commit → merge → release**. The **product owner** (PO) is the person initiating a branch — any contributor can be one. The PO initiates every event and every gate list — never start one unprompted; if unsure, ask ("Feature work is done; run pre-commit, or do you want to look first?"). This holds even when a gate script would only be *checking* work in progress: running `precommit.py`/`premerge.py` to see where things stand is still starting a gate list, and it writes the logs the PO's own run reports from. Verify work in progress with the individual tools instead (a build, `ctest`, one check script); the event scripts are the PO's to fire. A conditional check runs only when its objective trigger matches; an applicable-but-skipped check needs a one-line reason in the commit/PR/release notes. Each cycle produces visible output, and each cycle subtracts: remove code and docs that no longer earn their place, or know why nothing can go — `backlog/` and `history/` shrink too. External contributors follow the same timeline: fork, branch, PR into main — the same checks and review apply.

### Main

Expand Down Expand Up @@ -64,7 +64,7 @@ Docs land with the code, not at merge time: the module's spec and catalog card d

### Commit

Git only with the PO in the loop: staging, committing, and pushing happen only when the PO explicitly triggers them. What and when to commit or merge is 100% the product owner's call — never ask or propose commit timing. One combined commit per cycle (no partial commits; hygiene changes fold into the next one). Branches and commits may bundle multiple topics: not every small change gets its own commit — the pre-commit and pre-merge checks would be too much overhead.
Git only with the PO in the loop: staging, committing, and pushing happen only when the PO explicitly triggers them. **The PO verifies EVERY changed file before it is committed.** That is the rule the others serve: nothing reaches history unseen. Two things follow, and both have been broken. **The trigger is the words "commit now", never a task instruction** — "fix it", "do step 4", "the build is broken", even "hotfix it on main" say what to change and nothing about recording it; finishing the work is not a prompt to commit it. And **a "commit now" covers only the files the PO has actually looked at** — touch one more, anything at all, and the tree again holds something unverified, so the go-ahead is void until they see it. Stop at a clean tree, say exactly which files changed, and wait. On main exactly as on a branch; a one-line fix exactly as a feature. What and when to commit or merge is 100% the product owner's call — never ask or propose commit timing. One combined commit per cycle (no partial commits; hygiene changes fold into the next one). Branches and commits may bundle multiple topics: not every small change gets its own commit — the pre-commit and pre-merge checks would be too much overhead.

On "run pre-commit": `uv run moondeck/event/precommit.py`. It runs every gate whose trigger the change matches and reports PASS / FAIL / SKIP / MANUAL. Then wait for an explicit "commit now".

Expand Down
36 changes: 2 additions & 34 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul

**Read this when upgrading a device that already holds persisted state.** Entries are newest first. Each says what changed and what to do; most need nothing at all, because the lost value re-populates on next use.

**MoonLive is exempt until it launches.** Nobody is running scripts on a device yet, so a break in the script language or its storage cannot strand anyone, and an entry here would describe an upgrade path no user can take. Its breaking changes are recorded in the commit and PR record instead. This exemption ends at the first release that ships MoonLive as a supported feature; from then it follows the same rule as everything else.

**Action legend** — how much work an entry costs you:

| Action | Meaning |
Expand All @@ -20,40 +22,6 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul

## Unreleased (`next-iteration`)

### MoonLive scripts move to the filesystem (2026-08-11)

A scripted module used to carry its script as a `source` textarea — a fixed 1 KB array per module, plus a second 1 KB copy to notice edits, **resident whether or not a script was loaded**. Six modules cost 13 KB of a classic ESP32's 320 KB for text that was mostly empty. The script now lives in a file under `/moonlive/`, and the module holds only its **name** (~32 bytes): it is read into a right-sized buffer to compile and freed immediately, so nothing script-sized stays in RAM. A script is bounded by the filesystem instead of by a 1 KB array.

**Action: *re-add a module* — or, to keep your scripts, *update a file* first.**

The `source` control no longer exists, so a persisted `"source"` value is an unknown key and is ignored (the robust-reader rule). A MoonLive module therefore boots with **no script**, reporting `no script — set the script name`, and renders nothing until one is named.

| What | Why | What to do |
|---|---|---|
| Your script text | It was persisted under `source`, a control that is gone | **Copy it out before updating** — it is in `/.config/Layouts.json` (or `Effects.json`) as `"N.source"`. Save it as `/moonlive/<name>.mlv` via the File Manager, then set the module's `script` control to `<name>.mlv` |
| The module's own controls | A script's `@control` sliders exist only once it has compiled, so they are absent until a script is named | Nothing — they reappear with the script, keeping their persisted values |

`/moonlive/` is created on demand: naming a script is enough to make the folder appear, so a fresh device needs no setup.

**Editing today** goes through the File Manager rather than the module's own card. Wiring the card's editor to the same file is a separate change.

### MoonLive: a script can no longer declare a name the engine supplies (2026-08-10)

`t` (elapsed milliseconds), `width`/`height`/`depth` (the logical grid) and `x`/`y`/`z` (the light a modifier is transforming) are now **system variables** the engine supplies, so a script cannot declare one. Previously each binding faked them by prepending hidden declarations to the script, which meant an effect could declare its own `width` and quietly disagree with the layer it was drawing into.

Each module supplies only the names it writes, so what is reserved depends on the module: a layout gets `t` alone, an effect adds the grid, a modifier adds the coordinate. **`x` and `y` remain usable as loop counters in a layout or an effect.**

**Action: *update a file*, for scripted layouts only.**

A **layout** is the one script that legitimately used those names for its own controls: it *defines* where lights are, so it has no grid to be handed. A persisted layout script declaring `uint8_t width = 16;` now fails to compile with `name is a system variable`, and the layout places no lights — the fixture is **dark** until the script is edited.

| What | Why | What to do |
|---|---|---|
| A scripted layout declaring `width`/`height` | The name is what the layout is defining, so the declaration is a compile error and no lights are placed | Edit the `.mlv` file in the File Manager, renaming its own controls (the shipped `grid.mlv` uses `cols`/`rows`), then set the module's `script` control to that file |
| A scripted **modifier** using `x`, `y` or `z` as a loop variable | A modifier IS handed a coordinate under those names, so they cannot also be counters there | Rename the loop variable to something the modifier is not handed (`i`, `n`) |

Effects and modifiers need no change: they were already being handed these values, just through a preamble instead of by name. The error names the clash, and the module shows it on its card, so a broken script says why rather than failing silently.

### The `Layers` container is renamed to `Effects` (2026-08-08)

The three top-level light containers are now **Layouts, Effects, Drivers** — L.E.D. The old name sat one character from its own child (`Layers` holding `Layer`s) and read as a near-twin of `Layouts`, which is the pair a newcomer actually has to tell apart. The tree is unchanged in shape: `Effects` → `Layer`s → effects and modifiers.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ The engine is a **domain-neutral core** with one narrow seam, structured as thre

A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `prepare()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark — robust to any input. The module contract is [MoonLiveEffect](moonmodules/light/MoonLiveEffect.md).

**A scripted module differs from a compiled one in one thing only: where its behaviour comes from.** Everything else is the same mechanism — the same base class, the same `prepare()`/`release()` lifecycle, the same controls, the same status and memory reporting, the same container contract. A `MoonLiveLayout` is a `LayoutBase` that answers `lightCount()` and `forEachCoord()` like any other; it just answers them by running compiled machine code instead of arithmetic over its members. When a scripted binding needs a mechanism its compiled sibling does not, that is a finding: either the mechanism belongs in the base for everyone, or the divergence needs its reason stated where it is introduced. A binding that drifts into its own lifecycle stops being a module and becomes a second system to maintain.
**A scripted module differs from a compiled one in one thing only: where its behaviour comes from.** Everything else is the same mechanism — the same base class, the same `prepare()`/`release()` lifecycle, the same controls, the same status and memory reporting, the same container contract. A `MoonLiveLayout` is a `LayoutBase` that answers `lightCount()` and `placeLights()` like any other; it just answers them by running compiled machine code instead of arithmetic over its members. When a scripted binding needs a mechanism its compiled sibling does not, that is a finding: either the mechanism belongs in the base for everyone, or the divergence needs its reason stated where it is introduced. A binding that drifts into its own lifecycle stops being a module and becomes a second system to maintain.

The one place this is not yet clean: `applyState()` prepares parent-before-child, so a container asks its children for their extent before those children have prepared. A compiled layout computes its count from its members and does not notice; a scripted one has nothing to answer with until it compiles, so it compiles on demand from a `const` method — the `const_cast` and `mutable` members in `MoonLiveLayout` exist for that and for nothing else. Removing them means giving core a way for children to prepare before a container aggregates them, which is a lifecycle change for every module.

Expand Down
35 changes: 33 additions & 2 deletions docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,9 @@ Sequencing rule (unchanged): each functionality lands a device-side control firs

### Per-layout coordinate offset for independent placement (backlog)

`Layouts` stitches multiple child layouts into one physical light space, but only their *indices* are stitched (offset sequentially in `forEachCoord`) — their *coordinates* are not translated. Two layouts therefore overlap in the same coordinate box: two 64×64 grids both occupy x,y ∈ 0..63, so the Layer's dense bounding-box buffer is 64×64 (4096 voxels) even though the container reports 8192 lights, and the second layout's lights land on the first's positions. `scenario_Layouts_mutation` documents this (its steps assert pipeline liveness, not buffer-size arithmetic).
`Layouts` stitches multiple child layouts into one physical light space, but only their *indices* are stitched (offset sequentially in `placeLights`) — their *coordinates* are not translated. Two layouts therefore overlap in the same coordinate box: two 64×64 grids both occupy x,y ∈ 0..63, so the Layer's dense bounding-box buffer is 64×64 (4096 voxels) even though the container reports 8192 lights, and the second layout's lights land on the first's positions. `scenario_Layouts_mutation` documents this (its steps assert pipeline liveness, not buffer-size arithmetic).

When picked up: add `offsetX/Y/Z` (lengthType) controls to `LayoutBase`; `Layouts::forEachCoord` translates each child's emitted coords by its offset so layouts occupy disjoint regions of the physical extent (a 64-wide grid at offsetX=64 sits beside another at offsetX=0 → a 128×64 combined extent). `Layer::onBuildState` already derives physical dims from the max emitted coordinate, so it would pick up the wider extent automatically. Until then, "multiple layouts" means "multiple layouts sharing a coordinate box", which is only useful when they genuinely overlap (e.g. a sphere inscribed in a grid).
When picked up: add `offsetX/Y/Z` (lengthType) controls to `LayoutBase`; `Layouts::placeLights` translates each child's emitted coords by its offset so layouts occupy disjoint regions of the physical extent (a 64-wide grid at offsetX=64 sits beside another at offsetX=0 → a 128×64 combined extent). `Layer::onBuildState` already derives physical dims from the max emitted coordinate, so it would pick up the wider extent automatically. Until then, "multiple layouts" means "multiple layouts sharing a coordinate box", which is only useful when they genuinely overlap (e.g. a sphere inscribed in a grid).

### Improv as a child of NetworkModule (deferred — needs scheduler work first)

Expand Down Expand Up @@ -281,6 +281,37 @@ The **bottom-up landscape survey** is done — [livescripts-analysis-bottom-up.m

## HTTP and OTA

### Duplicate module names are reachable, and silent (backlog)

Two modules in the tree may hold the SAME name. Found on the bench: a classic ESP32 had a
`MoonLiveLayout` and a `MoonLiveEffect` both called `MoonLive`, one under `Layouts` and one under a
`Layer`. Nothing reported it. The UI keys a card's controls by module name, so both cards resolved to
the same entry and the effect's `bpm`/`zoom` sliders rendered under the LAYOUT's heading, where its
own `petals`/`radius` should have been. The server data was correct throughout; only the display was
wrong, which is what makes it hard to recognise.

`Scheduler::ensureUniqueName` exists and is called on `/api/modules` creation and after a persistence
load, so the tree normally cannot reach this state. The bench pair predates that pass or arrived
through a path that skipped it, which is exactly the case a check would catch. **The gap is that
nothing NOTICES:** a name collision is tolerated silently rather than reported, and the first symptom
is a UI showing another module's controls.

Fix: assert uniqueness after the persistence load and report a collision in the module status, so a
device that reaches this state says so instead of rendering the wrong card. Renaming a module from
the UI would also give a user a way out; there is no `name` control today.

### Deleting a module by name removes the FIRST match (backlog)

`DELETE /api/modules/<name>` resolves through `findModuleByName`, which returns the first match in
tree order. With a duplicate name (above) that is not necessarily the module the caller meant: on the
bench, deleting the effect by name would have removed the layout, because the layout came first.

Noticed while repairing that device, and avoided only by reading the handler before running the
request. It is latent rather than dangerous today, because duplicates are supposed to be impossible,
but the two issues compound: the state that makes a delete ambiguous is the same state nothing warns
about. Fix alongside the check above, either by refusing an ambiguous delete or by addressing a
module by a path rather than a bare name.

### HTTP file serving blocks the render tick (backlog)

`HttpServerModule::handleConnection()` serves large embedded files (`app.js`, `style.css`) with the blocking `TcpConnection::write` — a page load can briefly stall `loop20ms`. One-shot per load (lower priority than the per-tick preview issue, which is fixed). Fix: serve large HTTP responses with `writeChunks` (the same non-blocking path used for preview frames).
Expand Down
Loading
Loading