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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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
30 changes: 30 additions & 0 deletions docs/backlog/backlog-light.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,36 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on

Compiling inside `defineControls` is NOT the fix (tried): it makes the default script's controls exist before `setSource` runs, and swapping the source then re-seeds every control from its new declared default — the same value-loss, moved. The real fix is ordering: the engine must compile once the persisted `source` is in place but before controls are published, which is a Scheduler-phase question (the same parent-before-child ordering the `const_cast` in `MoonLiveLayout::compile` already works around). Affects all three MoonLive bindings, not just the layout.

- **Xtensa's vreg map violates the windowed ABI** (2026-08-13). ROOT CAUSE, found by comparing a
working ESP32-S31 (RISC-V) against a crashing S3 on identical firmware and scripts.

`call8` rotates the register window by eight: the callee's `a0..a7` ARE the caller's `a8..a15`, so
every host call clobbers `a8..a15`. ESP-IDF states it plainly — `a8..a15 clobbered (if
window_spill8)` in `components/xtensa/include/xtensa/coreasm.h`. Our map is `a2..a11`, so `a8..a11`
hold script values inside the rotation window. Measured crash: `A0 = 0x00000100` — that is
`nLights` (256), a script value sitting in the return-address register. Intermittent, because
whether it is fatal depends on which vreg held what when the window turned; that intermittency is
what made it survive a day of plausible theories.

Only `a0`(return address), `a1`(sp) and `a2..a7` survive a call, so the safe map is **six** vregs,
not ten. Six does not fit today: five are the fixed ABI vregs (`buf`, `nLights`, `cpl`, `t`,
`ctrls`), leaving one. Closing this needs those five OFF permanent registers and into the frame —
the register-promotion question [Plan-20260813](../history/plans/Plan-20260813%20-%20MoonLive%20on%20a%20stack%20machine%20%E2%80%94%20the%20frame%20is%20where%20values%20live.md)
defers on purpose. RISC-V is unaffected: no window, and its `call()` saves 14 registers into an
explicit 80-byte frame, which is why the S31 runs layout + plasma + modifier stably.

Until then a scripted layout, and any effect whose script CALLS a builtin, is unreliable on Xtensa.
Straight-line and inline-only scripts (`setRGB`, `fill`) are fine — they emit no call.

- **A scripted LAYOUT crashes on Xtensa: the emitted code is wrong** (2026-08-12). Naming any script on a `MoonLiveLayout` resets an S3 — `addLight(0, 0, 0);` alone is enough. A scripted EFFECT on the same board is fine, which is the whole clue: an effect writes pixels through `setRGB`, a layout calls `addLight`, and only the layout path faults.

**Measured, so the earlier theories are retired.** Per-stage timings on the S3 read `stat 2.0 ms, alloc 8 us, read 2.7 ms, compile 0.5 ms` — the compile COMPLETES, in about five milliseconds, and the crash comes after it returns. So this is not a slow compile and not the task watchdog running out: tracing either side of `runScript` shows the fault lands *inside the JIT'd code*, as `Guru Meditation Error: Double exception` with a corrupted backtrace, or as `LoadProhibited` at a nonsense address. (Filesystem access IS expensive — ~90% of the load — but that is a cost, not the bug.)

**The defect is visible in the disassembly.** `uv run moondeck/moonlive/disasm.py '<script text>'` on the grid shape shows the call sequence ending:
`mov.n a11, a12` (the call result into the destination) immediately followed by `movi a11, 1`, which overwrites it; the loop then compares against registers restored from the wrong frame slots, so the bounds are not the loop's own. The counters run away and `addLight` is called unboundedly until the host callback exhausts the stack — which is exactly the `Double exception` signature.

**Xtensa only.** The identical script on the arm64 host backend calls `addLight` exactly 256 times for a 16×16 grid, so the core compiler and the register allocator are right and the fault is in `XtensaAssembler::call` / the Xtensa lowering of a void call whose destination register is a live vreg. No test executes Xtensa code (only arm64 runs in tests), which is why this shipped — the gap the MoonLive plan named. A test that runs emitted Xtensa bytes, or a `disasm.py` assertion over the shipped scripts, is what would have caught it.

- **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.

- **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`.
Expand Down
Loading
Loading