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

- **A scripted modifier needs a way to drop a light** (2026-08-10). A coordinate slot is a byte, so a script that computes past 255 wraps: `shift.mlv` with a large `amount` lands lights back at the left edge instead of walking them off it. The Layer already drops an out-of-bounds position, but a script has no way to SAY out-of-bounds — every value it can write is a valid coordinate. Needs a sentinel the binding recognises (or a wider coordinate slot), at which point the "walks off the edge" behaviour a scroll modifier wants becomes expressible.

- **A scripted layout loses its control values across a reboot** (2026-08-11). Persistence SAVES them correctly (`/.config/Layouts.json` holds `"0.width":64`), but on boot they are loaded into a control set that does not exist yet: a script's controls are created by `defineControls`, which can only publish what the ENGINE declared, and the engine does not compile until `prepare()` — Scheduler phase 4, after the phase-2b re-bind. So the loaded value has nowhere to land and the script's declared default wins; a saved 64×64 grid comes back 16×16. Verified on the P4 bench.

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.

- **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
45 changes: 24 additions & 21 deletions docs/metrics/repo-health.json
Original file line number Diff line number Diff line change
@@ -1,51 +1,54 @@
{
"commit": "746c4e1c",
"commit": "9d77ade4",
"flash": {
"esp32": 1762368,
"esp32p4-eth": 1600848,
"esp32p4-eth": 1604272,
"esp32p4-eth-wifi": 1793760,
"esp32s3-n16r8": 1752384,
"esp32s3-n8r8": 1666992,
"esp32s31": 2024656,
"desktop": 1137800
"esp32s3-n16r8": 1753232,
"esp32s3-n8r8": 1753232,
"esp32s31": 2025600,
"desktop": 1137928,
"esp32-16mb": 1714608,
"esp32-eth": 1324928,
"esp32-wrover": 1765504
},
"perf": {
"desktop": {
"tick_us": 122,
"fps": 8196
"tick_us": 129,
"fps": 7751
},
"esp32": {
"tick_us": 2151,
"fps": 464
}
},
"loc": {
"core": 16973,
"light": 24268,
"platform": 12841,
"core": 16980,
"light": 24317,
"platform": 12860,
"ui": 6468,
"test": 41226,
"test": 41246,
"moondeck": 20323
},
"comments": {
"core": {
"lines": 6395,
"lines": 6402,
"ratio": 0.41
},
"light": {
"lines": 9406,
"lines": 9427,
"ratio": 0.429
},
"platform": {
"lines": 4353,
"ratio": 0.374
"lines": 4365,
"ratio": 0.375
},
"ui": {
"lines": 1670,
"ratio": 0.274
},
"test": {
"lines": 7161,
"lines": 7169,
"ratio": 0.201
},
"moondeck": {
Expand All @@ -54,19 +57,19 @@
}
},
"tests": {
"cases": 1324,
"cases": 1325,
"scenarios": 23
},
"docs": {
"md_files": 178,
"md_lines": 24393,
"md_lines": 24401,
"plans_files": 91,
"backlog_lines": 3625,
"backlog_lines": 3629,
"lessons_lines": 454,
"claude_md_lines": 135
},
"complexity": {
"functions": 2470,
"functions": 2473,
"over_threshold": 151,
"worst_ccn": 93
}
Expand Down
33 changes: 18 additions & 15 deletions docs/metrics/repo-health.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,54 @@
# Repo health

Measured at `746c4e1c`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
Measured at `9d77ade4`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**

Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.

## Firmware size

| Target | Flash |
|---|---:|
| desktop | 1,111 KB |
| desktop | 1,111 KB (+0 KB) ⚠ |
| esp32 | 1,721 KB |
| esp32p4-eth | 1,563 KB |
| esp32-16mb | 1,674 KB |
| esp32-eth | 1,294 KB |
| esp32-wrover | 1,724 KB |
| esp32p4-eth | 1,567 KB (+0 KB) ⚠ |
| esp32p4-eth-wifi | 1,752 KB |
| esp32s3-n16r8 | 1,711 KB (+0 KB) ⚠ |
| esp32s3-n8r8 | 1,628 KB |
| esp32s31 | 1,977 KB |
| esp32s3-n16r8 | 1,712 KB |
| esp32s3-n8r8 | 1,712 KB (+84 KB) ⚠ |
| esp32s31 | 1,978 KB |

## Render performance

| Target | Tick | FPS |
|---|---:|---:|
| desktop | 122 µs (−4 µs) | 8,196 (+260) ✓ |
| desktop | 129 µs (+1 µs) | 7,751 (−61) ⚠ |
| esp32 | 2,151 µs | 464 |

## Code

| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
| core | 16,973 (+16) ⚠ | 6,395 | 41.0 % |
| light | 24,268 (+4) ⚠ | 9,406 | 42.9 % |
| platform | 12,841 | 4,353 | 37.4 % |
| core | 16,980 | 6,402 | 41.0 % |
| light | 24,317 (+5) ⚠ | 9,427 | 42.9 % |
| platform | 12,860 | 4,365 | 37.5 % |
| ui | 6,468 | 1,670 | 27.4 % |
| test | 41,226 (+64) ⚠ | 7,161 | 20.1 % (+0.1 %) ⚠ |
| test | 41,246 | 7,169 | 20.1 % |
| moondeck | 20,323 | 3,246 | 18.3 % |

## Tests

| Kind | Count |
|---|---:|
| unit cases | 1,324 (+2) ✓ |
| unit cases | 1,325 |
| scenarios | 23 |

## Complexity

| Metric | Value |
|---|---:|
| functions | 2,470 |
| functions | 2,473 (+1) ✓ |
| over threshold | 151 |
| worst CCN | 93 |

Expand All @@ -54,9 +57,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri
| Metric | Value |
|---|---:|
| markdown files | 178 |
| markdown lines | 24,393 |
| markdown lines | 24,401 |
| plan files | 91 |
| backlog lines | 3,625 |
| backlog lines | 3,629 |
| lessons lines | 454 |
| CLAUDE.md lines | 135 |

2 changes: 1 addition & 1 deletion docs/moonmodules/light/MoonLiveEffect.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The controls are **derived from `source`** (one per declared `uint8` control; th
- **`MoonLiveCompiler`** (`src/core/moonlive/MoonLiveCompiler.h/.cpp`) — the **platform-independent front-end**: a recursive-descent lexer + expression parser that lowers each statement to the typed IR (`MoonLiveIr.h`). Pure (source + table in, IR out, deterministic). Knows the *language*, never an ISA and never a domain.
- **`MoonLiveBuiltins_light`** (`src/light/moonlive/MoonLiveBuiltins_light.h`) — the **light-domain registration**: the only place the LED vocabulary lives. Registers the whole vocabulary above — Inline ops lowering to stores, and Calls into host helpers — plus the system variables each binding supplies. A different host (display, sensor) writes its own table; the core is unchanged.
- **per-ISA assembler + lowering** (`src/platform/<target>/moonlive_asm_*` + `moonlive_lower_*`) — a tiny named-instruction MacroAssembler with label back-patching, and the IR→bytes lowering that drives it. Xtensa for the classic/S3 (`__XTENSA__`), the host ISA on desktop (arm64/x86-64). Adding an ISA is a new assembler + lowering; the front-end and IR are unchanged. (`emitFill`/`emitAnimatedFill` remain as the hand-encoded `fill` references the assembler's output is checked against.)
- **`MoonLiveEffect`** (`src/light/moonlive/MoonLiveEffect.h`) — the **thin binding**: a first-class `EffectBase` carrying the `source` control, whose `tick()` delegates to the engine over its own `buffer()` and passes the light builtin table to `compile`. The engine is projectMM-agnostic; the binding is the only coupled layer.
- **`MoonLiveEffect`** (`src/light/moonlive/MoonLiveEffect.h`) — the **thin binding**: a first-class `EffectBase` carrying the `source` control, whose `tick()` delegates to the engine over its own `buffer()`. `compile(source, table, sysvars)` takes both host tables: the shared `lightBuiltins()`, and the system variables THIS binding supplies — `effectSysVars()` here, `modifierSysVars()` for a modifier, `layoutSysVars()` for a layout, which is what decides the names each kind of script can read and cannot declare. The engine is projectMM-agnostic; the binding is the only coupled layer.

## Cross-domain wiring

Expand Down
8 changes: 6 additions & 2 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,11 @@ A `MoonLiveEffect` compiles its `source` text to native Xtensa once on the cold
| `setRGB(random16(256), 0, 255, 0)` (one host call) | 29 | ~140 B |
| `fill(0, 0, 255)` (loop over all lights) | 47 | ~68 B |

The tick cost is native-code speed — a `setRGB` is a bounds-guard + three byte stores (~26 µs including the per-tick module overhead), `fill` adds the per-light loop. The **exec block scales with the program**, not a fixed cap: a one-liner is tens of bytes of machine code (`place()` allocates the emitted length, word-rounded), reported as the module's dynamic memory (`setDynamicBytes(codeLen())`) so it shows on the UI card. At rest the engine itself is ~48 B of members + that exec block; the compile path's transient buffers (staging, IR, assembler ≈ 4 KB) live on the cold-path stack and are freed on return — see [docs/backlog/livescripts-analysis-top-down.md § 3.7](backlog/livescripts-analysis-top-down.md) for how this scales as the language grows.
The rows above are a dated S3 bench record; the numbers below them are what a desktop run measures today. The tick cost is native-code speed — a `setRGB` is a bounds-guard + three byte stores (~26 µs including the per-tick module overhead), `fill` adds the per-light loop. The **exec block scales with the program**, not a fixed cap: a one-liner is tens of bytes of machine code (`place()` allocates the emitted length, word-rounded), reported as the module's dynamic memory (`setDynamicBytes(engine_.heapBytes())` — the exec block plus the control arena) so it shows on the UI card. At rest the engine itself is ~48 B of members + that exec block; the compile path's transient buffers (staging, IR, assembler ≈ 4 KB) live on the cold-path stack and are freed on return — see [docs/backlog/livescripts-analysis-top-down.md § 3.7](backlog/livescripts-analysis-top-down.md) for how this scales as the language grows.

**System variables cost a byte store each, per binding.** They are arena slots the binding refreshes before `run()` — a null check and a byte store apiece, replacing nothing, so the per-tick figure above is unchanged by them. An **effect** writes three (`width`/`height`/`depth`) once per tick; a **modifier** writes six (those plus the `x`/`y`/`z` it is handed) on the mapping-build cold path, not per frame; a **layout** writes none, since it is given no dimensions. `t` adds no arena byte: it is an argument register the host already passes. Not quite free, though — a callee may clobber an argument register under the ABI, so a backend saves it across calls (the arm64 one stacks x3 with the vreg pool; `unit_moonlive_fill` pins that a script reading `t` after a call still sees the host's value). `t` costs nothing at all: it is an argument register the host already passes. The compile path grew (a system-variable table, resolved before locals and controls) but that is cold-path, once per `source` edit.

**Desktop tick across this cycle:** 150 → 122 µs (6666 → 8196 fps), measured by `collect_kpi.py --commit` at each commit. The gain is not from MoonLive — it tracks the two heap-overrun fixes and the register-reuse work landing earlier in the branch. No scenario `contract` was renegotiated on this branch: all 20 scenarios pass inside their existing budgets, which is the assertion surface this page defers to.

---

Expand All @@ -244,7 +248,7 @@ Each parallel LED driver run on real hardware at a 128×128 = 16384-light grid,

| Peripheral | Board | Pins used (8 lanes) | Result | Ceiling / bound |
|---|---|---|---|---|
| **Parlio** | ESP32-P4 (Waveshare P4-NANO) | `20,21,22,23,24,25,26,27` | `Drivers` tick ~30100 µs, fps 30 at 16384 lights (8 lanes, SWAR transpose) | 65535 bytes/lane single-shot = **897 RGB lights/lane**; an over-limit frame fails with a loud status |
| **Parlio** | ESP32-P4 (Waveshare P4-NANO) | `20,21,22,23,24,25,26,27` | `Drivers` tick ~30100 µs, fps 30 at 16384 lights (8 lanes, SWAR transpose) | Parlio's single-shot transfer caps at 65535 bytes TOTAL (not per lane), and a light costs `channels × 24 × slotBytes` — so the ceiling is **898 lights/lane at 8 lanes RGB**, 673 RGBW, and halves to 443/332 at 16 lanes (a 16-bit bus doubles `slotBytes`). Over that, the driver reports `too many lights per pin` and keeps running; lifting the ceiling is the [chunked-DMA work](backlog/backlog-light.md) (tier 1 → ~16-21K). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clarify the over-limit Parlio result.

At 16,384 lights across 8 lanes, the row contains 2,048 lights per lane. This exceeds the stated RGB ceiling of 898 lights per lane. Do not present the 30,100 microsecond value as a successful 16,384-light transmission. Label it as an over-limit refusal/dead-frame measurement, or replace it with a within-limit result.

As per coding guidelines: “Documentation must describe the system as it currently exists.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/performance.md` at line 251, Update the Parlio performance row to
clarify that the ~30,100 µs and 30 fps figure at 16,384 lights represents an
over-limit refusal/dead-frame measurement, not a successful transmission;
alternatively replace it with measurements from a within-limit light count. Keep
the documented 898-lights-per-lane RGB ceiling and current driver behavior
accurate.

Source: Coding guidelines

| **LCD_CAM i80** (MultiPinLedDriver) | ESP32-S3 N16R8 Dev | data `18,5,6,7,8,9,10,11` · WR(clock) `12` · DC `13` | Same encoder, healthy on real i80; encode scales ~6 µs/light (8×512 = 4096 → 23 ms; 8×1024 = 8192 → 50 ms) | **single-DMA init ceiling 8192–12288 lights** (8×1024 inits; 8×1536 → "LCD init failed — check pins/memory"). A data lane on WR/DC only corrupts *that* lane (it carries the bus-control waveform, not pixels), so the driver **warns and keeps running** — a board that wires all lanes but drives fewer strands can legitimately park WR/DC on an unused data pin. WR and DC on the *same* GPIO is rejected up front (the bus needs two distinct control lines). |
| **RMT** | classic ESP32 (LOLIN D32 / WROOM) | `2,4,13,14,16,17,18,19` (pin 2 = a real 24-LED strand) | 8-pin RMT drives **8×256 = 2048 lights** (tick ~12.6 ms), scales to ~8192 before the tick plateaus; all lanes healthy, pin-2 strand verified lit | **silent alloc-fail:** the RMT symbol buffer sizes for the driver's `count` window, so `count=0` on a 16384-grid needs ~1.5 MB, fails on the ~90 KB heap, and `tick()` bails with **no status** (LEDs dark). Bound the driver with the start/count window; a status for this is [backlogged](backlog/backlog-light.md). |
| **I2S i80** | classic ESP32 (ESP32-WROVER) | data `2,4,13,14,18,19,21,22` · WR(clock) `32` · DC `33` (pin 2 = a real strand, verified lit) | The classic ESP32 runs the **same** `MultiPinLedDriver` over the **I2S peripheral in i80 mode** (IDF routes the i80 API to I2S here, to LCD_CAM on the S3/P4 — one driver, chip-picked backend). 8-lane doubling sweep (128×128 grid, 2026-07-13): 64/pin (512) → 4877 µs, 128/pin (1024) → 8575 µs, 256/pin (2048) → 15638 µs. Scales linearly at **~7.6 µs/light** (heavier than the S3's LCD_CAM ~6 µs — the classic I2S clock path). `frameTime` reports the WS2812 wire floor (512 → 243 fps, 2048 → 67 fps). The `MultiPinLed` status reports the live count. **16 lanes work on classic too** (the I2S peripheral does the 16-bit i80 bus, 16×256 = 4096 verified), but the WROVER exposes only ~13 non-strap pins, so 8-lane is the practical set. | **Internal-RAM ceiling: 2048 lights at 8 lanes (4096 at 16).** The classic I2S backend **cannot DMA from PSRAM** (`esp_lcd_i80_alloc_draw_buffer` rejects `MALLOC_CAP_SPIRAM` — "external memory is not supported"), so its frame buffer is internal-DMA-RAM only (`maxBlock` ≈ 76 KB). Swept at 8 lanes on a 128×128 grid (2026-07-13): 64/pin (512) ✅, 128/pin (1024) ✅, **256/pin (2048) ✅ — then 512/pin (4096) and above → `i80 bus init failed — check pins / memory`**, a **clean degrade, not a crash** (uptime kept climbing through every rung). That lands exactly on the parallel-I2S acceptance floor (8×256 = 2048), so the classic chip meets its floor and no more. The opposite of the LCD_CAM row below, which reaches 16384 via PSRAM — the classic chip's DMA simply can't get there. **The render is decoupled from this ceiling:** the same sweep kept rendering the full 128×128 = 16384-light grid at every rung (`Layer` ≈ 511 ms/frame, from PSRAM) while the *output* was capped — so a big grid still renders, it just can't all reach the LEDs. At 16K lights the effect render (511 ms) dwarfs the output (24 ms), so multicore cannot help: the render is the wall on this chip. Two classic-only quirks the driver handles: the I2S i80 tx has an unconditional command phase whose busy-wait hangs to a watchdog reset unless given a real 8-bit command (`lcd_cmd_bits=8` / `kI80Cmd=0`), and the draw buffer + a done-ISR marked `IRAM_ATTR`. |
Expand Down
14 changes: 14 additions & 0 deletions esp32/sdkconfig.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,17 @@ CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y
# that wants it. ASSERTIONS stay ENABLED (IDF's default): they are cheap next to the codegen win and
# they are what turns a silent corruption into a loud abort.
CONFIG_COMPILER_OPTIMIZATION_PERF=y

# Parlio's completion ISR must run with the flash cache DISABLED. A control change writes the
# config to flash, and a DMA completion landing in that window fetched the handler from flash while
# the cache was off — `Guru Meditation Error: Core 0 panic'ed (Cache error)` inside
# pxPortGetCoprocArea, reproduced on the P4 bench by resizing a Parlio-driven layout. Our own done
# callback was already IRAM_ATTR; the IDF driver's handler around it was not.
# (github.com/MoonModules/projectMM/issues/44)
CONFIG_PARLIO_TX_ISR_CACHE_SAFE=y
CONFIG_PARLIO_RX_ISR_CACHE_SAFE=y
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# ...and the driver's own objects with it. The TX option above places the HANDLER in IRAM but,
# unlike its RX twin, does not select PARLIO_OBJ_CACHE_SAFE — so IDF's transaction queues keep
# MALLOC_CAP_DEFAULT and stay PSRAM-eligible, which the same ISR dereferences the moment our
# callback returns. An IRAM handler reading a PSRAM queue is the same fault one frame later.
CONFIG_PARLIO_OBJ_CACHE_SAFE=y
15 changes: 15 additions & 0 deletions moonlive/layouts/lattice.mlv
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// A 3D lattice: stacked layers of a grid, the primitive 3D space of LED strips.
// `z` is an ordinary axis to a layout -- the shipped 2D layouts simply pass 0 for it.
// Three nested loops need more registers than Xtensa has, so this runs on P4/S31/desktop
// but not the S3; two loops (grid.mlv) fit everywhere.
uint8_t cols = 4; // @control 1..32
uint8_t rows = 3; // @control 1..32
uint8_t layers = 5; // @control 1..32

for (z = 0; z < layers; z = z + 1) {
for (y = 0; y < rows; y = y + 1) {
for (x = 0; x < cols; x = x + 1) {
addLight(x, y, z);
}
}
}
Comment on lines +1 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add or verify a 3D coordinate regression test.

This script must emit 60 coordinates for 4 × 3 × 5, with five z-layers and x varying fastest. Use test/unit/light/unit_MoonLiveLayout.cpp, Lines 38-52, to verify the count, coordinates, and ordering for this exact script.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moonlive/layouts/lattice.mlv` around lines 1 - 15, Add or verify a regression
test in the layout tests around the existing lattice coverage in
unit_MoonLiveLayout.cpp. Execute the lattice script with dimensions 4×3×5 and
assert it emits 60 coordinates, with x varying fastest, y next, and five
z-layers in order; preserve the expected addLight coordinate sequence for this
exact script.

4 changes: 3 additions & 1 deletion src/core/NetworkModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,9 @@ class NetworkModule : public MoonModule {
apShutdownPending_ = true;
syncTxPower(); // see setWifiCredentials's syncTxPower comment
std::snprintf(statusBuf_, sizeof(statusBuf_), "AP: %s @ 4.3.2.1", apName); setStatus(statusBuf_, Severity::Status);
std::printf("NetworkModule: AP started: %s\n", apName);
// The address is what a user needs: AP mode exists so they can open the UI and
// enter credentials. Naming the network without it sends them looking for a URL.
std::printf("NetworkModule: AP started: %s → join it and open http://4.3.2.1\n", apName);
} else {
state_ = State::Idle;
std::snprintf(statusBuf_, sizeof(statusBuf_), "No network"); setStatus(statusBuf_, Severity::Error);
Expand Down
Loading
Loading