Skip to content
Merged
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
79 changes: 0 additions & 79 deletions .claude/settings.local.cleaned.json

This file was deleted.

2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Commit message: title ≤ 72 characters, imperative. Then a 1–3 sentence end-u

The PO pushes the branch; external review runs on the PR; findings are processed on the branch. On "run pre-merge": `uv run moondeck/event/premerge.py`, which re-runs the mechanical checks over the whole branch diff and lists the judgment gates it cannot decide.

Those judgment gates: review feedback addressed; the Reviewer agent over the whole branch diff (start it first, it runs in parallel; scope: boundaries, bespoke conventions, unnecessary abstractions, duplication, hot path, spec conformance, bloat); lessons carried forward only when VERY important — most learning lives in the commit/PR record; a truly important gotcha → `lessons.md`, a major architectural decision → a new ADR, a hardened rule → CLAUDE.md or coding-standards; docs sync; the PR title and description matching the actual diff; the performance snapshot when tick-path code changed; the permission review (prune the accumulated local list and snapshot the approved result — never broaden destructive or network-mutating permissions without explicit approval, err toward tight); a README refresh when build, flash, or first-run changed.
Those judgment gates: review feedback addressed; the Reviewer agent over the whole branch diff (start it first, it runs in parallel; scope: boundaries, bespoke conventions, unnecessary abstractions, duplication, hot path, spec conformance, bloat); lessons carried forward only when VERY important — most learning lives in the commit/PR record; a truly important gotcha → `lessons.md`, a major architectural decision → a new ADR, a hardened rule → CLAUDE.md or coding-standards; docs sync; the PR title and description matching the actual diff; the performance snapshot when tick-path code changed; a README refresh when build, flash, or first-run changed.

### Release

Expand Down
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ else()
endif()
endif()

# MM_MOONLIVE_NO_HOST_JIT builds this machine the way a desktop with NO MoonLive backend sees the
# code. Every x86-64 desktop is one (Windows, Linux, Intel macOS ship no backend), so a test that
# wrongly presumes a compile succeeds passes on the arm64 bench and fails only once CI runs it.
# A compile DEFINITION rather than a CMAKE_CXX_FLAGS entry: this file configures warnings through
# add_compile_options, which does not consume that variable, so a flag passed there would reach the
# cache and never the compiler.
option(MM_MOONLIVE_NO_HOST_JIT "Build as a desktop with no MoonLive backend" OFF)
if(MM_MOONLIVE_NO_HOST_JIT)
add_compile_definitions(MM_MOONLIVE_FORCE_NO_HOST_JIT=1)
endif()

# `uv` is the project's Python launcher (see CLAUDE.md / moondeck/MoonDeck.md).
# The build invokes Python helpers for build_info.h generation and UI embedding;
# resolving them through `uv run python …` keeps Windows (where `python3` isn't
Expand All @@ -94,6 +105,7 @@ add_library(mm_core STATIC
src/core/Scheduler.cpp
src/core/moonlive/MoonLive.cpp
src/core/moonlive/MoonLiveCompiler.cpp
src/core/moonlive/MoonLiveSpill.cpp
)
target_include_directories(mm_core PUBLIC src/)
target_link_libraries(mm_core PUBLIC mm_platform)
Expand Down
19 changes: 18 additions & 1 deletion docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ 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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### 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.
Expand All @@ -32,7 +49,7 @@ A **layout** is the one script that legitimately used those names for its own co

| 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 script's `source` control, renaming its own controls (the shipped `grid.mlv` uses `cols`/`rows`) |
| 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.
Expand Down
25 changes: 25 additions & 0 deletions docs/backlog/backlog-light.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,31 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on

- **A scripted modifier that reshapes the grid** (2026-08-10). `ModifierBase::modifyLogicalSize` lets a modifier change the logical `width`/`height`/`depth` — a Multiply kaleidoscope grows the grid, a crop shrinks it — and a compiled modifier uses it. A SCRIPTED one cannot: system variables are read-only, so `MoonLiveModifier` writes the box in and never reads it back. Needs a writable system variable — the binding reads the slots after the script returns and reports the result through `modifyLogicalSize` — which is a new `SysVarKind` (or a mutable flag on `SysVar`) plus the read-back, not a new builtin. Until then a scripted modifier can fold coordinates but not resize the grid they live in.

- **Editing a script's CONTENTS through /api/file does not recompile it** (2026-08-14). A binding
caches `compiledHash_` and skips the compile while it is non-zero; the hash is cleared when the
script NAME changes (`onControlChanged`, `setScript`), but a write to `/moonlive/<same-name>` via
the File Manager leaves it set, so the layout keeps running the previous code until the name is
touched or the device reboots. `MoonLiveModifier` does not have this: it re-hashes the source on
every prepare and compares, which is the shape to copy.

The fix belongs at the filesystem seam rather than in the binding — a write under `/moonlive/`
invalidates whatever compiled from that path — so it is a small core/HTTP change, not a MoonLive
one. Pre-existing, not introduced by the stack-machine work.

- **MoonLive has no x86-64 backend — scripts do not run on Windows** (2026-08-14). The desktop
assembler (`moonlive_asm_host.cpp`) is arm64-only, so `MM_MOONLIVE_HAS_HOST_JIT` is 0 on x86-64
Windows, x86-64 Linux and Intel macOS. `compileSource` fails cleanly there and scripted modules
render dark — no crash, but no MoonLive either, on the desktop platform most users run. Apple
Silicon macOS is the only desktop where scripts work today, which is why this stayed invisible:
the bench is arm64 and CI's x86-64 runners gate their MoonLive tests on the macro.

Closing it is one more backend behind the unchanged IR (the seam's whole promise): an
`x86_64` branch alongside the three that exist. It is the widest ISA of the four — variable-length
encoding, and a different calling convention per OS (System V on Linux/macOS, Microsoft x64 on
Windows), so the `call()` save-set and argument registers differ from everything written so far.
`disasm.py --isa x86_64` should land with it, since no test executes emitted bytes for any backend
but the host's.

- **Drain MoonLive's `print()` through a queue** (2026-08-09). `print(v)` writes to serial directly, and an EFFECT script runs on the render tick — so a print inside one blocks the frame for as long as the UART takes. The burst cap bounds it (a handful of writes per compile, then a compare and a return), but bounded is not free, and `tick()` is annotated `MM_NONBLOCKING`.

**What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for.
Expand Down
49 changes: 49 additions & 0 deletions docs/history/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,52 @@ share a shape: a green build, plausible-looking output, and a defect only counti
no amount of reading the new code reveals. Diffing old against new across every span and
2.8M inputs found it in seconds. **For a hot-path primitive whose replacement is meant to be
behaviour-identical, prove it by exhaustive comparison against the original.**

## Lessons from the MoonLive-on-Xtensa branch (the register-window frame bug)

MoonLive's JIT ran on desktop arm64 and on RISC-V, and reset every Xtensa board the moment a
script called a host function. Three days, because every check we had was looking at the
instructions and the defect was in the stack layout around them.

- **On Xtensa, the top 32 bytes of a call8 frame belong to the hardware, not to the routine.**
The window-overflow handler (`_WindowOverflow8`, esp-idf `components/xtensa/xtensa_vectors.S`)
writes two 16-byte bands into a spilled frame: the top 16 take an *older* frame's a0-a3
(`s32e aN, a9, -16..-4`), and the next 16 take the routine's *own* a4-a7
(`s32e aN, a0, -32..-20`, where a0 is that frame's top). GCC obeys the same rule — every
call8-making function it compiles reserves locals + exactly 32, at every size. Our emitter
reserved 16, so the parked control-arena pointer sat inside the second band and a window
spill overwrote it with an expression temp. **A frame-layout question is answered by the
platform's own spill handler and a compiler probe, not by the ISA prose.**

- **Window rotation is deterministic; the SPILL is not, so the contract is spatial.** Rotation
happens only at `call8`/`retw`, in our own instruction stream. The spill is lazy: it fires on
any interrupt that lands while a call is in flight, at an instruction we do not choose. No
ordering, no code sequence and no "quiet moment" can dodge it — only the layout can be right
or wrong. This is also the whole symptom explanation: deep call chains (plasma's `sin`/`beat`
into libm) gave every tick interrupt a wide window and died in under a second, brief leaf
calls almost never coincided, and call-free scripts never faulted at all. **A crash whose
frequency tracks how LONG a call runs, rather than what it computes, is an interrupt-timing
bug — look at what the hardware writes asynchronously, not at the code path.**

- **Byte-perfect encodings prove the instructions, and say nothing about the frame.** Round-trip
checks against `xtensa-esp32-elf-as` passed for every instruction we emit, including the
`entry` whose immediate was the bug. Two other backends running the identical IR pipeline were
flawless, which read as "the compiler is fine, the target is haunted". **When a defect is
target-specific and every static check is green, suspect the contract with the platform
(frame, ABI, alignment) rather than the code generator.**

- **The guardrail had to see the bug before the fix went in.** `MM_ISA_RESERVED_TOP` was raised
to 32 in the structural checker *first*, and it failed on the shipped emitter, naming the
offending frame offset. Only then did the emitter constant change. A test written after a fix
proves the fix compiles; a test that fails first proves it *detects*. The checker decodes the
real `entry` immediate out of the emitted bytes, so it pins the invariant rather than the
constant.

- **A reserve constant is coupled to the widest call the emitter can produce.** 32 is exact for
`call8`; `call12` would need 48 (its extra save area holds a8-a11 as well). We only ever emit
`call8`, so the checker's 32 is an equality — but nothing in the code says so, and a future
wider call would leave the test green while the frames went wrong again. hpwit, who wrote the
new-parser Xtensa compiler, independently reached the same floor from experience ("I always
start at at least 32") and phrases it as a minimum for exactly this reason. The reserve is
therefore derived from the emitted call opcode rather than written down, so widening the call
moves the reserve with it or fails the build.
Loading
Loading