Skip to content

MoonLive: scripted effects, modifiers and layouts, compiled on the device - #63

Merged
MoonModules merged 11 commits into
mainfrom
next-iteration
Aug 10, 2026
Merged

MoonLive: scripted effects, modifiers and layouts, compiled on the device#63
MoonModules merged 11 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Scripted effects, modifiers and layouts you can write on a running device, and the container rename that landed alongside them.

MoonLive: write a script, see it on the wall

A source control holds text; the engine compiles it to native machine code and calls it each tick through a function pointer. Not an interpreter — a compiler with three backends (Xtensa, RISC-V, arm64) behind one IR, so a script runs at hand-written speed on the render path.

Three bindings ship: a scripted effect (colors), modifier (where one light lands), and layout (where the lights physically are). Eleven example scripts live in moonlive/, each compiled by the test suite so a language change that breaks one is caught.

// moonlive/layouts/ring.mlv — 24 lights in an 11x11 box
uint8_t count = 24;   // @control 3..255
uint8_t radius = 5;   // @control 1..127

for (i = 0; i < count; i = i + 1) {
  addLight(scale(cos(i * turn(count)), radius * 2 + 1),
           scale(sin(i * turn(count)), radius * 2 + 1), 0);
}

A uint8_t x = 4; // @control 0..64 line becomes a real slider, bound to a live arena byte the running code reads — moving it does not recompile.

System variables, not hidden declarations

The values a script is givent, the layer's width/height/depth, and the x/y/z a modifier folds — are a first-class construct: the host registers them, the compiler resolves them before locals and controls, and a script cannot declare a name that shadows one.

They used to be faked. Each binding pasted uint8_t width = 0; declarations on top of the user's script and hid them from the UI by index, which meant an effect could declare its own width and silently disagree with the layer it was drawing into — set 16 on an 8×8 panel and it drew off the edge.

Each binding supplies only what it writes. That is what keeps x and y usable as ordinary loop counters in a layout while they mean "the light being folded" in a modifier, and what turns a layout reading width into a named error rather than a silent zero that places no lights.

LayersEffects

The three top-level containers are now Layouts, Effects, Drivers — L.E.D. The old name sat one character from its own child (Layers holding Layers) and read as a near-twin of Layouts. The tree keeps its shape; the child Layer is untouched.

Breaking

Two, both in MIGRATING.md with the action to take:

  • A saved layout script declaring width/height no longer compiles and its fixture goes dark until edited (the shipped grid.mlv uses cols/rows).
  • The Layers container rename costs the saved light tree and preset role tints; both are one file edit on the device, or a rebuild in the UI.

Bugs this branch fixed

  • Two heap overruns that crashed a layout resize — one in the blend map, one in the folded-LUT scatter. 320 resizes clean afterward.
  • A for typo compiled clean. for (i = 0; j < 3; i = i + 1) emitted code testing i whatever the clauses named, and trailing junk after the step was dropped. A wrong fixture with no diagnostic — the hardest kind to trace.
  • A nested loop reusing its variable name emitted a program that never returned: a watchdog reset from a script anyone could type.
  • Three register miscompiles: RISC-V scratch aliasing a vreg, -1 materialised as 65535 on two backends, and nested loops refused on Xtensa for scratch that a program without inline ops never needed.
  • controlSlot() was bounded by the control count, so every system-variable write hit a null pointer and vanished.
  • The rename missed MoonDeck: run_live_scenario.py could not restore the Effects subtree, so a live scenario would leave a bench board with a destroyed config.

Verification

  • 1226 unit tests, 20 scenarios inside their existing contracts — no budget renegotiated.
  • Desktop tick 150 → 122 µs across the branch (the heap-overrun and register-reuse work, not MoonLive).
  • ASan and TSan clean; the sanitizer jobs also caught a missing #include <string> that every local clang build supplied transitively, which is why a GCC gate (CI's toolchain) now runs on every commit.
  • A Reviewer pass over the full diff plus four CodeRabbit rounds; findings fixed or skipped with a reason in the commit bodies.
  • Bench-verified on S3 and S31 (8×8 panel), desktop preview throughout.

Known limits

  • lines.mlv does not compile on Xtensa — fill plus two loops exceeds the S3 register budget. It runs on S31 and desktop; every other shipped script compiles on all three. The fix is spilling to the stack, which is its own branch.
  • A scripted modifier cannot reshape the grid yet (system variables are read-only), and cannot drop a light out of bounds (a coordinate is a byte). Both backlogged by name.

The three top-level light containers are now Layouts, Effects and Drivers —
L.E.D. The old name sat one character from its own child (Layers holding
Layers) and read as a near-twin of Layouts, which is the pair a newcomer
actually has to tell apart. The tree keeps its shape: Effects -> Layers ->
effects and modifiers.

desktop 140us/7,142fps | esp32 4,164us/240fps | flash unchanged

Light domain
- Layers -> Effects: the class, its header, the registered type name, the card
  image, and Drivers::setLayers -> setEffects.
- Layer, the child, is untouched. The two names differ by one character, so
  every replacement is word-boundary anchored rather than a token sweep — the
  distinction this rename draws is exactly what a blind replace would collapse.
- src/light/layers/ keeps its name: it also holds Layer.h, Buffer.h, BlendMap.h
  and MappingLUT.h, which are all still layer things.

Core
- ControlModule's capture-role index is kEffectsRole, and its static_assert
  re-pinned to spell "Effects". It checked the old name character by character
  ([0]=='L', [1]=='a', [5]=='s'), so it would have failed the build.

Tests
- unit_Layers_container -> unit_Effects_container, scenario_Layers_composition
  -> scenario_Effects_composition, and the scenario key that binds Drivers to
  the container is "effects" on both sides.
- No golden moved, which is the evidence this is a rename and nothing else.

Docs/CI
- MIGRATING.md carries the break. The type name IS the persisted filename and
  the preset capture key, so a device boots with an empty light tree and presets
  stop applying their effects. Per ADR-0013 that is documented, not migrated.
  Both halves were verified on a running desktop: the tree does come up empty,
  and renaming /.config/Layers.json to Effects.json restores it intact — so the
  shortcut in that entry is tested rather than assumed.
- docs/history/ is left alone: it records what was true when written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request renames the light-pipeline container from Layers to Effects. It adds MoonLive scripting for layouts, modifiers, loops, arithmetic, built-ins, backend code generation, diagnostics, and validation. It also adds light-buffer bounds checks and ESP32 panic decoding.

Changes

Effects rename and runtime integration

Layer / File(s) Summary
Effects container and preset contract
src/light/layers/Effects.h, src/core/ControlModule.h, src/light/drivers/Drivers.h
Renames the public container to Effects, changes the preset role to effects, and updates driver binding to setEffects().
Application, UI, scenarios, and tests
src/main.cpp, src/ui/*, test/scenarios/*, test/unit/*
Registers and wires Effects, updates fixture hierarchies, and adapts preset, driver, lifecycle, and composition tests.
Documentation and migration notes
docs/*, README.md
Documents the renamed container, persisted Effects.json data, updated preset keys, and unchanged child Layer names.

MoonLive compiler and execution

Layer / File(s) Summary
Compiler and IR
src/core/moonlive/*
Adds arithmetic expressions, scoped for loops, three-argument calls, labels, branches, system variables, and code-generation diagnostics.
Platform backends
src/platform/desktop/moonlive_*, src/platform/esp32/moonlive_*
Updates register allocation, call conventions, branching, immediate encoding, label lowering, and scratch-register handling.
Script built-ins
src/light/moonlive/MoonLiveBuiltins_light.h, src/light/moonlive/MoonLiveEffect.h
Adds rate-limited print, addLight callback dispatch, source replacement, print-budget resets, and heap reporting.

MoonLive modules and validation

Layer / File(s) Summary
Scripted layouts and modifiers
src/light/moonlive/MoonLiveLayout.h, src/light/moonlive/MoonLiveModifier.h
Adds script-backed coordinate generation and coordinate transformation with controls, recompilation, error fallback, and rebuild signaling.
Example scripts and documentation
moonlive/*, moonlive/README.md, docs/moonmodules/light/*
Adds layout, effect, and modifier scripts with authoring and runtime documentation.
Tests and scenarios
test/unit/light/unit_MoonLive*.cpp, test/scenarios/light/scenario_MoonLive_pipeline.json
Adds parser, compiler, concurrency, memory, layout, modifier, and end-to-end pipeline coverage.

Light safety and diagnostics

Layer / File(s) Summary
Mapped-buffer bounds protection
src/light/layers/BlendMap.h, src/light/layers/Layer.h, test/unit/light/unit_BlendMap.cpp
Bounds LUT reads and writes during resize transitions and drops excess destinations instead of writing past allocated buffers.
MoonDeck diagnostics
moondeck/run/monitor_esp32.py, moondeck/moonlive/disasm.py, moondeck/MoonDeck.md
Adds optional firmware ELF panic decoding and a MoonLive Xtensa emission/disassembly command.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the MoonLive scripted effects, modifiers, and layouts added by the pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch next-iteration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/architecture.md`:
- Line 33: Update both table-of-contents links for the “Effects and Layer”
section to use the correct Markdown anchor `#effects-and-layer` instead of
`#layers-and-layer`, including the reference near line 475.

In `@docs/backlog/system-modules.md`:
- Line 65: Rewrite the sentence around the “Services is to System” comparison so
it describes Services as the existing top-level container for user-added Audio
and IR modules, analogous to Effects containing user-added Layer children.
Remove the implication that Effects contains effect children directly or that
MQTT is a user-added Service; preserve MQTT as code-wired under Network.

In `@docs/gettingstarted.md`:
- Around line 272-279: Propagate the Layers-to-Effects terminology across all
specified documentation: in docs/gettingstarted.md lines 257-261 and 290-299 and
README.md line 23, use the pipeline name “Layouts → Effects → Drivers”; in
docs/moonmodules/core/control.md lines 51 and 53, change preset-role references
from “layer” to “Effects” or “effects”; in docs/moonmodules/light/supporting.md
lines 21-27, add and use the effects anchor, update the test link accordingly,
and retain layers only as an intentional compatibility alias.

In `@docs/moonmodules/core/services.md`:
- Line 7: Update the Services heading in the core services documentation from a
third-level heading to ## Services so it is correctly nested beneath # Core
services and provides the proper parent level for Audio and IR.

In `@docs/usecases/build-your-own-moonmodules.md`:
- Line 322: Update docs/usecases/build-your-own-moonmodules.md at line 322 to
describe the hierarchy as Layouts → Effects → Layer → Effect/Modifier → Drivers,
with Layouts, Effects, and Drivers as top-level sibling containers and Layer
nested under Effects. Update docs/usecases/home-automation.md at line 111 to
direct users to add the Hue driver in the top-level Drivers container, not under
a Layer.
- Line 322: Update the architecture document link description in the “The
architecture doc” bullet to reflect the hierarchy `Layouts → Effects → Layer →
Effect/Modifier → Drivers`, distinguishing the top-level Effects container from
its child Effect/Modifier stage. Keep the existing hot-path rules reference
unchanged.

In `@src/core/Scheduler.cpp`:
- Line 157: Update the nearby explanatory text to say “two Layer instances”
rather than “two Effects,” matching ModuleFactory::create("Layer") and the
duplicate Layer children under the Effects container; leave the uniqueness logic
unchanged.

In `@src/light/effects/EffectBase.h`:
- Line 69: Restore the MoonLight prior-art URL path in the comments by replacing
Effects/VirtualLayer.h with Layers/VirtualLayer.h in
src/light/effects/EffectBase.h lines 69-69, src/light/layers/Buffer.h lines
28-28, and src/light/layers/Layer.h lines 33-33; no other changes are needed.

In `@src/light/layers/Effects.h`:
- Around line 45-48: Update the comments around the role-filtered loop
propagation and the corresponding comment near line 103 to describe child
modules as Layer instances or having ModuleRole::Layer, not Effects. Keep
Effects reserved for the container terminology and align both comments with the
implementation.
- Line 16: Replace the broken MoonLight prior-art URL in the comment near the
Effects container in src/light/layers/Effects.h:16-16 with one verified
canonical reference, or remove the link. Apply the same consistent update to the
Drivers prior-art comment in src/light/drivers/Drivers.h:71-71.

In `@src/main.cpp`:
- Line 158: Add a one-time migration alongside
mm::ModuleFactory::registerType<mm::Effects> that rewrites persisted “Layers.*”
keys to “Effects.*” and “captures”:“Layers” values to “Effects” across settings
and preset payloads before loading them. Ensure existing data remains
applicable, and add coverage or documentation for the complete migration.
- Line 158: Update the documentation link in
mm::ModuleFactory::registerType<mm::Effects> from the stale `#layers` anchor to
light/supporting.md#effects, unless an explicit compatibility anchor is
intentionally retained at the Effects heading.

In `@test/scenario_runner.cpp`:
- Around line 316-323: Keep Effects/Layer terminology consistent across the
affected tests: in test/scenario_runner.cpp lines 316-323, describe child Layer
modules; update the specified descriptions in
test/unit/light/unit_Effects_container.cpp lines 29-32, 90-91, 117-119, and
141-145 to refer to child Layers, enabled Layer buffers, and Drivers compositing
Layers. In test/unit/light/unit_Effects_container.cpp lines 258-265, 280-287,
302-308, 322-329, 349-358, 404-408, 424-433, and 451, rename the local
mm::Effects variable from layers to effects without changing behavior.

In `@test/unit/light/unit_Drivers_container.cpp`:
- Line 11: Update the comment describing Layer::tick() and Effects::tick() to
replace “child Effects” with “child Layer modules,” accurately identifying
Effects as the top-level container and Layer as its child type.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ba437a56-c961-4f03-931a-3d9a52d331d3

📥 Commits

Reviewing files that changed from the base of the PR and between 611ee89 and 9afeec1.

⛔ Files ignored due to path filters (1)
  • docs/assets/light/Effects.png is excluded by !**/*.png
📒 Files selected for processing (63)
  • docs/MIGRATING.md
  • docs/architecture.md
  • docs/backlog/power-functions-analysis-top-down.md
  • docs/backlog/system-modules.md
  • docs/coding-standards.md
  • docs/gettingstarted.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/core/control.md
  • docs/moonmodules/core/services.md
  • docs/moonmodules/light/supporting.md
  • docs/usecases/build-your-own-moonmodules.md
  • docs/usecases/home-automation.md
  • src/core/ControlModule.h
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/core/ModuleFactory.h
  • src/core/MoonModule.h
  • src/core/Scheduler.cpp
  • src/core/Services.h
  • src/light/drivers/Drivers.h
  • src/light/effects/EffectBase.h
  • src/light/layers/Buffer.h
  • src/light/layers/Effects.h
  • src/light/layers/Layer.h
  • src/light/layers/MappingLUT.h
  • src/light/layouts/Layouts.h
  • src/main.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/scenarios/core/scenario_MoonModule_control_change.json
  • test/scenarios/light/scenario_Audio_mutation.json
  • test/scenarios/light/scenario_Effects_composition.json
  • test/scenarios/light/scenario_GridBlacks_blackpixel.json
  • test/scenarios/light/scenario_GridLayout_resize.json
  • test/scenarios/light/scenario_MoonLiveEffect_controls.json
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/scenarios/light/scenario_modifier_chain.json
  • test/scenarios/light/scenario_modifier_swap.json
  • test/scenarios/light/scenario_perf_full.json
  • test/scenarios/light/scenario_perf_light.json
  • test/scenarios/light/scenario_peripheral_grid_sweep.json
  • test/scenarios/light/scenario_peripheral_switch.json
  • test/unit/core/unit_ControlModule.cpp
  • test/unit/core/unit_FilesystemModule_subtree.cpp
  • test/unit/core/unit_MoonModule_lifecycle.cpp
  • test/unit/core/unit_MqttModule.cpp
  • test/unit/core/unit_Scheduler_unique_names.cpp
  • test/unit/core/unit_Services.cpp
  • test/unit/light/golden_frame.h
  • test/unit/light/unit_Canvas.cpp
  • test/unit/light/unit_Drivers_container.cpp
  • test/unit/light/unit_Drivers_rendersplit.cpp
  • test/unit/light/unit_Effects_container.cpp
  • test/unit/light/unit_Layer_live_modifier.cpp
  • test/unit/light/unit_Layer_persistence.cpp
  • test/unit/light/unit_Layer_phase_animation.cpp
  • test/unit/light/unit_Layouts_container.cpp
  • test/unit/light/unit_Layouts_toggle_cycle.cpp
  • test/unit/light/unit_PreviewDriver.cpp
  • test/unit/light/unit_RandomMapModifier.cpp

Comment thread docs/architecture.md Outdated
Comment thread docs/backlog/system-modules.md Outdated
Comment thread docs/gettingstarted.md
Comment on lines +272 to +279
**Effects** — what plays on the lights. Add an **effect** (a moving pattern), stack
several to blend them, and reshape them with **modifiers** (mirror, rotate, and
more). Each effect has its own controls — speed, color mode, and so on — that you
tweak live.

![The Layers module](assets/gettingstarted/02-09-UI-Layers.png)
![The Effects module](assets/gettingstarted/02-09-UI-Layers.png)

> [Layers](moonmodules/light/supporting.md) · [Layer](moonmodules/light/supporting.md)
> [Effects](moonmodules/light/supporting.md) · [Layer](moonmodules/light/supporting.md)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Propagate the LayersEffects rename through public documentation.

The visible rename is applied, but old container names remain in pipeline prose, preset-role prose, and anchors. Use Effects for the top-level container and Layer only for child modules.

  • docs/gettingstarted.md#L272-L279: update the pipeline references at Lines 257-261 and 290-299, and README.md Line 23, to use Layouts → Effects → Drivers.
  • docs/moonmodules/core/control.md#L38-L45: change the preset-role prose at Lines 51 and 53 from layer to Effects or effects.
  • docs/moonmodules/light/supporting.md#L21-L27: add and use the effects anchor, update the test link, and retain layers only as an intentional compatibility alias.
📍 Affects 3 files
  • docs/gettingstarted.md#L272-L279 (this comment)
  • docs/moonmodules/core/control.md#L38-L45
  • docs/moonmodules/light/supporting.md#L21-L27
🤖 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/gettingstarted.md` around lines 272 - 279, Propagate the
Layers-to-Effects terminology across all specified documentation: in
docs/gettingstarted.md lines 257-261 and 290-299 and README.md line 23, use the
pipeline name “Layouts → Effects → Drivers”; in docs/moonmodules/core/control.md
lines 51 and 53, change preset-role references from “layer” to “Effects” or
“effects”; in docs/moonmodules/light/supporting.md lines 21-27, add and use the
effects anchor, update the test link accordingly, and retain layers only as an
intentional compatibility alias.

Comment thread docs/moonmodules/core/services.md Outdated
Comment thread docs/usecases/build-your-own-moonmodules.md Outdated
/// **Prior art:** MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel — same idea, different shape: Drivers (not Layers) does the compositing here (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight).
/// @card Layers.png
class Layers : public MoonModule {
/// **Prior art:** MoonLight's `PhysicalLayer` runs N `VirtualLayer`s and composites their buffers into the display channel — same idea, different shape: Drivers (not Effects) does the compositing here (https://github.com/ewowi/MoonLight/blob/main/src/MoonLight).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the duplicated broken prior-art links.

The same prior-art URL appears in both changed comments and returns 404. Replace both links with one verified canonical MoonLight reference, or remove both links. ()

  • src/light/layers/Effects.h#L16-L16: update the Effects container prior-art link.
  • src/light/drivers/Drivers.h#L71-L71: update the Drivers prior-art link.
📍 Affects 2 files
  • src/light/layers/Effects.h#L16-L16 (this comment)
  • src/light/drivers/Drivers.h#L71-L71
🤖 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 `@src/light/layers/Effects.h` at line 16, Replace the broken MoonLight
prior-art URL in the comment near the Effects container in
src/light/layers/Effects.h:16-16 with one verified canonical reference, or
remove the link. Apply the same consistent update to the Drivers prior-art
comment in src/light/drivers/Drivers.h:71-71.

Source: MCP tools

Comment thread src/light/layers/Effects.h Outdated
Comment thread src/main.cpp Outdated
// Containers
mm::ModuleFactory::registerType<mm::Layouts>("Layouts", "light/supporting.md#layouts");
mm::ModuleFactory::registerType<mm::Layers>("Layers", "light/supporting.md#layers");
mm::ModuleFactory::registerType<mm::Effects>("Effects", "light/supporting.md#layers");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Migrate persisted keys as well as the filename.

Changing the registered type to "Effects" changes more than /.config/Layers.json to Effects.json. Existing persistence and preset payloads also contain old "Layers.*" keys and "captures":"Layers" values, while the updated fixtures require "Effects.*" and "captures":"Effects". Renaming only the file leaves settings unapplied and makes old presets non-applicable.

Add a one-time migration, or document and test a complete rewrite of the old keys and capture values before shipping.

🤖 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 `@src/main.cpp` at line 158, Add a one-time migration alongside
mm::ModuleFactory::registerType<mm::Effects> that rewrites persisted “Layers.*”
keys to “Effects.*” and “captures”:“Layers” values to “Effects” across settings
and preset payloads before loading them. Ensure existing data remains
applicable, and add coverage or documentation for the complete migration.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n '^#+ .*(Effects|Layers)' docs/moonmodules/light/supporting.md

Repository: MoonModules/projectMM

Length of output: 174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "### File context"
sed -n '1,50p' docs/moonmodules/light/supporting.md

echo
echo "### Anchor usage"
rg -n 'supporting\.md#[A-Za-z0-9-]+' .

Repository: MoonModules/projectMM

Length of output: 3263


Fix the Effects documentation anchor.

light/supporting.md has an old #layers anchor before the renamed Effects section and no matching #layers heading. registerType<mm::Effects>("Effects", "light/supporting.md#layers") points to the wrong section. Update it to light/supporting.md#effects, or keep an explicit compatibility anchor at the Effects heading.

🤖 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 `@src/main.cpp` at line 158, Update the documentation link in
mm::ModuleFactory::registerType<mm::Effects> from the stale `#layers` anchor to
light/supporting.md#effects, unless an explicit compatibility anchor is
intentionally retained at the Effects heading.

Comment thread test/scenario_runner.cpp
Comment on lines +316 to +323
if (std::strcmp(type, "Effects") == 0) {
// Wire the container's Layouts (mirrors main.cpp's
// layersContainer->setLayouts). Layers re-propagates this to its
// child Layers at every prepareTree, so a Layer added later picks
// effectsContainer->setLayouts). Effects re-propagates this to its
// child Effects at every prepareTree, so a Layer added later picks
// it up — the self-healing path the device relies on.
if (props.has("layouts")) {
auto* layoutsModule = static_cast<mm::Layouts*>(modules[props["layouts"].str]);
if (layoutsModule) static_cast<mm::Layers*>(mod)->setLayouts(layoutsModule);
if (layoutsModule) static_cast<mm::Effects*>(mod)->setLayouts(layoutsModule);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the Effects/Layer boundary consistent in test comments and names.

The runtime contract uses Effects as the grouping container and Layer as its child role. The changed test text and local names use Effects for child Layers or layers for an mm::Effects object.

  • test/scenario_runner.cpp#L316-L323: change “child Effects” to “child Layer modules”.
  • test/unit/light/unit_Effects_container.cpp#L29-L32: describe composition across child Layers.
  • test/unit/light/unit_Effects_container.cpp#L90-L91: rename “two child Effects” to “two child Layers”.
  • test/unit/light/unit_Effects_container.cpp#L117-L119: refer to enabled Layer buffers.
  • test/unit/light/unit_Effects_container.cpp#L141-L145: describe Drivers compositing Layers from the Effects container.
  • test/unit/light/unit_Effects_container.cpp#L258-L265: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L280-L287: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L302-L308: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L322-L329: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L349-L358: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L404-L408: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L424-L433: rename the local layers variable to effects.
  • test/unit/light/unit_Effects_container.cpp#L451-L451: rename the local layers variable to effects.

As per coding guidelines, use standard, recognizable names and make test descriptions state user-understandable behavior.

📍 Affects 2 files
  • test/scenario_runner.cpp#L316-L323 (this comment)
  • test/unit/light/unit_Effects_container.cpp#L29-L32
  • test/unit/light/unit_Effects_container.cpp#L90-L91
  • test/unit/light/unit_Effects_container.cpp#L117-L119
  • test/unit/light/unit_Effects_container.cpp#L141-L145
  • test/unit/light/unit_Effects_container.cpp#L258-L265
  • test/unit/light/unit_Effects_container.cpp#L280-L287
  • test/unit/light/unit_Effects_container.cpp#L302-L308
  • test/unit/light/unit_Effects_container.cpp#L322-L329
  • test/unit/light/unit_Effects_container.cpp#L349-L358
  • test/unit/light/unit_Effects_container.cpp#L404-L408
  • test/unit/light/unit_Effects_container.cpp#L424-L433
  • test/unit/light/unit_Effects_container.cpp#L451-L451
🤖 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 `@test/scenario_runner.cpp` around lines 316 - 323, Keep Effects/Layer
terminology consistent across the affected tests: in test/scenario_runner.cpp
lines 316-323, describe child Layer modules; update the specified descriptions
in test/unit/light/unit_Effects_container.cpp lines 29-32, 90-91, 117-119, and
141-145 to refer to child Layers, enabled Layer buffers, and Drivers compositing
Layers. In test/unit/light/unit_Effects_container.cpp lines 258-265, 280-287,
302-308, 322-329, 349-358, 404-408, 424-433, and 451, rename the local
mm::Effects variable from layers to effects without changing behavior.

Source: Coding guidelines

Comment thread test/unit/light/unit_Drivers_container.cpp Outdated
Scripts can now compute — `+`, `-`, `*` with parentheses and the usual
precedence — and print a value to the serial log. On top of that, a modifier can
be written as a script: the coordinate transform that decides where each light
sits in the pattern, edited on a running device instead of compiled in and
reflashed.

desktop 133us/7,518fps | esp32 4,164us/240fps | flash: desktop 1,076KB (+2KB),
esp32s3-n16r8 1,704KB (+4KB)

Core
- MoonLive gains arithmetic: precedence climbing over the existing Const/Add/Mul
  IR, so all three backends lower it unchanged. Subtraction is emitted as
  a + (b * -1): no supported ISA has a subtract, and Xtensa's add-immediate
  masks its operand to four bits, so negating the immediate there would silently
  produce a different constant.
- Division and modulo are deliberately absent: both need a two-argument host
  call and Call is unary today. A script using them gets "unexpected character",
  which is the honest answer rather than a misleading parse error.

Light domain
- print(v) logs a value and returns it, so it wraps any sub-expression without
  changing the result. It is the only view inside a running script — one that
  compiles cleanly and renders wrong gives no other clue, which is exactly the
  case that cost this session its longest debugging detour. Capped at a short
  burst: the script runs once per light, and an uncapped print on a 16k wall
  would be 16k blocking serial writes per rebuild.
- setXYZ(index, x, y, z) writes a POSITION through the same element store
  setRGB writes a colour through — three values at index * stride. The engine
  needed no change to host a second binding, which is the evidence its
  domain-neutrality is real rather than asserted.
- MoonLiveModifier: scripts read x/y/z and width/height/depth as injected
  controls. A script needs the EXTENT to write a mirror at all; against a fixed
  255 every light of a 16-wide grid lands out of bounds, the Layer drops it, and
  the fixture goes black with no error anywhere.

The bug that made it render nothing, since it is the interesting one: prepare()
asked the Layer for a mapping rebuild, the Layer's rebuild IS applyState(), and
applyState() calls prepare(). The two called each other forever, the mapping was
permanently mid-rebuild, and nothing ever drew. Every part was correct in
isolation — the script compiled, the fold returned the right coordinates, every
light was accepted — so only a probe inside the fold showed it: 782,296 rebuilds
in six seconds where there should have been one. A rebuild is now requested only
when the compiled source actually changed.

Tests
- The rebuild cycle: re-preparing an unchanged script must not ask again.
- The black-screen case: the default script stays inside the grid it is given,
  at every coordinate.
- print() transparency, arithmetic precedence, and the per-light cost (16,384
  transforms in 31us, so the native call per light is free).

Docs/CI
- MoonLiveModifier.md, including the two limits worth knowing: a coordinate is a
  byte, and a script cannot resize the logical box.
- Rename fallout from the external review: four upstream MoonLight URLs my sweep
  had rewritten to a path that does not exist, two broken doc anchors, the
  registerType help link, the README pipeline name, a heading level, and four
  comments saying Effects where they meant Layer.
- The preset capture role is "effects" rather than "layer": it names the
  container a preset captures, not a module inside it. A breaking change,
  documented in MIGRATING.md.
- system-modules.md no longer justifies a core-domain split by naming
  light-domain containers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/MIGRATING.md`:
- Around line 33-38: Update the migration documentation in docs/MIGRATING.md
(lines 33-38) to represent preset captures as the single string value the loader
reads, using "Layers" only for the file-edit migration path; update the
corresponding guidance in docs/moonmodules/core/control.md (lines 35-43)
consistently. Do not document captures as an array.

In `@docs/moonmodules/light/MoonLiveModifier.md`:
- Around line 41-47: Update the Limits section of MoonLiveModifier documentation
to state that values written by setXYZ are stored as bytes and therefore
truncate or wrap to the 0–255 range, including transformed outputs beyond that
range. Place this behavior alongside the existing input-coordinate limit
description and keep the documented input behavior unchanged.

In `@README.md`:
- Line 23: Update the pipeline description near the `layouts → layers with
effects + modifiers → drivers` text to name the top-level stage `Effects` and
describe its child layers separately, matching the terminology used in the
pluggable pipeline description while preserving the existing pipeline order.

In `@src/light/moonlive/MoonLiveBuiltins_light.h`:
- Around line 34-47: The print burst cap is process-lifetime rather than per-run
because mm_light_print uses a never-reset function-local static counter. In
src/light/moonlive/MoonLiveBuiltins_light.h:34-47, either reset the counter at
the start of each script run sequence or revise the surrounding comment to
accurately describe per-boot behavior; in
docs/moonmodules/light/MoonLiveModifier.md:39, update the documentation to match
the shipped behavior, explicitly replacing the per-run burst claim.

In `@src/light/moonlive/MoonLiveModifier.h`:
- Around line 138-151: Remove the unused public test accessors sourceForTest()
and boxForTest() from MoonLiveModifier; do not alter the release() or
setSource() behavior.
- Around line 165-175: Replace the redundant compiled_ buffer in the MoonLive
modifier state with a compact checksum of full_. Update the rebuild-detection
logic to compute and compare the checksum, refreshing it whenever the mapping is
rebuilt, while preserving the existing source-change behavior and using the
repository’s existing core/crc helper.

In `@test/unit/light/unit_MoonLiveModifier.cpp`:
- Around line 167-209: Remove the first duplicate rebuild-signal test case and
its preceding explanatory comment. Keep the second test case, “editing a script
asks the layer to rebuild its mapping,” including its rebuild-consumption checks
and final modifyLogical assertion.
- Around line 240-265: Update the test case “a script that computes a position
outside the grid does not black out the fixture” so it observes the modifier’s
mapped output rather than the prefilled layer buffer. Remove the direct buffer
fill/count assertion and inspect the mapping fold’s counts or destinations after
applying the layer, or render through a driver and count emitted lights; ensure
the assertion fails when no light is mapped.
- Around line 147-165: Update the test case “transforming a wall's worth of
lights stays within a rebuild's budget” to remove the timing-dependent CHECK on
elapsed microseconds and the associated wall-clock assertion. Preserve the loop
and MESSAGE measurement for reporting, and replace the timing-based validation
with a deterministic assertion that directly verifies the script is not compiled
once per modifyLogical call, using the existing observable API or state.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6f4521ac-651a-4bef-9cee-475ff9d31d5e

📥 Commits

Reviewing files that changed from the base of the PR and between 9afeec1 and f99ee5e.

📒 Files selected for processing (25)
  • README.md
  • docs/MIGRATING.md
  • docs/architecture.md
  • docs/backlog/system-modules.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/core/control.md
  • docs/moonmodules/core/services.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • docs/usecases/build-your-own-moonmodules.md
  • docs/usecases/home-automation.md
  • src/core/ControlModule.h
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/light/drivers/Drivers.h
  • src/light/layers/Effects.h
  • src/light/layers/Layer.h
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveModifier.h
  • src/main.cpp
  • src/ui/app.js
  • test/CMakeLists.txt
  • test/unit/core/unit_ControlModule.cpp
  • test/unit/light/unit_Drivers_container.cpp
  • test/unit/light/unit_Effects_container.cpp
  • test/unit/light/unit_MoonLiveModifier.cpp

Comment thread docs/MIGRATING.md Outdated
Comment on lines +33 to +38
| The saved light tree | The device looks for `/.config/Effects.json` and the old file is `Layers.json`, so the light tree boots empty | Re-add your Layer, effect and modifiers, then let it save |
| Presets that capture the look | A preset file records `"captures": ["Layers"]`, a key no module now answers to | Re-save each preset once the tree is rebuilt |

A preset also records the ROLE it covers, and that role is now named after the container rather than after a module inside it: `"layer"` becomes `"effects"`. A preset carrying the old role still loads, but shows no tint or emoji on its pad until it is re-saved — the UI has no `layer` role to colour it by.

Renaming the file on the device works if you would rather not rebuild by hand: `Layers.json` → `Effects.json`, and `"Layers"` → `"Effects"` inside each `/.config/presets/*.json`. Nothing else in either file changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect one persisted captures shape across implementation and documentation.
rg -n -C 8 \
  'saveSubtreeTo|applySubtree|captures' \
  --glob '*.{cpp,h,hpp,md,json}'

Repository: MoonModules/projectMM

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching target names:"
git ls-files | rg '(^docs/MIGRATING\.md$|^docs/moonmodules/core/control\.md$|FilesystemModule\.md$|\.cpp$|\.h$|\.hpp$|\.json$)' | sed -n '1,200p'

echo
echo "Search presets/captures/persistence names without filtering by extension first:"
rg -n -C 5 'captures|saveSubtreeTo|applySubtree|Layers|Effects' . --glob '!**/.git/**' | sed -n '1,300p'

Repository: MoonModules/projectMM

Length of output: 36383


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Target docs lines:"
sed -n '1,80p' docs/MIGRATING.md
echo
sed -n '1,70p' docs/moonmodules/core/control.md

echo
echo "Source files containing LightPresetsModule/preset/captures/saveSubtreeTo/applySubtree:"
rg -n -C 6 'LightPresetsModule|LightPresets|preset|presets|captures|saveSubtreeTo|applySubtree|Layers|Effects' \
  src moondeck library.json espidf component.mk CMakeLists.txt -g '!**/build/**' -g '!**/.git/**' | sed -n '1,400p'

echo
echo "List LightPresetsModule candidate files:"
git ls-files | rg '(^src/light/drivers/LightPresetsModule\.(h|cpp)$|.*ControlModule.*|.*FilesystemModule.*)'

Repository: MoonModules/projectMM

Length of output: 49643


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 'class ControlModule|struct P|presets|captures|preset|apply\(|save\(' \
  src/core/Control*.h src/core/Control*.cpp src/core/ControlModule.cpp || true

python3 - <<'PY'
import subprocess, re
files = subprocess.check_output(['git','ls-files'], text=True).splitlines()
for f in files:
    if f.endswith(('.h','.hpp','.cpp','.md')):
        text = open(f, encoding='utf-8', errors='surrogateescape').read()
        for name in ['ControlModule','presetsRevision','presetCount','presetName','activePreset','applyPreset','capture','captures']:
            if name in text:
                lines = text.splitlines()
                for i,l in enumerate(lines,1):
                    if name in l:
                        print(f"{f}:{i}: {l.strip()}")
PY

Repository: MoonModules/projectMM

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "ControlModule implementation excerpt:"
sed -n '1,240p' src/core/ControlModule.cpp
echo
sed -n '240,420p' src/core/ControlModule.cpp

echo
echo "Read-only semantic probe: parseString ignores array value."
python3 - <<'PY'
# Mirror the minimal parseString path from JsonUtil/ControlModule behavior without running repo code:
# parseString(head, "captures", p.captures) expects "captures":"Value"; when captures is ["Layers"]
# the value token is [, so the string parse does not produce "Layers".
import json
for value in ["Effects", '["Layers"]']:
    data = {"captures": value}
    print(value, "== parseString target ==")
    try:
        print(json.loads(data))
    except Exception as e:
        print("exception", *type(e).__name__, str(e))
PY

Repository: MoonModules/projectMM

Length of output: 267


Keep the migration capture key as a string.

ControlModule persists preset captures as a single subtree string (for example "Effects"); "captures": ["Layers"] does not match the single-value capture the loader reads. The migration instruction should use "Layers" in the migrate-to example only if a file edit is the chosen path.

📍 Affects 2 files
  • docs/MIGRATING.md#L33-L38 (this comment)
  • docs/moonmodules/core/control.md#L35-L43
🤖 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/MIGRATING.md` around lines 33 - 38, Update the migration documentation
in docs/MIGRATING.md (lines 33-38) to represent preset captures as the single
string value the loader reads, using "Layers" only for the file-edit migration
path; update the corresponding guidance in docs/moonmodules/core/control.md
(lines 35-43) consistently. Do not document captures as an array.

Source: Coding guidelines

Comment thread docs/moonmodules/light/MoonLiveModifier.md Outdated
Comment thread README.md
🧊 **Native 3D from the ground up**: 2D and 1D are just the cases where a dimension is size 1. Effects never pick a mode.

🎛️ **Pluggable pipeline**: Layouts → Layers (effects + modifiers) → Drivers. Build it visually in the browser, and every change applies live (settings also persist to flash across power cycles).
🎛️ **Pluggable pipeline**: Layouts → Effects (layers of effects + modifiers) → Drivers. Build it visually in the browser, and every change applies live (settings also persist to flash across power cycles).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the README pipeline terminology consistent.

Line 23 now uses Effects, but Line 106 still describes layouts → layers with effects + modifiers → drivers. The README presents two names for the same top-level stage. Update Line 106 to use Effects and distinguish its Layer children.

🤖 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 `@README.md` at line 23, Update the pipeline description near the `layouts →
layers with effects + modifiers → drivers` text to name the top-level stage
`Effects` and describe its child layers separately, matching the terminology
used in the pluggable pipeline description while preserving the existing
pipeline order.

Source: Coding guidelines

Comment thread src/light/moonlive/MoonLiveBuiltins_light.h
Comment thread src/light/moonlive/MoonLiveModifier.h Outdated
Comment thread src/light/moonlive/MoonLiveModifier.h Outdated
Comment thread test/unit/light/unit_MoonLiveModifier.cpp Outdated
Comment thread test/unit/light/unit_MoonLiveModifier.cpp Outdated
Comment thread test/unit/light/unit_MoonLiveModifier.cpp Outdated
A script's subtraction was correct only modulo 256 on two of the three targets.
`a - b` compiles to `a + (b * -1)`, and both assemblers built that -1 through a
16-bit immediate, so the register held 65535. A stored colour byte came out
right either way, which is why every test passed; anything reading the full
value did not. An index computed by subtraction became ~65k, the element store's
bounds guard rejected it, and the light silently never lit.

desktop 133us/7,518fps | esp32 4,164us/240fps | flash unchanged

Platform
- arm64 emits `movn wD, #~imm16` for a negative constant; movz can only build a
  zero-extended one.
- Xtensa uses the wide `movi` 12-bit SIGNED field (-2048..2047), the only
  encoding here that holds a negative. Verified byte for byte: -1 emits
  0x32 0xaf 0xff, which the CPU reads back as -1.
- RISC-V already sign-extended and is unchanged.

Confirmed on the bench: the scripted mirror renders on the S3, which it could
not have done with a wrapped -1 — every light would have folded outside the grid
and the panel would be black.

Light domain
- A disabled-then-re-enabled scripted modifier was permanently inert: release()
  frees the compiled program but kept the source it was compiled from, so the
  next prepare saw "unchanged" and never asked for the rebuild that would apply
  it. release() now forgets it, which is the state a first compile starts from.
- print()'s burst resets on every compile. It was a process-lifetime counter, so
  the tool that exists to explain a misbehaving script went silent after one
  burst — including in the middle of diagnosing this commit's own bug.

Tests
- Subtraction is pinned THROUGH print(), which returns the full 32-bit value.
  A byte comparison cannot see this class of bug at all: mod-256 congruence
  makes the stored byte correct whether or not the arithmetic is.
- The wall-budget test asserts the compiled program is unchanged across 16,384
  folds rather than a wall-clock bound, so it states the property (no per-call
  compile) instead of how busy the machine is.
- The blackout test observes the folded COORDINATES. It filled the buffer
  through draw::fill and counted lit bytes, which writes every byte itself and
  so could not fail whatever the mapping did.

Docs
- MIGRATING: a preset records `"captures": "Layers"`, a single string; it was
  written as an array, which would send a file edit hunting for brackets that
  are not there.
- MoonLiveModifier: a failed compile falls back to passing coordinates through
  (the doc claimed the previous mapping survives), and a position a script
  computes past 255 keeps its low byte.

Reviews
- Reviewer (Fable): H1 (this commit's subject), M1, M2 and L1-L5 addressed.
- CodeRabbit: 5 fixed, 4 already done, 1 skipped — replacing the compared source
  with a checksum trades a collision-shaped false negative for nothing on a cold
  path that compares once per prepare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/moonmodules/light/MoonLiveModifier.md (1)

49-55: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document script-declared controls.

The table lists only source, but MoonLiveModifier::defineControls() also exposes each user-declared MoonLive control after compilation. Document how a script declares these controls and state that changing one rebuilds the mapping.

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/moonmodules/light/MoonLiveModifier.md` around lines 49 - 55, Update the
Controls section of MoonLiveModifier documentation to include the user-declared
controls exposed by MoonLiveModifier::defineControls(). Describe how scripts
declare these controls and state that changing a declared control rebuilds the
mapping, alongside the existing source control behavior.

Source: Coding guidelines

test/unit/light/unit_MoonLiveModifier.cpp (1)

151-174: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Use an observable compile count.

dynamicBytes() is engine_.codeCap(). Recompiling the same source can produce the same executable capacity, so Line 174 still passes if modifyLogical() compiles on every call. Add a compile-count test seam or an executable-allocation counter and assert that the count remains one across the fold.

🤖 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 `@test/unit/light/unit_MoonLiveModifier.cpp` around lines 151 - 174, Replace
the dynamicBytes() comparison in the MoonLiveModifier test with an observable
compilation count, exposed through an appropriate test seam or
executable-allocation counter. Capture the count after m.prepare(), assert it
reflects one compilation, and verify it remains unchanged after all
modifyLogical() calls; do not rely on executable capacity as the recompilation
indicator.

Source: Path instructions

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/light/moonlive/MoonLiveBuiltins_light.h`:
- Around line 47-52: Update mm_light_print and its printBudget diagnostic flow
so effect ticks perform only bounded, nonallocating queue writes and remain
noexcept, removing both std::printf calls from the tick path. Store the
formatted diagnostic records in a preallocated queue, then drain that queue from
a housekeeping path through the existing platform output seam, preserving the
print budget and burst-spent message behavior.

In `@src/platform/desktop/moonlive_asm_host.cpp`:
- Around line 48-59: Complete signed-immediate lowering in
HostAssembler::mulImm() so negative multipliers, including -1, are materialized
with signed semantics; update src/platform/desktop/moonlive_asm_host.cpp lines
48-59, while HostAssembler::movImm() is only context. In
src/platform/esp32/moonlive_asm_xtensa.cpp lines 58-71, prevent values below
-2048 from falling through to unsigned construction by materializing the full
signed value or rejecting the literal during compilation. Add backend tests
covering subtraction and a literal below -2048.

In `@test/unit/light/unit_MoonLiveModifier.cpp`:
- Around line 224-259: Update the test case name to distinguish the intentional
out-of-grid mapping from the default-script assertion, then add CHECK(inside ==
0) after the loop in “a script that computes a position outside the grid leaves
lights mapped” to assert every transformed coordinate is unmapped while
preserving the existing defInside check.
- Around line 290-308: Update the subtraction regression test around “a
subtraction produces the whole value, not just its low byte” to run against a
multi-light buffer and use the subtraction result as a bounds-checked element
index. Assert that the expected destination element is written, ensuring values
such as 99 and an incorrectly widened result cannot both pass through setXYZ’s
byte truncation.

---

Outside diff comments:
In `@docs/moonmodules/light/MoonLiveModifier.md`:
- Around line 49-55: Update the Controls section of MoonLiveModifier
documentation to include the user-declared controls exposed by
MoonLiveModifier::defineControls(). Describe how scripts declare these controls
and state that changing a declared control rebuilds the mapping, alongside the
existing source control behavior.

In `@test/unit/light/unit_MoonLiveModifier.cpp`:
- Around line 151-174: Replace the dynamicBytes() comparison in the
MoonLiveModifier test with an observable compilation count, exposed through an
appropriate test seam or executable-allocation counter. Capture the count after
m.prepare(), assert it reflects one compilation, and verify it remains unchanged
after all modifyLogical() calls; do not rely on executable capacity as the
recompilation indicator.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e555b367-e5cf-45b3-b0b5-f02211e7051e

📥 Commits

Reviewing files that changed from the base of the PR and between f99ee5e and 6b56dce.

📒 Files selected for processing (11)
  • docs/MIGRATING.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveEffect.h
  • src/light/moonlive/MoonLiveModifier.h
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/esp32/moonlive_asm_xtensa.cpp
  • test/unit/light/unit_Effects_container.cpp
  • test/unit/light/unit_MoonLiveModifier.cpp

Comment on lines +47 to +52
extern "C" inline uint32_t mm_light_print(uint32_t v) {
uint32_t& left = printBudget();
if (left > 0) {
std::printf("[script] %u\n", static_cast<unsigned>(v));
if (--left == 0) std::printf("[script] (burst spent; edit the script for a fresh one)\n");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Remove blocking output from the effect tick path.

MoonLiveEffect::tick() can invoke mm_light_print() through engine_.run(). Line 50 calls std::printf, which can block the render tick. Store bounded diagnostic records in a preallocated queue and drain them from a housekeeping path through a platform seam.

As per path instructions: “render/tick code is nonblocking and noexcept; avoid allocation, blocking I/O, delays, or network calls there.”

🤖 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 `@src/light/moonlive/MoonLiveBuiltins_light.h` around lines 47 - 52, Update
mm_light_print and its printBudget diagnostic flow so effect ticks perform only
bounded, nonallocating queue writes and remain noexcept, removing both
std::printf calls from the tick path. Store the formatted diagnostic records in
a preallocated queue, then drain that queue from a housekeeping path through the
existing platform output seam, preserving the print budget and burst-spent
message behavior.

Source: Path instructions

Comment thread src/platform/desktop/moonlive_asm_host.cpp
Comment thread test/unit/light/unit_MoonLiveModifier.cpp
Comment on lines +290 to +308
// Subtraction is emitted as `a + (b * -1)`, and -1 has to survive into the register. The assemblers
// materialise a constant with a 16-bit immediate, so a naive -1 becomes 65535 and every subtraction
// is right only MODULO 256 — invisible in a stored byte, and wrong everywhere the full value is
// used: a bounds-guarded index silently drops the light, and a value handed to a host call is
// nonsense. Byte-comparison tests cannot see this, so it is checked through print(), which returns
// the full 32-bit value.
TEST_CASE("a subtraction produces the whole value, not just its low byte") {
// `a - b` compiles to `a + (b * -1)`, so -1 has to reach the register intact. The assemblers
// build a constant from a 16-bit immediate, and a naive -1 lands as 65535 — which leaves every
// subtraction correct only MODULO 256. A stored colour byte cannot show that (the low byte is
// right either way), so this checks the value THROUGH print(), which returns the full 32 bits
// and is therefore the only observer that can fail.
//
// The consequences the byte hides: an index computed by subtraction becomes ~65k, the element
// store's bounds guard rejects it, and the light silently never lights; a subtraction handed to
// a host call (random16, print) gets a wrong argument.
CHECK(transform("setXYZ(0, print(width - 1 - x), y, z);", 0, 0, 0, 16, 16, 1).x == 15);
CHECK(transform("setXYZ(0, print(100 - 1), y, z);", 0, 0, 0, 255, 255, 1).x == 99);
CHECK(transform("setXYZ(0, print(5 - 5), y, z);", 0, 0, 0, 255, 255, 1).x == 0);

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

Test subtraction through an untruncated observer.

Each print() result is immediately stored through setXYZ(), which keeps only its low byte. Both 99 and 65635 store as 99, so these checks pass when -1 is materialized as 65535. Execute the script against a multi-light buffer and use the subtraction result as a bounds-checked element index, then assert that the expected destination was written.

🤖 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 `@test/unit/light/unit_MoonLiveModifier.cpp` around lines 290 - 308, Update the
subtraction regression test around “a subtraction produces the whole value, not
just its low byte” to run against a multi-light buffer and use the subtraction
result as a bounds-checked element index. Assert that the expected destination
element is written, ensuring values such as 99 and an incorrectly widened result
cannot both pass through setXYZ’s byte truncation.

A layout can now be written as text on a running device instead of compiled in as a C++ class.
`for (i = 0; i < width; i = i + 1) { addLight(i, 0, 0); }` describes a strand; the nested form
describes a panel. The MoonLive language grows the loops, arithmetic and print() this needs, and a
`moonlive/` folder ships the scripts as editable files.

Performance: desktop tick 164 -> 149us (16384 lights); flash S3 +4.9KB, P4 +97KB, desktop +18KB.
The stored KPI read 133us, so this looks like a regression in the metrics diff; it is not. That
number came from a quieter machine. Re-measuring the pre-branch tree on THIS machine gives 164us,
against 149us here.

**Core**
- MoonLive language: `for` loops (bottom-tested, entry guard + back edge), script locals, and
  precedence-climbing arithmetic. Subtraction emits `a + (b * -1)`.
- IR gains `Mov`, `Label`, `BranchGe`, `BranchNe`; 16 labels.
- `HostCallFn` widened to three arguments so a built-in can take a coordinate triple.

**Light domain**
- `MoonLiveLayout`: a scripted layout that **allocates nothing**. `lightCount()` runs the script with
  a counting sink, `forEachCoord` runs it into the caller's, so the two answers come from the same
  code and cannot drift. Staging coordinates would cost 48KB on a 16k-light fixture.
- `MoonLiveModifier`: a scripted coordinate fold, with the x/y/z preamble its script reads.
- Built-ins: `addLight`, `setXYZ`, `print`.

**Platform**
- Xtensa `Mov` lowers to a real register move. It was add-immediate-zero, which the ISA cannot
  encode (that slot means -1), so a loop counter started at -1, the unsigned guard saw 0xffffffff,
  and every loop exited immediately. Found by disassembling, after five wrong hypotheses.
- Negative immediates: Xtensa via 12-bit signed `movi`, arm64 via `movn`. `Const(-1)` was becoming
  65535 on both, making subtraction correct only modulo 256. Out-of-range now fails the compile
  rather than silently taking the unsigned path.
- RISC-V vreg map widened 12 -> 18, call frame 64 -> 112 bytes.

**Scripts/MoonDeck**
- `moonlive/` with layouts, effects and modifiers as `.mlv` files; each module ships one as default.
- `moondeck/moonlive/disasm.py` disassembles what a script emits, on the host, without a device.

**Tests**
- `unit_MoonLiveLayout`, `unit_MoonLiveScripts` (compiles every file in `moonlive/`),
  `scenario_MoonLive_pipeline` (layout + effect + modifier all scripted, in one wired pipeline).
- Pinned: a scripted control keeps its live value across a source edit. The arena matches slots by
  OFFSET, so a different script whose first control is also `width` inherits the value rather than
  its own initialiser. Deliberate (the slider you moved survives an edit) and easy to be surprised
  by, so it is a test rather than a comment.

**Docs**
- `print` documented in one place, `moonlive/README.md`, with the bindings pointing at it: it is for
  debugging, it blocks, and it comes back out again before a script ships.
- Backlog: drain `print()` through a queue so nothing of it remains on the tick.

**Reviews**
- 🐇 `mulImm` negative handling: skipped, never called by the compiler.
- 🐇 Xtensa/arm64 `movImm` below the encodable range: fixed, both now fail the compile.
- 🐇 Subtraction test proved nothing (99 and 65635 are the same byte): fixed, asserts on a loop
  bound instead, which is not truncated.
- 🐇 Out-of-grid modifier test passed on half the script: fixed with `CHECK(inside == 0)`.
- 🐇 `MoonLiveModifier.md` missing script-declared controls: fixed.
- 🐇 `print()` blocks a MM_NONBLOCKING tick: valid. Bounded by the burst cap and documented; the
  queue-drain redesign is backlogged, not built here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/moonmodules/light/MoonLiveLayout.md`:
- Around line 52-62: Update the two-pass explanation near the claim that
identical arithmetic prevents drift so the guarantee explicitly applies only to
deterministic scripts. Acknowledge that supported stateful or random built-ins
such as random16 can produce different count and placement results, consistent
with the existing Limits warning.

In `@moondeck/moonlive/disasm.py`:
- Around line 58-69: Resolve Ruff E741 in the disassembly parsing by renaming
the ambiguous l variables to line in both comprehensions, including the hexbytes
construction and the objdump line search, without changing their behavior.
- Around line 42-46: Update the emitter build flow around subprocess.run to use
the project’s MoonDeck build entry point instead of invoking c++ directly.
Ensure the temporary emitter is built through the MoonDeck script or command so
project-configured compiler flags and dependencies are applied, while preserving
the existing emitter output behavior.

In `@moondeck/moonlive/emit_xtensa.cpp`:
- Around line 5-15: Remove the direct Xtensa implementation includes and
__XTENSA__ definition from the translation unit containing lowerToBytes. Expose
the required backend functionality through the platform-layer library/API under
src/platform/**, then have the MoonDeck utility depend on and link against that
API without compiling platform implementation files directly; preserve a single
backend definition.

In `@moonlive/effects/gradient.mlv`:
- Around line 3-5: Update InlineOp::StoreElem in moonlive_lower_riscv.cpp to
copy the index register into scratch before multiplying it for the byte offset,
preserving the original loop variable register for subsequent uses and the
for-step. Keep MoonLiveCompiler.cpp’s loop-variable register behavior and the
gradient example unchanged.

In `@moonlive/layouts/two-rows.mlv`:
- Around line 4-6: Update the light-emission loop in the two-row layout to
output all y=0 lights with ascending x coordinates first, then output all y=1
lights with descending x coordinates. Replace the current paired addLight calls
so the return row forms a reverse traversal.

In `@moonlive/README.md`:
- Around line 26-29: Remove the synchronous print path from render-executed
MoonLive scripts: update mm_light_print and its supporting code so rendering
only enqueues into a bounded, nonallocating noexcept queue drained outside the
render path, or disable the diagnostic in render builds. Ensure no std::printf
call remains reachable from render/tick code, and preserve the existing
burst-limit behavior where applicable.

In `@src/core/moonlive/MoonLiveCompiler.cpp`:
- Around line 417-433: Validate the condition and step identifiers in the
for-loop parsing flow against the active loop variable varName, rejecting
mismatches instead of silently using the loop local. Update the locals lookup
used by this flow to search from localCount - 1 down to zero so nested loop
variables shadow outer locals correctly; alternatively reject duplicate local
names.
- Around line 467-470: Update the loop-generation logic around the BranchGe and
BranchNe emissions to reject or safely bound steps that cannot advance toward
the limit, including a zero step such as i = i. Prefer validating that the
supported step is provably positive before emitting the back edge; otherwise
emit a bounded iteration guard so counter cannot loop indefinitely.

In `@src/platform/desktop/moonlive_asm_host.cpp`:
- Around line 112-120: Update the host-call frame and argument save/restore
logic in the surrounding assembly emission to preserve x3 alongside x0-x2, since
R3 carries elapsed time and may be clobbered by built-ins. Increase the stack
allocation and matching deallocation by a 16-byte-aligned amount, save x3 before
the call, and restore it afterward while keeping existing argument setup
unchanged.

In `@src/platform/esp32/moonlive_asm_riscv.cpp`:
- Around line 16-20: Remove the kScratchFn-to-x16 overlap by reserving a
register that is not included in kRvReg, and update call() to use that register
for the host-result stash. Ensure the selected scratch register does not
conflict with address calculation via kScratchAddr, and keep the result copy to
d outside the registers restored by call().

In `@src/platform/esp32/moonlive_lower_riscv.cpp`:
- Around line 64-69: Update the RISC-V call path used by the IrOp::Call case in
moonlive_lower_riscv.cpp and RiscvAssembler::call so the host-call return value
is saved in an unused stack slot before restoring saved[]/kScratchFn, then
reloaded into the destination afterward. Preserve existing argument passing and
restoration behavior while ensuring random16() and print() receive the actual a0
result.

In `@test/scenarios/light/scenario_MoonLive_pipeline.json`:
- Around line 13-52: Update the fixture to add an Effects module configured with
the existing Layouts module, parent Layer under Effects, and replace Drivers’
direct layer property with the effects reference. Preserve the existing module
IDs and driver setup while routing the scenario through Effects::setLayouts and
Drivers::setEffects.

In `@test/unit/light/unit_MoonLiveScripts.cpp`:
- Around line 70-73: Gate the native-code success assertions in the MoonLive
compile tests around the `engine.compile` checks, including the additional
affected cases, using the project’s supported-host/backend availability
condition. Preserve parser or script-validity coverage on unsupported hosts,
while only requiring `ok` and reporting compile failures when the native backend
is available.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: eb712dde-aa4e-452c-a474-21717ddbee0c

📥 Commits

Reviewing files that changed from the base of the PR and between 6b56dce and aa2e75f.

📒 Files selected for processing (40)
  • docs/backlog/backlog-light.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • moondeck/moonlive/disasm.py
  • moondeck/moonlive/emit_xtensa.cpp
  • moonlive/README.md
  • moonlive/effects/gradient.mlv
  • moonlive/effects/random-pixel.mlv
  • moonlive/layouts/diagonal.mlv
  • moonlive/layouts/grid.mlv
  • moonlive/layouts/reversed-row.mlv
  • moonlive/layouts/two-rows.mlv
  • moonlive/modifiers/mirror.mlv
  • moonlive/modifiers/shift.mlv
  • moonlive/modifiers/transpose.mlv
  • src/core/moonlive/MoonLiveBuiltins.h
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveIr.h
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveEffect.h
  • src/light/moonlive/MoonLiveLayout.h
  • src/main.cpp
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/desktop/moonlive_asm_host.h
  • src/platform/desktop/moonlive_lower_host.cpp
  • src/platform/esp32/moonlive_asm_riscv.cpp
  • src/platform/esp32/moonlive_asm_riscv.h
  • src/platform/esp32/moonlive_asm_xtensa.cpp
  • src/platform/esp32/moonlive_asm_xtensa.h
  • src/platform/esp32/moonlive_lower_riscv.cpp
  • src/platform/esp32/moonlive_lower_xtensa.cpp
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/scenarios/light/scenario_MoonLive_pipeline.json
  • test/scenarios/light/scenario_peripheral_grid_sweep.json
  • test/unit/light/unit_MoonLiveLayout.cpp
  • test/unit/light/unit_MoonLiveModifier.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp

Comment thread docs/moonmodules/light/MoonLiveLayout.md Outdated
Comment on lines +42 to +46
build = subprocess.run(
["c++", "-std=c++20", "-O0", "-I", os.path.join(ROOT, "src"),
"-I", os.path.join(ROOT, "src", "platform", "desktop"),
TOOL_SRC, os.path.join(ROOT, "src", "core", "moonlive", "MoonLiveCompiler.cpp"),
"-o", emitter],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the MoonDeck build entry point.

This direct c++ invocation bypasses the project build configuration. Build the temporary emitter through the MoonDeck build path so its compiler flags and dependencies match the project.

As per coding guidelines, “Use the project's MoonDeck scripts for build, flash, monitor, test, and check tasks rather than invoking alternate tooling directly.”

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 41-46: Command coming from incoming request
Context: subprocess.run(
["c++", "-std=c++20", "-O0", "-I", os.path.join(ROOT, "src"),
"-I", os.path.join(ROOT, "src", "platform", "desktop"),
TOOL_SRC, os.path.join(ROOT, "src", "core", "moonlive", "MoonLiveCompiler.cpp"),
"-o", emitter],
capture_output=True, text=True)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🪛 Ruff (0.16.1)

[error] 42-42: subprocess call: check for execution of untrusted input

(S603)


[warning] 42-42: subprocess.run without explicit check argument

Add explicit check=False

(PLW1510)


[error] 43-46: Starting a process with a partial executable path

(S607)

🤖 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 `@moondeck/moonlive/disasm.py` around lines 42 - 46, Update the emitter build
flow around subprocess.run to use the project’s MoonDeck build entry point
instead of invoking c++ directly. Ensure the temporary emitter is built through
the MoonDeck script or command so project-configured compiler flags and
dependencies are applied, while preserving the existing emitter output behavior.

Source: Coding guidelines

Comment thread moondeck/moonlive/disasm.py Outdated
Comment on lines +5 to +15
#define __XTENSA__ 1
#include "platform/esp32/moonlive_asm_xtensa.h"
#include "platform/esp32/moonlive_asm_xtensa.cpp"
// The lowerer body, with the emit seam it expects.
#include "core/moonlive/MoonLiveIr.h"
#include "core/moonlive/MoonLiveBuiltins.h"
namespace mm::moonlive {
size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap);
}
#include "platform/esp32/moonlive_lower_xtensa.cpp"
#undef __XTENSA__

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep the Xtensa backend behind the platform layer.

moondeck/moonlive/emit_xtensa.cpp defines __XTENSA__ and directly includes platform implementation files. This moves platform-specific implementation ownership into a MoonDeck utility and can create duplicate definitions when normal backend sources are also linked. Expose a platform-layer library or API and link the utility to it.

As per path instructions, src/platform/** is the only place where platform-specific includes and hardware APIs are allowed.

🤖 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 `@moondeck/moonlive/emit_xtensa.cpp` around lines 5 - 15, Remove the direct
Xtensa implementation includes and __XTENSA__ definition from the translation
unit containing lowerToBytes. Expose the required backend functionality through
the platform-layer library/API under src/platform/**, then have the MoonDeck
utility depend on and link against that API without compiling platform
implementation files directly; preserve a single backend definition.

Source: Path instructions

Comment thread moonlive/effects/gradient.mlv
Comment thread src/platform/desktop/moonlive_asm_host.cpp
Comment thread src/platform/esp32/moonlive_asm_riscv.cpp Outdated
Comment thread src/platform/esp32/moonlive_lower_riscv.cpp
Comment on lines +13 to +52
"fixture": [
{
"name": "fix-layouts",
"op": "add_module",
"id": "Layouts",
"type": "Layouts"
},
{
"name": "fix-scripted-layout",
"op": "add_module",
"id": "MLL",
"type": "MoonLiveLayout",
"parent_id": "Layouts"
},
{
"name": "fix-layer",
"op": "add_module",
"id": "Layer",
"type": "Layer",
"props": {
"layouts": "Layouts",
"channelsPerLight": 3
}
},
{
"name": "fix-drivers",
"op": "add_module",
"id": "Drivers",
"type": "Drivers",
"props": {
"layer": "Layer"
}
},
{
"name": "fix-artnet",
"op": "add_module",
"id": "ArtNet",
"type": "NetworkSendDriver",
"parent_id": "Drivers"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Exercise the Effects container in this end-to-end scenario.

The fixture creates a standalone Layer, and Drivers uses its direct layer fallback. This bypasses Effects::setLayouts and Drivers::setEffects, which are part of this rename.

Add an Effects fixture with Layouts configured. Parent Layer under Effects. Configure Drivers with effects.

🤖 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 `@test/scenarios/light/scenario_MoonLive_pipeline.json` around lines 13 - 52,
Update the fixture to add an Effects module configured with the existing Layouts
module, parent Layer under Effects, and replace Drivers’ direct layer property
with the effects reference. Preserve the existing module IDs and driver setup
while routing the scenario through Effects::setLayouts and Drivers::setEffects.

Comment thread test/unit/light/unit_MoonLiveScripts.cpp
A `for` counter was destroyed by any setRGB or addLight in its own body on both device backends, so
a loop ran the wrong number of times. Nested loops were refused outright on Xtensa, which meant the
shipped default layout script could not compile on the smallest target. Both fixed, and confirmed on
an S3 and a P4.

Performance: desktop tick unchanged; flash S3 +80 B, P4 +144 B (the scratch registers and the x3 save).

**Light domain**
- `two-rows.mlv` is now the serpentine its own comment describes: out along y=0, back along y=1 with
  x descending, so the turn joins two lights that are neighbours on the wire. It emitted
  (0,0),(0,1),(1,0),(1,1)… — a zig-zag no physical strand can follow.

**Platform**
- `StoreElem` builds a light's byte address in SCRATCH instead of folding it into the caller's index
  register. Folding assumed the index is dead after the store — true for a throwaway temp, false for
  a `for` counter, which the step and the loop test read again. `mull a8, a8, a4` overwrote the
  counter and `bne a8, a7` then compared the corrupted value. Xtensa AND RISC-V; the desktop host was
  already correct because it used a scratch register, which is exactly why the suite stayed green.
- Inline scratch is reserved only for the ops a program contains. The unconditional reservation cost
  every script a register, and that one register is what a nested loop was short of on Xtensa (12
  registers, and the program needs 11). Nested loops now compile there.
- arm64 `call()` saves x3 (kArg3, elapsed time), frame 112 -> 128 bytes. Unreachable today — the
  grammar has no `t` and no lowering reads it — but a built-in may clobber x3 under the AAPCS, so
  exposing `t` would have made it a silent wrong-value bug. The new encodings were checked by
  re-deriving the existing instructions with the same encoder first.
- RISC-V `kScratchFn` was x16/a6 = vreg R12, so `call()` stashed its result there and the restore
  loop reloaded it from the frame, returning R12's stale value. Now t6, the only caller-saved
  register outside the map. A constexpr check on all three backends now rejects a scratch that is
  also a vreg; restoring the old value makes it fire, so it catches the bug rather than passing
  vacuously.

**Tests**
- A loop counter survives the body that uses it; a nested loop lays out a full grid on every
  target's register budget.
- `unit_MoonLiveScripts` requires native-code success only where a backend exists for the host ISA
  (`MM_MOONLIVE_HAS_HOST_JIT` is 0 on x86_64, which is what CI runs) — it would have failed every
  script there for a reason unrelated to the script. The codegen diagnostic is now a named constant
  so the test distinguishes "no JIT here" from "this script is wrong" without matching on prose.

**Docs/Scripts**
- The two-pass count/emit guarantee states its condition: it holds for a deterministic script, and
  `random16` breaks it (the Limits section already said so, three paragraphs down).
- A serpentine over arbitrary rows still needs `%` or `if`; a fixed few rows can be written as one
  loop per direction, which is what two-rows.mlv now does.
- `disasm.py`: rename the ambiguous `l` bindings (Ruff E741). `emit_xtensa.cpp` records why it
  includes the backend sources directly: it is `#if defined(__XTENSA__)`, so on this host it compiles
  to nothing and there is no library to link — running the real emitter without a board is the point.

**Reviews**
- 🐇 RISC-V kScratchFn aliasing, and the call return destroyed by the restore: already fixed earlier
  on this branch; verified against current code and skipped.
- 🐇 RISC-V StoreElem clobbers the loop variable: fixed, and Xtensa had it too.
- 🐇 two-rows.mlv is not a serpentine: fixed.
- 🐇 arm64 call() does not preserve x3: fixed.
- 🐇 Ruff E741 in disasm.py; layout doc's unqualified drift claim; script tests need a host-JIT
  gate: all fixed.
- 🐇 Scenario fixture should route through Effects: skipped, stale — it describes the fixture already
  replaced on this branch, which wires Layer.layouts and Drivers.layer like every working scenario.
- 🐇 emit_xtensa.cpp should link a platform library instead of including .cpp files, and disasm.py
  should build through MoonDeck: skipped, same premise — the backend compiles to nothing off-target,
  so no such library exists. Reason recorded in the file.
- 🐇 print() must not write from the render path: bounded by the burst cap, documented, and
  backlogged by name last commit; the queue redesign is its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/platform/esp32/moonlive_asm_xtensa.cpp (2)

174-180: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stage argument A before overwriting a11.

a11 is a valid source register in kXtReg. If a maps to a11, Line 179 overwrites it before Line 180 reads it. A three-argument built-in then receives argument B as argument A.

Use a non-vreg scratch such as a13 to stage argument A before assigning a11.

Proposed fix
-    emit2(uint16_t((uint32_t(ar(c)) << 8) | (12 << 4) | 0xd));   // mov a12, argC
-    emit2(uint16_t((uint32_t(ar(b)) << 8) | (11 << 4) | 0xd));   // mov a11, argB
-    emit2(uint16_t((uint32_t(ar(a)) << 8) | (10 << 4) | 0xd));   // mov a10, argA
+    emit2(uint16_t((uint32_t(ar(a)) << 8) | (13 << 4) | 0xd));   // mov a13, argA
+    emit2(uint16_t((uint32_t(ar(c)) << 8) | (12 << 4) | 0xd));   // mov a12, argC
+    emit2(uint16_t((uint32_t(ar(b)) << 8) | (11 << 4) | 0xd));   // mov a11, argB
+    emit2(uint16_t((13u << 8) | (10 << 4) | 0xd));                // mov a10, a13
🤖 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 `@src/platform/esp32/moonlive_asm_xtensa.cpp` around lines 174 - 180, Update
the three-argument emission sequence around the mov instructions to stage
argument A in the non-vreg scratch register a13 before writing a11, then assign
a10 from that staged value. Preserve the existing high-first ordering for
arguments C and B and ensure ar(a) is read before a11 can be overwritten.

167-172: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve a14 and a15 across the window rotation.

kXtReg maps virtual registers R10 and R11 to a14 and a15. callx8 rotates the window by 8, so these live values can be overwritten. Save both registers to the entry frame around callx8 and restore them with the other rotate-out values.

🤖 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 `@src/platform/esp32/moonlive_asm_xtensa.cpp` around lines 167 - 172, Update
the callx8 save/restore sequence around the rotate-out scratch handling to
preserve a14 and a15, which hold virtual registers R10 and R11 via kXtReg. Store
both registers in unused entry-frame slots before callx8 and restore them
alongside a8, a9, and a11 afterward, without changing the existing a10
argument/result handling.

Source: Path instructions

src/core/moonlive/MoonLiveCompiler.cpp (1)

435-440: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject lexer errors while scanning the deferred step expression.

If the step contains an unsupported character, such as i = i / 1, Lexer::advance() leaves the lexer at the same character. This loop then never reaches Tok::End or Tok::RParen. Compilation can block the caller indefinitely.

Proposed fix
         int depth = 0;
         while (!failed && lex.kind != Tok::End) {
+            if (lex.kind == Tok::Error) {
+                fail(lex.err);
+                return false;
+            }
             if (lex.kind == Tok::LParen) depth++;
             else if (lex.kind == Tok::RParen) { if (depth == 0) break; depth--; }
             lex.advance();
         }
🤖 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 `@src/core/moonlive/MoonLiveCompiler.cpp` around lines 435 - 440, Update the
deferred step-expression scanning loop in MoonLiveCompiler to detect lexer
errors or lack of advancement after lex.advance(). When the lexer cannot
advance, reject the expression and propagate the existing compilation failure
path instead of continuing to loop; preserve normal stopping at Tok::End and the
matching Tok::RParen.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/moonmodules/light/MoonLiveLayout.md`:
- Line 54: Update the random16 explanation near the two-pass addLight
description: state that counts differ only when random output changes loop
bounds or the number of addLight calls, while random coordinate arguments can
produce different placements between passes without changing the count.

In `@src/platform/esp32/moonlive_asm_riscv.cpp`:
- Around line 16-17: Update the register mapping around kRvReg so host
callee-saved registers x18–x21 are preserved across JIT entry and exit:
implement matching save/restore logic in the registered-function prologue() and
epilogue(), including stack handling, or remove the corresponding R14–R17
mappings from kRvReg. Ensure the existing lowerToBytes() call preservation
remains consistent.

In `@test/unit/light/unit_MoonLiveLayout.cpp`:
- Around line 270-284: Extend the regression test for loop-counter preservation
to exercise StoreElem lowering through an effect or engine buffer, using a
six-iteration loop that calls setRGB with the loop index and asserting all six
indexed writes occur. Keep the existing addLight call-based test separately to
retain Call register-preservation coverage, and align the buffer setup and
assertions with the relevant docs/moonmodules specification.

---

Outside diff comments:
In `@src/core/moonlive/MoonLiveCompiler.cpp`:
- Around line 435-440: Update the deferred step-expression scanning loop in
MoonLiveCompiler to detect lexer errors or lack of advancement after
lex.advance(). When the lexer cannot advance, reject the expression and
propagate the existing compilation failure path instead of continuing to loop;
preserve normal stopping at Tok::End and the matching Tok::RParen.

In `@src/platform/esp32/moonlive_asm_xtensa.cpp`:
- Around line 174-180: Update the three-argument emission sequence around the
mov instructions to stage argument A in the non-vreg scratch register a13 before
writing a11, then assign a10 from that staged value. Preserve the existing
high-first ordering for arguments C and B and ensure ar(a) is read before a11
can be overwritten.
- Around line 167-172: Update the callx8 save/restore sequence around the
rotate-out scratch handling to preserve a14 and a15, which hold virtual
registers R10 and R11 via kXtReg. Store both registers in unused entry-frame
slots before callx8 and restore them alongside a8, a9, and a11 afterward,
without changing the existing a10 argument/result handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d264f38f-11a3-4be8-9bfb-470edcf04c29

📥 Commits

Reviewing files that changed from the base of the PR and between aa2e75f and c28015b.

📒 Files selected for processing (18)
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • moondeck/moonlive/disasm.py
  • moondeck/moonlive/emit_xtensa.cpp
  • moonlive/layouts/two-rows.mlv
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveCompiler.h
  • src/core/moonlive/MoonLiveIr.h
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/desktop/moonlive_lower_host.cpp
  • src/platform/esp32/moonlive_asm_riscv.cpp
  • src/platform/esp32/moonlive_asm_xtensa.cpp
  • src/platform/esp32/moonlive_lower_riscv.cpp
  • src/platform/esp32/moonlive_lower_xtensa.cpp
  • test/scenarios/light/scenario_MoonLive_pipeline.json
  • test/unit/light/unit_MoonLiveLayout.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp

Comment thread docs/moonmodules/light/MoonLiveLayout.md Outdated
Comment thread src/platform/esp32/moonlive_asm_riscv.cpp Outdated
Comment thread test/unit/light/unit_MoonLiveLayout.cpp
A MoonLive script hit a wall at six statements, whichever kind it was: the compiler's working buffers
were fixed-size members chosen when a script was one statement. They are now sized to the source, so
a script is bounded by the device's memory instead. A scripted module also cost far more RAM than it
should, which bootlooped an S3.

Performance: desktop tick 149 -> 148us; flash S3 +1.3KB, P4 +640B, desktop +16KB.

**Core**
- `IrProgram` allocates its op array to fit, sized by a token pre-pass before parsing. It was
  `IrInst ops[64]` — 2 KB of stack for a one-statement script and a hard ceiling for a large one.
  `kMaxIrOps` survives only as a sanity bound so a runaway source fails with a diagnostic.
- `MoonLive::compile` stages emitted code in a right-sized buffer instead of `uint8_t staging[768]`.
- A `sourceHash` (FNV-1a, the idiom already used for schema and WS diffing) answers "has this source
  changed since I compiled it" in 4 bytes.
- The `for` header's step-expression scan now stops on a lexer error. `Tok::Error` is not `Tok::End`
  and the lexer does not advance past the offending character, so a stray symbol there HUNG the
  compile instead of reporting it.

**Light domain**
- A scripted module was as large as three copies of its script. `compiled_` existed only to be
  compared, and the modifier's `full_` existed for the duration of one compile; the first is now the
  hash, the second a cold-path temporary. MoonLiveModifier 13328 -> 4632 B, Layout 8712 -> 4624 B.
  This is what bootlooped the S3: `ModuleFactory::registerType` constructs a `T probe` ON THE STACK,
  and 13 KB does not fit a 12288-byte main task.
- The script buffer is 4 KB (was 512 B, which truncated a longer script silently — the ceiling a
  user meets first, since it is what the UI writes into).

**Platform**
- Xtensa saves a14/a15 across `call()`. They are vregs R10/R11 and CALL8 rotates the window out from
  under them, so a value live across a call was destroyed. Reachable on the SHIPPED default:
  `grid.mlv` is a nested loop (11 vregs, so R10 is in use) whose body calls addLight.
- Xtensa stages argA through a13. a11 is vreg R9, so argA could BE a11 and the argB write clobbered
  it before the a10 move read it; high-first ordering alone did not cover that.
- RISC-V drops x18-x21 from its vreg map. They are callee-saved and `prologue()` is empty, so the
  emitted routine returned to its caller with four registers clobbered. 14 vregs still exceeds
  Xtensa's 12; frame 112 -> 80 bytes with every offset moved to match.
- The branch back-patch is bounds-checked in all three assemblers, and every label/fixup table access
  is guarded against a failed allocation — `buf_` is the caller's tightly-sized buffer now, where a
  stray patch corrupts the heap rather than landing in an oversized member.
- `Scheduler::prepareTree` quiesces the render worker for the whole walk. applyState runs Layouts,
  then Layer, then Drivers, so a reshape leaves a window where the mapping is rebuilt for the new
  light count while the driver buffer is still the old size. The add/remove mutators already quiesce;
  a reshape did not. NOT MoonLive-specific — `LayoutBase::affectsPrepare` returns true for every
  layout control, so a compiled GridLayout width takes the same path.

**Tests**
- Resizing a scripted layout keeps the layer's mapping and buffer in step: six resizes, growing and
  shrinking, through a real Layouts + Layer. Green under ASan.
- A loop counter survives the body that uses it, now covering the inline store (`setRGB`) as well as
  the call (`addLight`) — the bug was in StoreElem, which the call path never exercised. Verified by
  reintroducing the bug and watching the new subcase fail.
- A stray character in a `for` header is rejected rather than spun on.
- A script is limited by memory, not by a fixed op or code budget.
- `unit_MoonLiveLayout` / `unit_MoonLiveModifier` gate on `MM_MOONLIVE_HAS_HOST_JIT`. It is 0 on
  x86_64, which is what CI runs, so every case there failed for a reason unrelated to the layout —
  this is the sanitizer failure on the last two commits, verified against a true no-backend build.

**Docs/Scripts**
- `disasm.py` links the platform layer (the new allocations need it) — it had stopped building.
- The two-pass count/emit guarantee states when `random16` actually breaks it: the count only
  diverges when randomness drives a loop bound or the number of addLight calls; a random coordinate
  keeps the count and moves the lights.
- Backlogged: shrink a scripted module below its 4 KB script buffer, and stop `registerType` building
  a whole module on the stack — that is the smaller fix and the one that unblocks a classic ESP32.

**Reviews**
- 🐇 Xtensa a14/a15 not preserved across call: fixed, confirmed in disassembly.
- 🐇 Xtensa argA clobbered when it is a11: fixed, staged through a13.
- 🐇 RISC-V x18-x21 callee-saved but clobbered: fixed by dropping them from the map.
- 🐇 for-header scan can loop forever: fixed, with a regression test.
- 🐇 Loop-counter test misses StoreElem: fixed, and the new subcase was proven to fail with the bug
  reintroduced.
- 🐇 random16 two-pass wording imprecise: fixed.
- 👾 Reviewer on the staged diff: the agent died on an API error, so no findings. I audited the part
  it was pointed at — every new allocation is freed on all paths (three destructors, two scope
  guards), and free(nullptr) is a no-op on both platforms.

Known open: resizing a scripted layout still corrupts the heap on an S3 (a `block_locate_free`
assert — a damaged free list, so a write past a block, not a race). The quiesce above did not fix it
and the cause is not yet found; whether it predates this branch is being checked on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/core/moonlive/MoonLiveCompiler.cpp (1)

299-302: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the stale comment above the arity check.

The comment states that the IR Call op carries a single argument vreg and that a Call builtin must be unary. The code now passes three argument vregs and rejects only argc > 3. Align the comment with the three-argument contract.

♻️ Proposed comment update
-        // The IR Call op carries a single argument vreg, so a Call-kind builtin must be unary.
-        // (Today random16 is the only one.) Reject a multi-arg Call up front rather than silently
-        // dropping args[1..]; a future N-ary helper needs the IR Call contract widened first.
+        // The IR Call op carries three argument vregs, so a Call-kind builtin takes at most three
+        // arguments. Reject a wider Call up front rather than silently dropping args[3..]; a
+        // future N-ary helper needs the IR Call contract widened first.
         if (fn->kind == BuiltinKind::Call && fn->argc > 3) { fail("a call takes at most three arguments"); return; }
🤖 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 `@src/core/moonlive/MoonLiveCompiler.cpp` around lines 299 - 302, Update the
comment above the arity check in the MoonLiveCompiler call-handling logic to
describe the IR Call op’s three-argument-vreg contract and state that Call-kind
builtins support at most three arguments. Keep the existing argc > 3 validation
and failure behavior unchanged.
src/platform/esp32/moonlive_asm_riscv.cpp (1)

130-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale frame documentation in RiscvAssembler::call. The call frame grew from 64 to 80 bytes and gained argument staging slots, but the surrounding comments still describe the old layout. A reader checking offsets against the comments gets the wrong numbers.

  • src/platform/esp32/moonlive_asm_riscv.cpp#L130-L143: change "64-byte frame, 16-byte aligned" to 80-byte, and derive the saved set from kRvReg so the list cannot drift from the register map.
  • src/platform/esp32/moonlive_asm_riscv.cpp#L166-L170: change the trailing comments from lw ra, 108(sp) and addi sp, sp, 112 to 76 and 80, matching the emitted encodings.
🤖 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 `@src/platform/esp32/moonlive_asm_riscv.cpp` around lines 130 - 143, The
RiscvAssembler::call frame documentation and saved-register handling are stale:
in src/platform/esp32/moonlive_asm_riscv.cpp lines 130-143, update the header to
describe the 80-byte frame and derive the saved set from kRvReg so it cannot
drift; in lines 166-170, correct the trailing offset comments to ra at 76(sp)
and stack restoration by 80 bytes.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/history/plans/Plan-20260809` - MoonLive scales — right-sized IR, and the
stack as the register overflow.md:
- Around line 9-14: Update all repository-source Markdown links in this plan,
including the affected ranges, to prefix paths with ../../../ so links resolve
from docs/history/plans/ to the repository root; preserve the existing targets
and link text.
- Around line 220-232: Correct the ordered-list numbering in the verification
steps: keep the first item 6, then renumber the subsequent entries sequentially
from 7 through 10 without changing their content.
- Around line 50-55: Keep platform allocation and executable-memory placement
out of core: update the plan at docs/history/plans/Plan-20260809 - MoonLive
scales — right-sized IR, and the stack as the register overflow.md:50-55 to
specify a core-neutral compile/code-placement interface, and update
src/core/moonlive/MoonLive.cpp:61-76 to inject that interface into MoonLive.
Implement the concrete allocation and executable-code placement behind the
interface under src/platform/**, leaving src/core/** platform-independent.

In `@moonlive/README.md`:
- Around line 22-25: Update the README description of the source control’s
capacity to state that it has a fixed 4 KB limit, with the effective script
limit determined by whichever is reached first: the 4 KB source capacity or
available device memory. Remove the claim that script size is bounded only by
free memory or has no fixed budget.

In `@src/core/moonlive/MoonLive.cpp`:
- Around line 61-76: Update the staging allocation in the compile flow around
srcLen, stagingCap, and compileSource so a valid script is not rejected when the
initial estimate is too small. Base capacity on parsed IR or detect output
overflow and retry compilation with a larger buffer before reporting “codegen
failed,” while preserving cleanup on every allocation and retry path.

In `@src/core/moonlive/MoonLiveIr.h`:
- Line 6: Remove the platform/platform.h dependency from IrProgram in
MoonLiveIr.h and make op-array allocation core-neutral. Update
IrProgram::reserve and its callers to receive an injected allocator interface or
caller-owned span, with the light/platform layer supplying allocation and
release; preserve the existing sizing and storage behavior without platform
references in src/core.
- Around line 122-128: Update IrProgram::reserve to reset vregsUsed alongside
count before allocating the new instruction buffer, ensuring reused programs
start with no stale virtual-register usage mark.

In `@src/light/moonlive/MoonLiveEffect.h`:
- Around line 105-110: Update the comment above MoonLiveEffect::source_ to
remove obsolete references to the 512-byte limit, truncation, and any backlog
for increasing capacity; document only the current 4096-byte buffer rationale
and stable-address requirement.

In `@src/platform/esp32/moonlive_asm_riscv.cpp`:
- Around line 166-170: Update the inline comments on the restore instructions in
the surrounding `emit32` sequence to match their actual immediates: document
`encLw(1, 2, 76)` as loading from 76(sp) and `encAddi(2, 2, 80)` as adding 80 to
sp. Do not change the instruction values or surrounding restore logic.

In `@src/platform/esp32/moonlive_asm_xtensa.h`:
- Around line 27-39: Remove the first duplicated constructor documentation
paragraph in the ESP32 Xtensa assembler declaration, preserving the second block
that also documents branches. Apply the same cleanup to the corresponding
assembler documentation in the desktop host and ESP32 RISC-V declarations.

In `@test/unit/light/probe_w.cpp`:
- Around line 14-26: Add doctest assertions to the light-count test around the
existing controls loop and repeated lightCount() calls: verify the initial
count, assert each result equals the expected width multiplied by height, and
assert a, b, and c are equal for every width. Retain the diagnostic output while
making incorrect or inconsistent counts fail the test.
- Line 4: Add the missing <initializer_list> standard header alongside the
existing includes in probe_w.cpp so the range-based initializer-list loop
compiles in sanitizer builds.

In `@test/unit/light/unit_MoonLiveLayout.cpp`:
- Around line 388-418: Gate the code-generation TEST_CASE named “a script is
limited by memory, not by a fixed op or code budget” with
MM_MOONLIVE_HAS_HOST_JIT, since both subcases require a host backend. Move the
existing `#endif` below this TEST_CASE or apply the same conditional pattern used
in unit_MoonLiveScripts.cpp, while preserving both subcases unchanged when JIT
support is available.

In `@test/unit/light/unit_MoonLiveScripts.cpp`:
- Around line 111-115: Update the valid-script assertion in the MoonLive test
around the case variable c so kCodegenFailed is accepted only when
MM_MOONLIVE_HAS_HOST_JIT is unavailable, matching the existing logic around
lines 77-81. Keep the expected-failure assertion unchanged and preserve
acceptance of successful valid scripts on all hosts.

---

Outside diff comments:
In `@src/core/moonlive/MoonLiveCompiler.cpp`:
- Around line 299-302: Update the comment above the arity check in the
MoonLiveCompiler call-handling logic to describe the IR Call op’s
three-argument-vreg contract and state that Call-kind builtins support at most
three arguments. Keep the existing argc > 3 validation and failure behavior
unchanged.

In `@src/platform/esp32/moonlive_asm_riscv.cpp`:
- Around line 130-143: The RiscvAssembler::call frame documentation and
saved-register handling are stale: in src/platform/esp32/moonlive_asm_riscv.cpp
lines 130-143, update the header to describe the 80-byte frame and derive the
saved set from kRvReg so it cannot drift; in lines 166-170, correct the trailing
offset comments to ra at 76(sp) and stack restoration by 80 bytes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a98fcdee-f1ae-4cd6-a4ac-71d1425795d7

📥 Commits

Reviewing files that changed from the base of the PR and between aa2e75f and 32026eb.

📒 Files selected for processing (33)
  • docs/backlog/backlog-light.md
  • docs/history/plans/Plan-20260809 - MoonLive scales — right-sized IR, and the stack as the register overflow.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • moondeck/moonlive/disasm.py
  • moondeck/moonlive/emit_xtensa.cpp
  • moonlive/README.md
  • moonlive/layouts/two-rows.mlv
  • src/core/Scheduler.cpp
  • src/core/moonlive/MoonLive.cpp
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveCompiler.h
  • src/core/moonlive/MoonLiveIr.h
  • src/light/moonlive/MoonLiveEffect.h
  • src/light/moonlive/MoonLiveLayout.h
  • src/light/moonlive/MoonLiveModifier.h
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/desktop/moonlive_asm_host.h
  • src/platform/desktop/moonlive_lower_host.cpp
  • src/platform/esp32/moonlive_asm_riscv.cpp
  • src/platform/esp32/moonlive_asm_riscv.h
  • src/platform/esp32/moonlive_asm_xtensa.cpp
  • src/platform/esp32/moonlive_asm_xtensa.h
  • src/platform/esp32/moonlive_lower_riscv.cpp
  • src/platform/esp32/moonlive_lower_xtensa.cpp
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/scenarios/light/scenario_MoonLive_pipeline.json
  • test/unit/light/probe_w.cpp
  • test/unit/light/unit_MoonLiveLayout.cpp
  • test/unit/light/unit_MoonLiveModifier.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp

Comment on lines +9 to +14
- **Seven `addLight` statements in a row fail.** `kMaxIrOps = 64` ([MoonLiveIr.h:33](src/core/moonlive/MoonLiveIr.h#L33))
and a call costs ~9 IR ops. No nesting, no register pressure — this is the wall a user meets first.
- **Nested `for` loops are refused on Xtensa.** Measured: `LOWER BAIL: vregsUsed=11 +2 > kRegCount=12`
([moonlive_lower_xtensa.cpp:24](src/platform/esp32/moonlive_lower_xtensa.cpp#L24)). They work on
desktop and RISC-V, which have larger register maps. The shipped default layout script `grid.mlv`
is a nested loop, so the module's own default cannot compile on the smallest target.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the source-file links.

These relative paths resolve below docs/history/plans/, not at the repository root. For example, src/core/moonlive/MoonLiveIr.h resolves to docs/history/plans/src/core/moonlive/MoonLiveIr.h.

Prefix repository-source links with ../../../, and update all affected links in this plan.

Also applies to: 31-46, 50-52, 67-70, 83-103, 114-126, 171-191

🤖 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/history/plans/Plan-20260809` - MoonLive scales — right-sized IR, and the
stack as the register overflow.md around lines 9 - 14, Update all
repository-source Markdown links in this plan, including the affected ranges, to
prefix paths with ../../../ so links resolve from docs/history/plans/ to the
repository root; preserve the existing targets and link text.

Comment on lines +50 to +55
- **Allocate to fit the script, not to `kMax`.** The op array and the code buffer move to a
right-sized `platform::alloc` ([platform.h:55](src/platform/platform.h#L55)), sized from a cheap
pre-pass over the source and freed when compilation ends. A one-statement script pays for one
statement instead of 3.4 KB, so this *reduces* peak memory for the common case while removing the
ceiling for the rare one. Compilation is cold-path, so an allocation there costs nothing that
matters.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep platform allocation and executable placement outside src/core.

The plan assigns platform::alloc to core code, and MoonLive::compile implements that design. This breaks the required core/platform boundary. It also leaves MoonLive.cpp coupled to platform allocation and executable-memory behavior.

  • docs/history/plans/Plan-20260809 - MoonLive scales — right-sized IR, and the stack as the register overflow.md#L50-L55: change the design to use a core-neutral compile and code-placement interface.
  • src/core/moonlive/MoonLive.cpp#L61-L76: inject that interface into MoonLive, and move allocation and executable-code placement to src/platform/**.

Based on learnings: src/core must remain platform-independent and executable-memory handling must move behind a core/platform boundary. As per path instructions: src/core/** must be platform-independent, and only src/platform/** may contain platform-specific APIs.

📍 Affects 2 files
  • docs/history/plans/Plan-20260809 - MoonLive scales — right-sized IR, and the stack as the register overflow.md#L50-L55 (this comment)
  • src/core/moonlive/MoonLive.cpp#L61-L76
🤖 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/history/plans/Plan-20260809` - MoonLive scales — right-sized IR, and the
stack as the register overflow.md around lines 50 - 55, Keep platform allocation
and executable-memory placement out of core: update the plan at
docs/history/plans/Plan-20260809 - MoonLive scales — right-sized IR, and the
stack as the register overflow.md:50-55 to specify a core-neutral
compile/code-placement interface, and update
src/core/moonlive/MoonLive.cpp:61-76 to inject that interface into MoonLive.
Implement the concrete allocation and executable-code placement behind the
interface under src/platform/**, leaving src/core/** platform-independent.

Sources: Path instructions, Learnings

Comment on lines +220 to +232
6. **Degrade**: a deliberately absurd script fails with a clear diagnostic and no crash; an alloc
failure in `reserve()` fails the compile cleanly rather than writing through a null pointer.
6. **Unchanged behaviour**: `unit_moonlive_ir` / `unit_moonlive_fill` (the `fill` behavioural golden,
and kArg4 surviving a call) stay green — they pin that a `FillElems` program still gets its
scratch. `unit_MoonLiveScripts.cpp:118` (bare vs commented produce equal length) is the canary for
the pass accidentally becoming source-dependent.
7. **Encodings on device backends without flashing**: `uv run moondeck/moonlive/disasm.py` on a
spilling script, reading the actual `s32i`/`l32i` offsets against the frame layout. This is the
tool that found the `Mov`→`addi 0` bug. Extend it to RISC-V, which has no equivalent today.
8. **Memory + hot path**: `collect_kpi.py --commit`. The IR allocation is cold-path, but a modifier
script runs once per light, so measure a mapping rebuild on a large grid. Confirm a non-spilling
script emits no prologue and costs nothing.
9. **Hardware, the final gate (PO)**: flash `grid.mlv` on an S3 (Xtensa — the target that fails

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the ordered-list numbering.

Line 222 repeats item 6. Renumber the remaining verification steps from 7 through 10.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 222-222: Ordered list item prefix
Expected: 7; Actual: 6; Style: 1/2/3

(MD029, ol-prefix)


[warning] 226-226: Ordered list item prefix
Expected: 8; Actual: 7; Style: 1/2/3

(MD029, ol-prefix)


[warning] 229-229: Ordered list item prefix
Expected: 9; Actual: 8; Style: 1/2/3

(MD029, ol-prefix)


[warning] 232-232: Ordered list item prefix
Expected: 10; Actual: 9; Style: 1/2/3

(MD029, ol-prefix)

🤖 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/history/plans/Plan-20260809` - MoonLive scales — right-sized IR, and the
stack as the register overflow.md around lines 220 - 232, Correct the
ordered-list numbering in the verification steps: keep the first item 6, then
renumber the subsequent entries sequentially from 7 through 10 without changing
their content.

Source: Linters/SAST tools

Comment thread moonlive/README.md Outdated
Comment on lines +22 to +25
A script's size is bounded by the device's free memory, not by a fixed budget: the compiler sizes
its working buffers to the source it is given. The `source` control holds 4 KB of text, which is a
few hundred statements — well past what a layout or effect needs. A script that outgrows the device
fails to compile with a diagnostic; it never truncates silently.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe the fixed source limit.

The source control has a fixed 4096-byte buffer. State that the effective limit is the 4 KB source capacity or available device memory, whichever is reached first. Do not state that script size has no fixed budget.

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 `@moonlive/README.md` around lines 22 - 25, Update the README description of
the source control’s capacity to state that it has a fixed 4 KB limit, with the
effective script limit determined by whichever is reached first: the 4 KB source
capacity or available device memory. Remove the claim that script size is
bounded only by free memory or has no fixed budget.

Source: Coding guidelines

Comment thread src/core/moonlive/MoonLive.cpp Outdated
Comment on lines +61 to +76
// Staging sized to the SOURCE, not to a fixed worst case. It was `uint8_t staging[768]`, which
// capped a script's emitted code at 768 bytes — about six statements — and put 768 bytes on the
// stack of a 12 KB task for every compile, however small the script.
//
// Eight code bytes per source character is the upper bound: the densest measured construct is a
// three-argument call at ~128 emitted bytes from ~16 characters. The assembler's own overflow
// check still catches a miss, so a wrong factor fails the compile rather than corrupting memory.
// It matters that this is not wildly generous — a 6 KB script on an ESP32 cannot afford an
// allocation sized by guesswork. Freed before returning, on every path.
const size_t srcLen = source ? std::strlen(source) : 0;
const size_t stagingCap = 256 + srcLen * 8;
auto* staging = static_cast<uint8_t*>(platform::alloc(stagingCap));
if (!staging) { freeCode(); error_ = "no code memory"; return false; }
struct Free { uint8_t* p; ~Free() { platform::free(p); } } freeStaging{staging};

CompileResult cr = compileSource(source, table, staging, stagingCap);

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 | 🏗️ Heavy lift

Replace the fixed source-to-code allocation multiplier.

Line 71 allocates 256 + srcLen * 8 bytes. A call also emits argument materialization and the backend call-save sequence. Repeated short valid calls can exceed this capacity. The lowerer then returns zero and reports codegen failed.

Estimate code capacity from parsed IR, or distinguish output overflow and retry with a larger buffer. Do not reject a valid script because the initial staging estimate is too small.

🤖 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 `@src/core/moonlive/MoonLive.cpp` around lines 61 - 76, Update the staging
allocation in the compile flow around srcLen, stagingCap, and compileSource so a
valid script is not rejected when the initial estimate is too small. Base
capacity on parsed IR or detect output overflow and retry compilation with a
larger buffer before reporting “codegen failed,” while preserving cleanup on
every allocation and retry path.

Comment thread src/platform/esp32/moonlive_asm_xtensa.h Outdated
Comment thread test/unit/light/probe_w.cpp Outdated
// @module MoonLiveLayout
#include "doctest.h"
#include "light/moonlive/MoonLiveLayout.h"
#include <cstring>

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 | 🔴 Critical | ⚡ Quick win

Add the required standard header.

The range-based initializer-list loop requires <initializer_list>. The sanitizer builds fail before tests run.

Proposed fix
 `#include` <cstring>
+#include <initializer_list>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#include <cstring>
`#include` <cstring>
`#include` <initializer_list>
🤖 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 `@test/unit/light/probe_w.cpp` at line 4, Add the missing <initializer_list>
standard header alongside the existing includes in probe_w.cpp so the
range-based initializer-list loop compiles in sanitizer builds.

Source: Pipeline failures

Comment thread test/unit/light/probe_w.cpp Outdated
Comment on lines +14 to +26
std::printf(" initial lights=%u\n", (unsigned)l.lightCount());
// Does an unchanged source recompile? codeCap changes address each time if so.

const auto& cs = l.controls();
for (uint8_t v : {10, 20, 32, 64, 1, 16}) {
for (uint8_t i = 0; i < cs.count(); i++)
if (cs[i].name && std::strcmp(cs[i].name, "width") == 0)
*static_cast<uint8_t*>(cs[i].ptr) = v;
l.prepare();
// Ask several times, as the pipeline does (count, then walk, then count).
auto a = l.lightCount(); auto b = l.lightCount(); auto c = l.lightCount();
std::printf(" width=%3u -> %u/%u/%u dynamicBytes=%u\n", v,
(unsigned)a,(unsigned)b,(unsigned)c,(unsigned)l.dynamicBytes());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add assertions for the repeated width changes.

This test only writes diagnostic output. It passes when lightCount() returns incorrect or inconsistent values. Assert the initial count, each expected width * height count, and equality across the repeated queries.

As per path instructions, “Unit and integration tests using doctest. Verify tests cover edge cases and match the specifications.”

🧰 Tools
🪛 Cppcheck (2.21.0)

[error] 23-23: failed to evaluate #if condition, undefined function-like macro invocation

(syntaxError)

🪛 GitHub Actions: Test / 0_sanitizers (address).txt

[error] 18-18: GCC compilation failed: deducing from a brace-enclosed initializer list requires '#include <initializer_list>'. Add the missing header. The 'mm_tests' build failed with exit code 2.

🪛 GitHub Actions: Test / 2_sanitizers (realtime).txt

[error] 18-18: C++ compilation failed: cannot deduce the initializer-list type because <initializer_list> was not included. Add #include <initializer_list>.

🪛 GitHub Actions: Test / 3_sanitizers (thread).txt

[error] 18-18: Compilation failed: deducing from a brace-enclosed initializer list requires '#include <initializer_list>'. Add the missing include before using the initializer-list range in the for loop. Command failed during 'cmake --build build/san --target mm_tests'.

🪛 GitHub Actions: Test / sanitizers (address)

[error] 18-18: GCC compilation failed in the mm_tests build: deducing from a brace-enclosed initializer list requires '#include <initializer_list>' for the range-based loop. Failed command: cmake --build build/san --target mm_tests.

🪛 GitHub Actions: Test / sanitizers (realtime)

[error] 18-18: Build of mm_tests failed: cannot deduce the initializer-list type because <initializer_list> was not included. Add #include <initializer_list> to probe_w.cpp.

🪛 GitHub Actions: Test / sanitizers (thread)

[error] 18-18: Compilation failed: deducing from a brace-enclosed initializer list requires '#include <initializer_list>' for the range-based loop. Add the missing include.

🤖 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 `@test/unit/light/probe_w.cpp` around lines 14 - 26, Add doctest assertions to
the light-count test around the existing controls loop and repeated lightCount()
calls: verify the initial count, assert each result equals the expected width
multiplied by height, and assert a, b, and c are equal for every width. Retain
the diagnostic output while making incorrect or inconsistent counts fail the
test.

Source: Path instructions

Comment thread test/unit/light/unit_MoonLiveLayout.cpp Outdated
Comment thread test/unit/light/unit_MoonLiveScripts.cpp
ewowi and others added 2 commits August 10, 2026 10:18
Resizing a scripted layout corrupted the heap and crashed an S3 roughly once every twelve changes.
Two separate unbounded writes, both found by evidence: 320 resizes on a production build now run
clean, plus malformed scripts, a 60,000-iteration loop, and 40s of concurrent edits from two threads.

Performance: desktop tick 149 -> 150us; S3 tick ~2150us unchanged; flash S3 +272 B, P4 -16 B.
(The stored esp32 KPI of 4164us was recorded on a different board state — this session measured
~2150us both before and after, so the metric's 2013us drop is a baseline change, not a speedup.)

**Light domain**
- `blendMap`'s mapped path is bounded by the destination it was handed. The identity path beside it
  has always clamped to min(src, dst); the LUT path did not, so a mapping built for a NEW light count
  wrote past a driver buffer still sized for the old one. ASan reproduces the overflow deterministically
  with the guard removed.
- `Layer::buildFoldedLUT`'s pass-B scatter is bounded by what it allocated. The two-pass CSR build
  sizes `dests` from pass A, then scatters into it — safe only while both passes see the same
  coordinates. `forEachCoord` triggers a scripted layout's lazy compile, so a control edited between
  the passes makes pass B emit more lights than pass A counted and the write runs off the end. This
  was the main culprit: the corruption surfaced later inside an unrelated allocation, which is why
  the crash pointed everywhere except the writer.
- The `addLight` sink is thread_local. It was one process-wide pair set and cleared around each run,
  so the HTTP task cleared it mid-run of the render task and the built-in called a live function
  pointer with a null context. Same shape as the WDT flag in the ESP32 worker.
- A scripted module reports every heap byte it holds (`MoonLive::heapBytes`), not just the exec block
  — the control arena was invisible, and a failed compile reported 0 while the arena was still live.

**Platform**
- Xtensa saves a14/a15 across `call()`. They are vregs R10/R11 and CALL8 rotates the window out from
  under them, so a value live across a call was destroyed. Reachable on the SHIPPED default: grid.mlv
  is a nested loop (11 vregs, so R10 is in use) whose body calls addLight.
- Xtensa stages argA through a13. a11 is vreg R9, so argA could BE a11 and the argB write clobbered
  it before the a10 move read it; high-first ordering alone did not cover that.
- RISC-V drops x18-x21 from its vreg map. They are callee-saved and `prologue()` is empty, so the
  emitted routine returned to its caller with four registers clobbered. 14 vregs still exceeds
  Xtensa's 12; frame 112 -> 80 bytes with every offset moved to match.

**Core**
- The `for` header's step-expression scan stops on a lexer error. `Tok::Error` is not `Tok::End` and
  the lexer does not advance past the offending character, so a stray symbol HUNG the compile.

**Scripts/MoonDeck**
- `monitor_esp32` decodes panic backtraces inline, the equivalent of PlatformIO's
  `esp32_exception_decoder`: each address resolves to function/file/line against that build's ELF,
  picking the Xtensa or RISC-V toolchain from the firmware name. It also decodes the register-dump PC
  (useful when the frame chain is destroyed), says so when a backtrace is marked CORRUPTED, and warns
  when the device's firmware SHA does not match the ELF — decoding against the wrong build is
  confidently wrong. Without `--firmware` it behaves exactly as before; decoding must never cost the
  serial output.

**Tests**
- A mapping larger than its destination writes only what the buffer holds (ASan-verified: the guard
  removed reproduces heap-buffer-overflow).
- A layout that changes size mid-build cannot overrun the mapping.
- Two threads can run scripts at once without stealing each other's sink.
- A scripted layout reports every heap byte it holds, compiled or not.
- A loop counter survives the body that uses it, now covering the inline store (setRGB) as well as
  the call (addLight) — the bug was in StoreElem, which the call path never exercised.
- A stray character in a `for` header is rejected, not spun on.
- `unit_MoonLiveLayout` / `unit_MoonLiveModifier` gate on `MM_MOONLIVE_HAS_HOST_JIT`. It is 0 on
  x86_64, which is what CI runs, so every case there failed for a reason unrelated to the layout —
  this is the sanitizer failure on the last commits.

**Docs**
- architecture.md states the rule: a scripted module differs from a compiled one in one thing only —
  where its behaviour comes from. A mechanism one needs and the other does not is a finding. The one
  current exception is named: `applyState()` prepares parent-before-child, so a container asks
  children for their extent before they have prepared, which is why MoonLiveLayout compiles lazily
  from a const method. That lazy compile is what let the layout change size mid-build above, so the
  smell and the crash were the same bug. The fix is a two-phase prepare (build child-first, aggregate
  parent-first); it touches the base class and the six aggregating containers, so it belongs on its
  own branch rather than folded in here.
- The two-pass count/emit guarantee states when `random16` actually breaks it: the count only diverges
  when randomness drives a loop bound or the number of addLight calls; a random coordinate keeps the
  count and moves the lights.

**Reviews**
- 🐇 Xtensa a14/a15 not preserved across call: fixed, confirmed in disassembly.
- 🐇 Xtensa argA clobbered when it is a11: fixed, confirmed in disassembly.
- 🐇 RISC-V x18-x21 callee-saved but clobbered: fixed by dropping them from the map. The P4 build
  caught a bad edit here that the desktop build cannot see — the array and its enum had drifted.
- 🐇 for-header scan can loop forever: fixed, with a regression test.
- 🐇 Loop-counter test misses StoreElem: fixed.
- 🐇 random16 two-pass wording imprecise: fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/platform/esp32/moonlive_asm_riscv.cpp (1)

172-181: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Stop branch patching after an emission overflow.

emit32() and emit() set overflow_ without advancing len_. A later branch records a fixup at that full buffer offset. finalize() then calls patchBranches() before lowerToBytes() checks overflowed(). Both implementations can read or write past buf_ during compilation of a valid script that exceeds the fixed code capacity.

  • src/platform/esp32/moonlive_asm_riscv.cpp#L172-L181: Return before reading buf_ + f.at when overflow_ is set.
  • src/platform/esp32/moonlive_asm_xtensa.cpp#L209-L214: Return before writing buf_[f.at + 2] when overflow_ is set.
Proposed fix
 void RiscvAssembler::patchBranches() {
+    if (overflow_) return;
     for (uint8_t i = 0; i < fixupCount_; i++) {
 void XtensaAssembler::patchBranches() {
+    if (overflow_) return;
     for (uint8_t i = 0; i < fixupCount_; i++) {
🤖 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 `@src/platform/esp32/moonlive_asm_riscv.cpp` around lines 172 - 181, Stop
branch patching immediately when overflow_ is set: in patchBranches() within
src/platform/esp32/moonlive_asm_riscv.cpp at lines 172-181, return before
reading buf_ + f.at; likewise, in src/platform/esp32/moonlive_asm_xtensa.cpp at
lines 209-214, return before writing buf_[f.at + 2].
src/platform/desktop/moonlive_asm_host.cpp (1)

166-171: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject invalid and unbound branch labels.

Line 166 indexes labelPos_ before it validates f.label. An invalid label reads past the fixed table. An unbound label leaves the branch offset at zero and does not set overflow_. A taken conditional branch then targets itself, and lowerToBytes() can return executable code that loops forever.

Set overflow_ and stop patching when f.label >= kMaxLabels or when its target is unbound.

Proposed fix
     for (uint8_t i = 0; i < fixupCount_; i++) {
         const Fixup& f = fixups_[i];
+        if (f.label >= kMaxLabels) {
+            overflow_ = true;
+            return;
+        }
         int32_t target = labelPos_[f.label];
-        if (target < 0) continue;
+        if (target < 0) {
+            overflow_ = true;
+            return;
+        }
🤖 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 `@src/platform/desktop/moonlive_asm_host.cpp` around lines 166 - 171, Update
the branch-patching loop around labelPos_ to validate f.label against kMaxLabels
before indexing the table, and set overflow_ then stop patching for invalid
labels. Also set overflow_ and stop patching when the validated label’s target
is unbound, rather than leaving the branch unchanged; preserve normal immediate
patching for valid bound labels.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@moondeck/MoonDeck.md`:
- Around line 959-964: Add the text language identifier to the fenced code block
containing the Guru Meditation monitor output in MoonDeck.md, changing the
opening fence to use text while preserving the output content unchanged.

In `@moondeck/run/monitor_esp32.py`:
- Around line 111-130: Store the firmware SHA comparison result from the SHA_RE
validation near the existing warning, and use it to prevent decoding when the
running SHA does not match elf_sha. Keep reporting the mismatch, but skip the
decode(tool, elf, addrs) call in the subsequent frame-processing loop until the
SHA is valid.
- Around line 71-80: Update the firmware setup block around find_addr2line and
elf_sha so OSError from Path(elf).read_bytes() is caught; on failure, reset
tool, elf, and elf_sha to None and print the existing raw-address fallback
message, allowing monitoring to continue to the serial-port setup.

---

Outside diff comments:
In `@src/platform/desktop/moonlive_asm_host.cpp`:
- Around line 166-171: Update the branch-patching loop around labelPos_ to
validate f.label against kMaxLabels before indexing the table, and set overflow_
then stop patching for invalid labels. Also set overflow_ and stop patching when
the validated label’s target is unbound, rather than leaving the branch
unchanged; preserve normal immediate patching for valid bound labels.

In `@src/platform/esp32/moonlive_asm_riscv.cpp`:
- Around line 172-181: Stop branch patching immediately when overflow_ is set:
in patchBranches() within src/platform/esp32/moonlive_asm_riscv.cpp at lines
172-181, return before reading buf_ + f.at; likewise, in
src/platform/esp32/moonlive_asm_xtensa.cpp at lines 209-214, return before
writing buf_[f.at + 2].
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0d855154-cc2c-4a70-a59b-ba2bcf2b61cd

📥 Commits

Reviewing files that changed from the base of the PR and between 32026eb and 4806046.

📒 Files selected for processing (35)
  • docs/architecture.md
  • docs/backlog/backlog-light.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • moondeck/MoonDeck.md
  • moondeck/moondeck_config.json
  • moondeck/moonlive/disasm.py
  • moondeck/run/monitor_esp32.py
  • moonlive/README.md
  • src/core/moonlive/MoonLive.h
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveCompiler.h
  • src/core/moonlive/MoonLiveIr.h
  • src/light/layers/BlendMap.h
  • src/light/layers/Layer.h
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveEffect.h
  • src/light/moonlive/MoonLiveLayout.h
  • src/light/moonlive/MoonLiveModifier.h
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/desktop/moonlive_asm_host.h
  • src/platform/desktop/moonlive_lower_host.cpp
  • src/platform/esp32/moonlive_asm_riscv.cpp
  • src/platform/esp32/moonlive_asm_riscv.h
  • src/platform/esp32/moonlive_asm_xtensa.cpp
  • src/platform/esp32/moonlive_asm_xtensa.h
  • src/platform/esp32/moonlive_lower_riscv.cpp
  • src/platform/esp32/moonlive_lower_xtensa.cpp
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/scenarios/light/scenario_MoonLive_pipeline.json
  • test/unit/light/unit_BlendMap.cpp
  • test/unit/light/unit_MoonLiveLayout.cpp
  • test/unit/light/unit_MoonLiveModifier.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp
💤 Files with no reviewable changes (6)
  • test/CMakeLists.txt
  • src/core/moonlive/MoonLiveCompiler.h
  • moonlive/README.md
  • docs/backlog/backlog-light.md
  • moondeck/moonlive/disasm.py
  • src/core/moonlive/MoonLiveCompiler.cpp

Comment thread moondeck/MoonDeck.md Outdated
Comment thread moondeck/run/monitor_esp32.py
Comment thread moondeck/run/monitor_esp32.py Outdated
MoonLive scripts now read `width`, `height`, `depth`, `x`, `y`, `z` and `t` as system
variables the engine hands them, instead of each module secretly pasting declarations on
top of the script and hiding them from the UI. An effect is told the size of the layer it
draws into, so it can no longer claim a different one; a layout keeps naming its own size,
because it is what defines the grid in the first place.

Performance: desktop 139 us/tick (7194 fps), esp32 2151 us/tick (464 fps).

Core
- SysVar/SysVarTable beside BuiltinTable, same shape and lookup: a host registers the names
  it defines, the compiler resolves them before locals and controls. `t` was a hardcoded
  identifier check; it is now an ordinary table entry, so there is one resolution path.
- Reserved: a control declaration or a `for` variable reusing a system name fails with
  "name is a system variable". Those are the only two places a script can introduce a name.
- The controls arena is split - script controls below kMaxCtrls, system variables above -
  so adding a control, which renumbers every control offset, cannot move a slot a binding
  cached a pointer to.
- controlSlot() was bounded by controlCount_, so every system-variable write landed on a
  null pointer and was silently dropped. Bounded by the arena now.
- A nested loop reusing the enclosing loop's variable emitted a program that never
  returned - a watchdog reset from a script a user can type. Refused at compile time.
  Pre-existing, found by review.
- kCodeCap lived as four independent literals whose comments warned they must agree; one
  core constant now, which each backend derives its buffer from.

Light domain
- Each binding supplies only what it writes: a layout gets the clock, an effect adds the
  grid, a modifier adds the coordinate. Supplying a name is what reserves it, so `x` and
  `y` stay usable as loop counters where nothing hands out a coordinate, and a layout
  asking for `width` is an error rather than a silent zero that places no lights.
- sin/cos over math16, and turn(n) - one revolution split n ways. A full turn is 65536, one
  past the largest number a script can write, so a circle cannot be spelled without it.
- Layout scripts name their own controls (cols/rows/radius); ring.mlv added, which places
  24 lights in an 11x11 box - the case where light count and grid size differ.
- The script-size constant and its rationale were copy-pasted into three bindings; one
  kMaxScriptBytes now.

Tests
- Reserved names, per-binding supply, and the nested-loop hang are each pinned. The script
  sweep compiles every .mlv against its own binding's table, so a script cannot read a name
  its module never writes and still pass.

Docs/CI
- MIGRATING entry: a persisted layout script declaring `width` no longer compiles and its
  fixture goes dark until edited. MoonLiveEffect.md documents system variables and the call
  vocabulary, which had no home before.
- Backlog: a scripted modifier cannot reshape the grid yet - system variables are read-only,
  so modifyLogicalSize needs a writable kind plus a read-back.

Reviews
- 👾 Reviewer, 11 findings: 8 fixed (the nested-loop hang, the two design findings above,
  four stale/duplicated comments, the SysVarTable cap). 1 skipped - folding SysVarTable's
  lookup into a shared template is a refactor beyond this change, and the Reviewer's own
  conclusion was that two types are correct. 2 were no-defect (arena sizing, reserved-name
  coverage), both re-verified here.
- 🐇 CodeRabbit, 5 findings: 2 fixed in the monitor (a SHA mismatch no longer decodes a
  backtrace against the wrong ELF; an unreadable ELF degrades to raw addresses instead of
  ending the session), 1 doc fence tagged. 3 skipped: they describe patching a code buffer
  that lowerToBytes discards on overflow before it is ever copied out, and an out-of-bounds
  label index that newLabel() makes unreachable.

Known limits
- lines.mlv does not compile on Xtensa: fill plus two loops exceeds the S3 register budget.
  It runs on S31 and desktop. Verified against the real backend; every other shipped script
  compiles on all three.
- Flash on esp32s31 reads +92 KB against a baseline built before this branch's Ethernet
  work, so most of that is not this change; the comparable S3 figure is +1.8 KB.

Flash: esp32 1762368, esp32s3-n16r8 1752128, esp32s31 2024672, desktop 1137800.
Tests: 1322 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
docs/MIGRATING.md (1)

55-55: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Update the manual preset migration.

The documented file edit changes "Layers" to "Effects" but leaves the persisted "role": "layer" value unchanged. That preset still loads without a pad tint, as Line 53 states. Rename the role to "effects" in the manual migration, or instruct users to re-save the preset after the edit.

As per coding guidelines, "**/*.md: 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/MIGRATING.md` at line 55, Update the manual preset migration
instructions near the Layers-to-Effects rename to also change the persisted role
value from "layer" to "effects" in each preset JSON, ensuring migrated presets
load with the correct pad tint.

Source: Coding guidelines

docs/moonmodules/light/MoonLiveModifier.md (1)

11-11: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document for loops as supported syntax. MoonLiveCompiler.cpp now parses braced for loops. A modifier still runs once per coordinate, but its script can contain a loop.

  • docs/moonmodules/light/MoonLiveModifier.md#L11-L11: replace “It does not loop” with wording that distinguishes per-coordinate invocation from supported script syntax.
  • docs/moonmodules/light/MoonLiveModifier.md#L39-L39: remove for from the unsupported grammar list.

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/moonmodules/light/MoonLiveModifier.md` at line 11, Update
docs/moonmodules/light/MoonLiveModifier.md:11 to clarify that the layer invokes
the modifier once per physical light/coordinate while the modifier script itself
may contain supported braced for loops. At
docs/moonmodules/light/MoonLiveModifier.md:39, remove for from the unsupported
grammar list; no other syntax documentation needs changing.

Source: Coding guidelines

src/platform/desktop/moonlive_asm_host.h (1)

59-64: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the host-call documentation to match the three-argument API.

HostAssembler::call now accepts three source registers, but the comment still says d = fn(a). Document d = fn(a, b, c) so the backend contract matches the declaration.

Proposed fix
-    // Call a host built-in: d = fn(a). Preserves the host-arg registers
+    // Call a host built-in: d = fn(a, b, c). Preserves the host-arg registers
🤖 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 `@src/platform/desktop/moonlive_asm_host.h` around lines 59 - 64, Update the
documentation for HostAssembler::call to describe the three-argument operation
as d = fn(a, b, c), while preserving the existing register-preservation and
live-vreg contract details.
src/core/moonlive/MoonLiveIr.h (1)

87-92: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the branch-label limit wording.

kIrLabels = 16 allocates new label IDs as each loop is parsed; a sequential script can use more than eight for constructs if the compiler stopped there. Reserve two labels using localCount for the actual nesting depth, or change kIrLabels to match the intended per-program label capacity.

🤖 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 `@src/core/moonlive/MoonLiveIr.h` around lines 87 - 92, Correct the
branch-label capacity around kIrLabels and its documentation: account for two
labels per for construct when determining supported nesting depth, using
localCount if that is the intended limit, or set kIrLabels to the actual
per-program label capacity. Ensure the documented limit matches the compiler and
assembler behavior.
src/light/moonlive/MoonLiveBuiltins_light.h (1)

24-27: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Remove mutable process-global state from concurrent built-ins.

Layout scripts can run on more than one thread. mm_light_random16() writes s without synchronization. printBudget() is also decremented and reset without synchronization. These operations cause C++ data races and undefined behavior.

  • src/light/moonlive/MoonLiveBuiltins_light.h#L24-L27: make the PRNG state execution-local, such as thread_local, or pass it through a binding-owned context.
  • src/light/moonlive/MoonLiveBuiltins_light.h#L111-L124: store diagnostic budget in binding-owned state and synchronize its reset and consumption. Do not share the counter across script executions.
🤖 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 `@src/light/moonlive/MoonLiveBuiltins_light.h` around lines 24 - 27, Remove
mutable process-global state from both concurrent built-ins: update
mm_light_random16() to use execution-local PRNG state, such as thread_local or
binding-owned context, and update printBudget() to use binding-owned diagnostic
state with synchronized reset and consumption; apply the changes at
src/light/moonlive/MoonLiveBuiltins_light.h lines 24-27 and 111-124.
♻️ Duplicate comments (1)
moondeck/moonlive/emit_xtensa.cpp (1)

1-20: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep the Xtensa backend in the platform layer.

This translation unit defines __XTENSA__ and includes platform assembler and lowering implementations directly. That violates the platform-layer boundary and can create duplicate backend definitions when normal platform sources are linked. Expose the required lowering API from the platform target and link this utility against it.

🤖 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 `@moondeck/moonlive/emit_xtensa.cpp` around lines 1 - 20, Remove the direct
__XTENSA__ definition and included platform implementation files from the
utility translation unit. Expose the required lowerToBytes API through the
platform target, then link this utility against that target while retaining only
the necessary public declarations and IR includes.

Source: Path instructions

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/moonmodules/light/MoonLiveEffect.md`:
- Around line 37-38: Update the system-variable documentation for width, height,
depth, and x, y, z to explicitly state their valid 0..255 range, reflecting the
uint8_t storage and saturation/bypass behavior. Do not present these fields as
unrestricted logical-grid dimensions or light coordinates.
- Around line 60-61: Update the builtin range descriptions in the table for
scale(value, n), sin(angle), and cos(angle) to match their implementation:
document scale as 0..n-1 and the trigonometric results as 1..65535. Leave the
surrounding descriptions unchanged.

In `@docs/moonmodules/light/MoonLiveLayout.md`:
- Around line 14-22: Use valid triple-backtick Markdown fences for both code
examples in docs/moonmodules/light/MoonLiveLayout.md: update the opening and
closing delimiters at lines 14-22 and 33-49, preserving each example’s content.

In `@moondeck/moonlive/emit_xtensa.cpp`:
- Around line 28-29: Update the compileSource invocation in the layout sample to
pass layoutSysVars() instead of modifierSysVars(), while keeping lightBuiltins()
and the existing source, buffer, and size arguments unchanged.

In `@moondeck/run/monitor_esp32.py`:
- Around line 125-131: Update the final serial cleanup around the reconnect flow
in monitor_esp32 so it checks whether ser is not None before calling
ser.close(). Preserve the existing cleanup behavior for an active serial
connection while avoiding AttributeError when interruption occurs during the
ser-is-None reconnect loop.

In `@moonlive/modifiers/shift.mlv`:
- Around line 1-5: Update the shift modifier’s setXYZ call so x + amount cannot
truncate to an in-range uint8_t value; preserve out-of-range coordinates through
the modifier runtime or use its established invalid-coordinate representation,
allowing Layer to drop positions beyond the box instead of wrapping them.

In `@src/core/moonlive/MoonLiveBuiltins.h`:
- Around line 144-147: Update SysVar::add to validate offsets before storing a
variable: accept Arena entries only when their offset is at least kMaxCtrls and
below kArenaBytes, and accept Arg entries only when their offset corresponds to
a defined argument VReg. Return false for invalid values while preserving the
existing capacity and null-name checks.

In `@test/unit/light/unit_MoonLiveScripts.cpp`:
- Around line 116-120: Require successful code generation on supported JIT hosts
by guarding the valid-script assertions with MM_MOONLIVE_HAS_HOST_JIT: in
test/unit/light/unit_MoonLiveScripts.cpp lines 116-120, require r.ok instead of
accepting moonlive::kCodegenFailed; in test/unit/core/unit_moonlive_compiler.cpp
lines 227-233, require ok.ok; and in lines 248-257, require both ok.ok and
seq.ok. Preserve the existing fallback acceptance for hosts without JIT support.

---

Outside diff comments:
In `@docs/MIGRATING.md`:
- Line 55: Update the manual preset migration instructions near the
Layers-to-Effects rename to also change the persisted role value from "layer" to
"effects" in each preset JSON, ensuring migrated presets load with the correct
pad tint.

In `@docs/moonmodules/light/MoonLiveModifier.md`:
- Line 11: Update docs/moonmodules/light/MoonLiveModifier.md:11 to clarify that
the layer invokes the modifier once per physical light/coordinate while the
modifier script itself may contain supported braced for loops. At
docs/moonmodules/light/MoonLiveModifier.md:39, remove for from the unsupported
grammar list; no other syntax documentation needs changing.

In `@src/core/moonlive/MoonLiveIr.h`:
- Around line 87-92: Correct the branch-label capacity around kIrLabels and its
documentation: account for two labels per for construct when determining
supported nesting depth, using localCount if that is the intended limit, or set
kIrLabels to the actual per-program label capacity. Ensure the documented limit
matches the compiler and assembler behavior.

In `@src/light/moonlive/MoonLiveBuiltins_light.h`:
- Around line 24-27: Remove mutable process-global state from both concurrent
built-ins: update mm_light_random16() to use execution-local PRNG state, such as
thread_local or binding-owned context, and update printBudget() to use
binding-owned diagnostic state with synchronized reset and consumption; apply
the changes at src/light/moonlive/MoonLiveBuiltins_light.h lines 24-27 and
111-124.

In `@src/platform/desktop/moonlive_asm_host.h`:
- Around line 59-64: Update the documentation for HostAssembler::call to
describe the three-argument operation as d = fn(a, b, c), while preserving the
existing register-preservation and live-vreg contract details.

---

Duplicate comments:
In `@moondeck/moonlive/emit_xtensa.cpp`:
- Around line 1-20: Remove the direct __XTENSA__ definition and included
platform implementation files from the utility translation unit. Expose the
required lowerToBytes API through the platform target, then link this utility
against that target while retaining only the necessary public declarations and
IR includes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e23e433f-42f4-4962-aea4-ee3714d935bf

📥 Commits

Reviewing files that changed from the base of the PR and between 4806046 and 9e5169a.

⛔ Files ignored due to path filters (1)
  • moondeck/build/erase_flash_esp32.py is excluded by !**/build/**
📒 Files selected for processing (39)
  • docs/MIGRATING.md
  • docs/backlog/backlog-light.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveEffect.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • moondeck/MoonDeck.md
  • moondeck/moonlive/disasm.py
  • moondeck/moonlive/emit_xtensa.cpp
  • moondeck/run/monitor_esp32.py
  • moonlive/effects/lines.mlv
  • moonlive/layouts/grid.mlv
  • moonlive/layouts/reversed-row.mlv
  • moonlive/layouts/ring.mlv
  • moonlive/layouts/two-rows.mlv
  • moonlive/modifiers/mirror.mlv
  • moonlive/modifiers/shift.mlv
  • src/core/math16.h
  • src/core/moonlive/MoonLive.cpp
  • src/core/moonlive/MoonLive.h
  • src/core/moonlive/MoonLiveBuiltins.h
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveCompiler.h
  • src/core/moonlive/MoonLiveIr.h
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveEffect.h
  • src/light/moonlive/MoonLiveLayout.h
  • src/light/moonlive/MoonLiveModifier.h
  • src/platform/desktop/moonlive_asm_host.h
  • src/platform/esp32/moonlive_asm_riscv.h
  • src/platform/esp32/moonlive_asm_xtensa.h
  • test/scenarios/light/scenario_MoonLive_pipeline.json
  • test/unit/core/unit_math16.cpp
  • test/unit/core/unit_moonlive_compiler.cpp
  • test/unit/core/unit_moonlive_fill.cpp
  • test/unit/core/unit_moonlive_ir.cpp
  • test/unit/light/unit_MoonLiveLayout.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp

Comment thread docs/moonmodules/light/MoonLiveEffect.md Outdated
Comment thread docs/moonmodules/light/MoonLiveEffect.md Outdated
Comment thread docs/moonmodules/light/MoonLiveLayout.md Outdated
Comment thread moondeck/moonlive/emit_xtensa.cpp
Comment thread moondeck/run/monitor_esp32.py
Comment thread moonlive/modifiers/shift.mlv Outdated
Comment on lines +1 to +5
// Slide along x. A coordinate outside the box is dropped by the Layer, so the pattern walks off
// the edge rather than wrapping.
uint8_t amount = 4; // @control 0..64

setXYZ(0, x + amount, y, z);

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 | 🏗️ Heavy lift

Preserve out-of-range coordinates instead of wrapping them.

For a 255-wide box, x + amount can exceed 255. setXYZ stores the low byte. For example, x = 254 and amount = 4 becomes 2. The Layer then receives an in-range coordinate and cannot drop it.

Preserve overflow state through the modifier runtime, or define a non-wrapping invalid-coordinate representation.

🤖 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/modifiers/shift.mlv` around lines 1 - 5, Update the shift modifier’s
setXYZ call so x + amount cannot truncate to an in-range uint8_t value; preserve
out-of-range coordinates through the modifier runtime or use its established
invalid-coordinate representation, allowing Layer to drop positions beyond the
box instead of wrapping them.

Comment thread src/core/moonlive/MoonLiveBuiltins.h
Comment thread test/unit/light/unit_MoonLiveScripts.cpp
Three sanitizer jobs went red on push, all at the compile step, all on one file: a test
used std::string without including <string>. Apple's clang supplies it transitively and
GCC does not, so the build was green on every machine here. `build_desktop.py --gcc`
already existed for exactly this and no gate ran it; now one does, in about a second.

Performance: desktop 126 us/tick (7936 fps), esp32 2151 us/tick (464 fps).

Scripts/MoonDeck
- A "GCC build (CI's toolchain)" gate, so a divergence between the two compilers is caught
  where it is cheap. It reproduces the pushed failure verbatim, which is how it was checked.
- The monitor closed its serial port unconditionally on exit, but that handle is None while
  the reconnect loop waits for a rebooting board - the moment Ctrl+C is most likely. Ending
  the session printed an AttributeError instead of the log path.

Core
- SysVarTable::add now refuses an offset the arena cannot hold. Storing one succeeded and
  then wrote nothing every frame, with no error at any point.
- kIrLabels was documented as the nesting depth a script can reach. The counter is
  program-wide and never reused, so it is the total number of loops (8) whatever their
  shape; the diagnostic said "nested too deeply" and now says what it means.

Light domain
- shift.mlv promised a light walks off the edge. A coordinate is a byte, so a large enough
  shift wraps it back to the left edge instead. The comment now says what happens; making
  the promise true needs a way for a script to say "out of bounds", which is backlogged.

Tests
- Two suites accepted "codegen failed" as a pass even on hosts that HAVE an assembler,
  which would have let a real codegen regression through. Strict where a backend exists,
  lenient only where there is none. Still green, so the JIT does emit for all of them.
- <string> added to the file that broke CI, and to one more found by sweeping - green today
  only because some other header happened to pull it in.

Docs/CI
- scale() maps onto 0..n-1 and sin()/cos() return 1..65535; both were documented one wider.
  The scale range is what ring.mlv depends on.
- The modifier page listed `for` as absent from the grammar. It has been there throughout -
  a modifier gets one coordinate per call, which is a reason not to need a loop, not a
  reason the language lacks one.
- System variables are bytes: the page now says 0..255 and what saturation does.
- MIGRATING said nothing else in a preset changes, two lines below documenting that the
  role does.

Reviews
- 🐇 CodeRabbit, 14 findings: 9 fixed (above), 1 partly - shift.mlv's comment corrected and
  the mechanism backlogged, since the proposed fix needs a runtime representation that does
  not exist yet. 4 skipped: random16 and printBudget hold process-global state, but a torn
  read of an LCG returns a different random number and a racy budget decrement moves a debug
  print limit by a few - neither breaks an invariant; emit_xtensa.cpp passes the WIDEST
  system-variable table on purpose, because it disassembles whatever script it is given, and
  restructuring that tool's includes is separate work.

Flash: esp32 1762368, esp32s3-n16r8 1752080, esp32s31 2024656, desktop 1137800.
Tests: 1322 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
docs/backlog/backlog-light.md (1)

291-293: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not defer synchronous print() on the render path.

Lines 291-293 confirm that print(v) writes directly to serial while an effect runs in tick(). The burst cap limits volume, but it does not make blocking I/O nonblocking. Queue preallocated records and drain them during housekeeping before release.

As per path instructions, render/tick code is nonblocking and must not perform blocking I/O.

🤖 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/backlog/backlog-light.md` around lines 291 - 293, Update MoonLive’s
synchronous print path so EFFECT execution in tick() never writes directly to
serial or performs blocking I/O. Queue print records using preallocated storage,
then drain the queue from the existing housekeeping/platform-output path before
release while preserving the current burst budget and burst-spent behavior.

Source: Path instructions

docs/moonmodules/light/MoonLiveModifier.md (1)

13-18: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the Markdown fence around the setXYZ examples.

Line 13 uses ``c and Line 18 uses two backticks. Markdown does not render this example as a fenced C block. Use a three-backtick opener and closer.

Proposed fix
-``c
+```c
...
-``
+```
🤖 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/moonmodules/light/MoonLiveModifier.md` around lines 13 - 18, Correct the
Markdown code fence surrounding the setXYZ examples: update the opening and
closing delimiters to use three backticks, preserving the C language annotation
and all example lines unchanged.
docs/moonmodules/light/MoonLiveLayout.md (1)

55-55: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the documented layout clock match the runtime.

Line 55 says a layout reads elapsed milliseconds through t. The runScript method in src/light/moonlive/MoonLiveLayout.h currently calls engine_.run(..., 0), so layout scripts receive t == 0 for both passes. Either pass one captured elapsed value to both count and placement passes, or document t as fixed at zero. Otherwise, layouts that use t cannot animate as documented.

As per path instructions, 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/moonmodules/light/MoonLiveLayout.md` at line 55, The documentation
claims MoonLive layouts receive elapsed milliseconds via t, but runScript
currently supplies 0. Update the MoonLiveLayout documentation to describe t as
fixed at zero, unless the runtime is changed to capture and pass one elapsed
value to both runScript count and placement passes; ensure the documented
behavior matches the implemented system.

Source: Path instructions

src/core/moonlive/MoonLiveCompiler.cpp (2)

457-494: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject trailing tokens in the loop step.

parseExpr() accepts a valid prefix and the parser restores the outer lexer without checking that the next token is Tok::RParen. A source such as for (i = 0; i < 2; i = i + 1 junk) { ... } compiles as if junk were absent.

After parsing stepSrc, require the next token to be the closing parenthesis before emitting IrOp::Mov.

🤖 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 `@src/core/moonlive/MoonLiveCompiler.cpp` around lines 457 - 494, The re-lexed
loop-step parser accepts trailing tokens because it does not verify that parsing
ended at Tok::RParen. In the stepLex block of the for-loop compilation flow,
validate the next token after parseExpr() is Tok::RParen before emitting
IrOp::Mov; reject any other token and avoid emitting the step assignment.

479-498: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Preserve loop values across host calls.

A loop body can call a builtin such as addLight(i, 0, 0). The counter and limit registers remain live after that call and are used by Lines 497-498. HostAssembler::call states that caller-saved registers other than R0-R2 must not be live across a call. The loop counter and bound therefore can be clobbered before the branch executes.

Define a cross-call liveness contract for loop values. Spill and restore live loop registers, or make every backend preserve the required virtual registers across IrOp::Call.

🤖 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 `@src/core/moonlive/MoonLiveCompiler.cpp` around lines 479 - 498, Preserve the
loop’s live counter and limit values across builtin calls before the post-body
branches in the for-loop compilation path. Update the loop handling around
parseStatement(), IrOp::Call generation, or HostAssembler::call to establish a
cross-call liveness contract, ensuring counter and limit are restored or
retained before the BranchGe and BranchNe instructions.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@moondeck/event/_gates.py`:
- Line 145: Update the command construction in the event gate to use iterable
unpacking for UV instead of list concatenation, preserving the existing
build_desktop.py, --gcc, and --tests arguments while resolving Ruff RUF005.

---

Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Around line 291-293: Update MoonLive’s synchronous print path so EFFECT
execution in tick() never writes directly to serial or performs blocking I/O.
Queue print records using preallocated storage, then drain the queue from the
existing housekeeping/platform-output path before release while preserving the
current burst budget and burst-spent behavior.

In `@docs/moonmodules/light/MoonLiveLayout.md`:
- Line 55: The documentation claims MoonLive layouts receive elapsed
milliseconds via t, but runScript currently supplies 0. Update the
MoonLiveLayout documentation to describe t as fixed at zero, unless the runtime
is changed to capture and pass one elapsed value to both runScript count and
placement passes; ensure the documented behavior matches the implemented system.

In `@docs/moonmodules/light/MoonLiveModifier.md`:
- Around line 13-18: Correct the Markdown code fence surrounding the setXYZ
examples: update the opening and closing delimiters to use three backticks,
preserving the C language annotation and all example lines unchanged.

In `@src/core/moonlive/MoonLiveCompiler.cpp`:
- Around line 457-494: The re-lexed loop-step parser accepts trailing tokens
because it does not verify that parsing ended at Tok::RParen. In the stepLex
block of the for-loop compilation flow, validate the next token after
parseExpr() is Tok::RParen before emitting IrOp::Mov; reject any other token and
avoid emitting the step assignment.
- Around line 479-498: Preserve the loop’s live counter and limit values across
builtin calls before the post-body branches in the for-loop compilation path.
Update the loop handling around parseStatement(), IrOp::Call generation, or
HostAssembler::call to establish a cross-call liveness contract, ensuring
counter and limit are restored or retained before the BranchGe and BranchNe
instructions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f3f72725-afee-4259-b840-396454c22b2b

📥 Commits

Reviewing files that changed from the base of the PR and between 9e5169a and 746c4e1.

📒 Files selected for processing (19)
  • docs/MIGRATING.md
  • docs/backlog/backlog-light.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveEffect.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • moondeck/event/_gates.py
  • moondeck/moonlive/emit_xtensa.cpp
  • moondeck/run/monitor_esp32.py
  • moonlive/modifiers/shift.mlv
  • src/core/moonlive/MoonLiveBuiltins.h
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/core/moonlive/MoonLiveIr.h
  • src/platform/desktop/moonlive_asm_host.h
  • test/scenarios/light/scenario_GridBlacks_blackpixel.json
  • test/unit/core/unit_FilesystemModule_persistence.cpp
  • test/unit/core/unit_moonlive_compiler.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp

Comment thread moondeck/event/_gates.py
# -Werror those are hard failures discovered only after a push. Compiling with the real
# thing answers it here — see build_desktop.py --gcc for the four cycles that cost once.
Gate("GCC build (CI's toolchain)",
UV + ["moondeck/build/build_desktop.py", "--gcc", "--tests"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use iterable unpacking for UV.

Ruff RUF005 flags UV + [...] on Line 145. Replace it with [*UV, "moondeck/build/build_desktop.py", "--gcc", "--tests"] to avoid list concatenation and clear the lint warning.

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 145-145: Consider iterable unpacking instead of concatenation

Replace with iterable unpacking

(RUF005)

🤖 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 `@moondeck/event/_gates.py` at line 145, Update the command construction in the
event gate to use iterable unpacking for UV instead of list concatenation,
preserving the existing build_desktop.py, --gcc, and --tests arguments while
resolving Ruff RUF005.

Source: Linters/SAST tools

A for loop emitted code that tested and advanced its own counter whatever the condition
and step clauses actually named, so `for (i = 0; j < 3; i = i + 1)` compiled and ran as
if it said `i` - and trailing junk after the step was dropped silently. Both are the worst
kind of bug for a live script: a wrong fixture with no error anywhere to explain it.

Performance: desktop 122 us/tick (8196 fps), esp32 2151 us/tick (464 fps).

Core
- Both for clauses now verify they name the loop variable, which is what their error
  strings already claimed. The realistic case is a nested loop whose inner condition tests
  the outer variable.
- The step is re-lexed from source that was skipped over, and an expression parser stops at
  the first token it cannot use, so anything after it vanished. It must now end at the ')'.
- MoonLiveBuiltins_light.h used kArg3 without including the header that defines it. It
  compiled only because every consumer includes MoonLiveIr.h first; standalone it failed.
  Swept every non-ESP32 header for the same defect - the two that remain (Layer.h,
  EffectBase.h) fail from a pre-existing mutual include, left alone.

Light domain
- MoonLiveLayout's compiled_ was sized by a literal where the other two bindings use
  sizeof(source_). It matches today; raising kMaxScriptBytes would have made this the one
  binding whose rebuild-comparison truncates and rebuilds every frame.

Scripts/MoonDeck
- The Layers->Effects rename never reached MoonDeck. run_live_scenario.py's snapshot set
  still named "Layers", so a live scenario could not capture or restore the Effects subtree
  - it would leave a bench board with a destroyed config after a clear_children. Five files.
- A test asserted codegen success without gating on MM_MOONLIVE_HAS_HOST_JIT, so it failed
  every CI sanitizer job: x86_64 has no MoonLive backend. It pins register reuse, which is
  front-end behaviour, but proving it needs emitted code. Swept the test tree - it was the
  only unguarded one of sixteen.

Tests
- The for-clause checks and the trailing-token rejection are pinned, including a sequential
  reuse case, since a name legitimately repeats once a loop closes.
- A loop counter is now shown to survive a call in its body by running the loop and counting
  the lights, rather than by arguing about which registers are caller-saved.

Docs/CI
- The host assembler said only R0..R2 may be live across a call; it saves the whole vreg
  pool, which is why loop counters survive. The comment is what made a reviewer read a bug
  into working code. Its kArg3 note also still said the grammar has no `t`.
- A layout's `t` is always 0: the script runs twice per rebuild and must agree with itself,
  so it gets a fixed clock. The page claimed it received elapsed milliseconds.
- MoonLiveEffect.md described the grammar as a single call statement (`for` has been there
  throughout) and claimed three builtins where thirteen are registered.
- moonlive/README.md documented a drivers/ folder git cannot ship - empty, so untracked.

Reviews
- 👾 Reviewer over the branch diff, 10 findings: 8 fixed (above), 1 already done, 1 skipped
  - collapsing the three bindings' defineControls into a mixin, which the Reviewer flagged
  as a judgment call and which coding-standards tolerates for header-only light modules.
  It found no fault with the system-variable construct, the arena split, the heap-overrun
  fixes, or the hot path, and fuzzed the compiler under ASan+UBSan without a crash.
- 🐇 CodeRabbit, 7 findings: 3 fixed, 4 skipped - two were already fixed earlier this
  session; RUF005 is not an enabled rule here (ruff passes, and all seven gates share the
  idiom); the print-queue request is already backlogged with that exact design. Its claim
  that a loop counter is clobbered across a call is wrong - the test above demonstrates it.

Flash: esp32 1762368, esp32s3-n16r8 1752384, esp32s31 2024656, desktop 1137800.
Tests: 1324 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/light/moonlive/MoonLiveLayout.h (1)

110-120: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Serialize compilation and execution of the shared engine.

lightCount() and forEachCoord() both call compile(). The runtime can invoke layout walks from the HTTP task and the render task concurrently. A first compile or source update then races on source_, compiled_, and engine_. One task can replace generated code while another task executes it.

Compile on a housekeeping path and publish an immutable program with a safe lifetime. Do not mutate engine_ from concurrent layout walks.

🤖 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 `@src/light/moonlive/MoonLiveLayout.h` around lines 110 - 120, Refactor
MoonLiveLayout::compile and the lightCount/forEachCoord execution path so layout
walks never concurrently mutate or execute the shared engine_. Perform
compilation during a serialized housekeeping/update path, then publish an
immutable compiled program with lifetime protection for readers; have concurrent
walks use that published program instead of source_, compiled_, or engine_
directly. Preserve status and dynamic-byte updates during compilation while
eliminating races on all shared compilation state.
src/core/moonlive/MoonLiveCompiler.cpp (1)

456-458: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Bound loop work at runtime.

A loop bound can be dynamic. Four nested loops with controls set to 255 execute more than four billion body iterations, even when every step advances. A MoonLiveEffect runs this code from tick(), so a valid script can block rendering for an unbounded time.

Add a shared generated iteration budget that exits the script when exhausted. Apply it to every loop level.

🤖 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 `@src/core/moonlive/MoonLiveCompiler.cpp` around lines 456 - 458, In
MoonLiveCompiler’s loop code generation around the limit expression, add one
shared runtime iteration budget for the entire script execution, initialize it
per run, and decrement/check it on every iteration of every loop level. Exit the
script cleanly when the budget is exhausted, ensuring nested and dynamically
bounded loops cannot run unbounded work.
src/light/moonlive/MoonLiveBuiltins_light.h (2)

97-128: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make the print budget per execution context.

printBudget() returns one process-global mutable counter. resetPrintBudget() writes it during compilation or preparation while mm_light_print() decrements it during execution. The supplied src/light/moonlive/MoonLiveLayout.h context allows those paths to run on different tasks. This creates a data race and allows one module to reset another module's budget. Store the budget with the module or run context, or use synchronized thread-local state with an explicit contract.

🤖 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 `@src/light/moonlive/MoonLiveBuiltins_light.h` around lines 97 - 128, Make the
print budget execution-context-local instead of process-global: update
printBudget(), resetPrintBudget(), and mm_light_print() to access the owning
module or MoonLiveLayout run context, or an explicitly synchronized thread-local
context. Ensure each context can reset and decrement only its own budget without
data races or affecting other modules.

25-29: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not share RNG state across concurrent script runs.

mm_light_random16 mutates one function-local static s without synchronization. The supplied src/light/moonlive/MoonLiveLayout.h context allows scripted layout runs from HTTP and render tasks, so simultaneous scripts can race on s. Keep RNG state per engine or run, or use documented thread-local state if cross-thread sequence sharing is not required.

🤖 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 `@src/light/moonlive/MoonLiveBuiltins_light.h` around lines 25 - 29, The
mm_light_random16 function currently shares mutable static RNG state across
concurrent script runs. Replace the function-local static s with RNG state
scoped to the engine or individual run, or use documented thread-local storage
when sequence sharing across threads is unnecessary, while preserving the
existing random16 calculation and zero-input behavior.
♻️ Duplicate comments (1)
src/light/moonlive/MoonLiveBuiltins_light.h (1)

97-128: 🩺 Stability & Availability | 🟠 Major

Remove synchronous output from script execution.

This remains unresolved from the previous review. mm_light_print calls std::printf, and MoonLiveEffect::tick() can execute it through engine_.run(). The call can block the render path and violates the nonblocking hot-path contract. Queue bounded diagnostic records and drain them from a housekeeping path.

As per path instructions: render/tick code must be nonblocking and must not allocate, block, or perform slow I/O.

🤖 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 `@src/light/moonlive/MoonLiveBuiltins_light.h` around lines 97 - 128, Replace
synchronous std::printf calls in mm_light_print with bounded, nonblocking
diagnostic record queuing, preserving the print budget and returned value. Add
or reuse a housekeeping-path drain to perform serial output outside
MoonLiveEffect::tick() and engine_.run(), ensuring the render path performs no
allocation, blocking, or slow I/O.

Source: Path instructions

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/moonmodules/light/MoonLiveEffect.md`:
- Line 80: Update the nearby MoonLive and MoonLiveEffect API bullets to document
compile(source, table, sysvars) and state that bindings provide effectSysVars(),
modifierSysVars(), or layoutSysVars(). Keep the new system-variable section
consistent with this current API description.

In `@test/unit/core/unit_moonlive_fill.cpp`:
- Around line 150-168: Add a host-JIT test alongside “a loop counter survives a
call in the body” that passes the supplied t value into random16, then uses t in
a later setRGB argument after the generated call. Run the program with a known t
value and assert the output contains that value, ensuring t survives across the
call.

---

Outside diff comments:
In `@src/core/moonlive/MoonLiveCompiler.cpp`:
- Around line 456-458: In MoonLiveCompiler’s loop code generation around the
limit expression, add one shared runtime iteration budget for the entire script
execution, initialize it per run, and decrement/check it on every iteration of
every loop level. Exit the script cleanly when the budget is exhausted, ensuring
nested and dynamically bounded loops cannot run unbounded work.

In `@src/light/moonlive/MoonLiveBuiltins_light.h`:
- Around line 97-128: Make the print budget execution-context-local instead of
process-global: update printBudget(), resetPrintBudget(), and mm_light_print()
to access the owning module or MoonLiveLayout run context, or an explicitly
synchronized thread-local context. Ensure each context can reset and decrement
only its own budget without data races or affecting other modules.
- Around line 25-29: The mm_light_random16 function currently shares mutable
static RNG state across concurrent script runs. Replace the function-local
static s with RNG state scoped to the engine or individual run, or use
documented thread-local storage when sequence sharing across threads is
unnecessary, while preserving the existing random16 calculation and zero-input
behavior.

In `@src/light/moonlive/MoonLiveLayout.h`:
- Around line 110-120: Refactor MoonLiveLayout::compile and the
lightCount/forEachCoord execution path so layout walks never concurrently mutate
or execute the shared engine_. Perform compilation during a serialized
housekeeping/update path, then publish an immutable compiled program with
lifetime protection for readers; have concurrent walks use that published
program instead of source_, compiled_, or engine_ directly. Preserve status and
dynamic-byte updates during compilation while eliminating races on all shared
compilation state.

---

Duplicate comments:
In `@src/light/moonlive/MoonLiveBuiltins_light.h`:
- Around line 97-128: Replace synchronous std::printf calls in mm_light_print
with bounded, nonblocking diagnostic record queuing, preserving the print budget
and returned value. Add or reuse a housekeeping-path drain to perform serial
output outside MoonLiveEffect::tick() and engine_.run(), ensuring the render
path performs no allocation, blocking, or slow I/O.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c87af99a-3632-4e6a-b8d0-8adea569b6b6

📥 Commits

Reviewing files that changed from the base of the PR and between 746c4e1 and 41eb2c0.

📒 Files selected for processing (19)
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/MoonLiveEffect.md
  • docs/moonmodules/light/MoonLiveLayout.md
  • docs/moonmodules/light/MoonLiveModifier.md
  • moondeck/check/check_specs.py
  • moondeck/docs/screenshot_modules.py
  • moondeck/docs/update_module_docs.py
  • moondeck/scenario/run_live_scenario.py
  • moonlive/README.md
  • src/core/moonlive/MoonLiveCompiler.cpp
  • src/light/drivers/Drivers.h
  • src/light/moonlive/MoonLiveBuiltins_light.h
  • src/light/moonlive/MoonLiveLayout.h
  • src/platform/desktop/moonlive_asm_host.cpp
  • src/platform/desktop/moonlive_asm_host.h
  • test/unit/core/unit_moonlive_compiler.cpp
  • test/unit/core/unit_moonlive_fill.cpp
  • test/unit/light/unit_MoonLiveScripts.cpp
💤 Files with no reviewable changes (1)
  • moonlive/README.md

- **`MoonLiveBuiltins`** (`src/core/moonlive/MoonLiveBuiltins.h`) — the **neutral host-binding seam**: a `BuiltinTable` of `{name → descriptor}`, where a descriptor is either `Call` (a host C function pointer — a pure helper like `random16`) or `Inline` (a neutral opcode tag the backend emits inline — the hot-path buffer writers, no per-pixel call). The core owns no function names; it resolves a call against whatever the host registered.
- **`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 `setRGB`/`fill` (Inline, lowering to RGB stores) and `random16` (Call). A different host (display, sensor) writes its own table; the core is unchanged.
- **`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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the API bullets for system variables.

The new system-variable section is correct, but the nearby MoonLive and MoonLiveEffect bullets still describe compile(source, table) and say that MoonLiveEffect passes only the builtin table. src/core/moonlive/MoonLive.h requires compile(source, table, sysvars), and the bindings pass effectSysVars(), modifierSysVars(), or layoutSysVars(). Update those bullets so this specification matches the current API.

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/moonmodules/light/MoonLiveEffect.md` at line 80, Update the nearby
MoonLive and MoonLiveEffect API bullets to document compile(source, table,
sysvars) and state that bindings provide effectSysVars(), modifierSysVars(), or
layoutSysVars(). Keep the new system-variable section consistent with this
current API description.

Source: Coding guidelines

Comment on lines +150 to +168
// A loop counter and its limit are live ACROSS a call whenever the body calls anything — which is
// most real effects. The assembler's contract says it preserves what has to survive; this runs the
// loop and counts, so a backend that clobbered either would show up as a short or runaway loop
// rather than as an argument about which registers are caller-saved.
#if MM_MOONLIVE_HAS_HOST_JIT
TEST_CASE("a loop counter survives a call in the body") {
moonlive::MoonLive eng;
// random16 is a Call; `i` and the limit `w` are both live around it.
REQUIRE(eng.compile("uint8_t w = 8;\nfor (i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }",
kCtrlTable, kSys));
uint8_t buf[8 * 3] = {};
eng.run(buf, 8, 3, 0);
int written = 0;
for (int i = 0; i < 8; i++)
if (buf[i * 3] || buf[i * 3 + 1] || buf[i * 3 + 2]) written++;
CHECK(written == 8); // every iteration ran: the counter was not clobbered by the call
eng.free();
}
#endif

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

Exercise t across the generated call.

This test keeps i and w live across random16, but it never reads t after that call. It cannot detect a regression in the x3-backed t preservation changed in src/platform/desktop/moonlive_asm_host.cpp. Add a case that passes t to random16, uses t in a later argument, and asserts that the output contains the supplied t.

🤖 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 `@test/unit/core/unit_moonlive_fill.cpp` around lines 150 - 168, Add a host-JIT
test alongside “a loop counter survives a call in the body” that passes the
supplied t value into random16, then uses t in a later setRGB argument after the
generated call. Run the program with a known t value and assert the output
contains that value, ensuring t survives across the call.

@ewowi ewowi changed the title MoonLive on power functions (and: Layers → Effects) MoonLive: scripted effects, modifiers and layouts, compiled on the device Aug 10, 2026
@MoonModules
MoonModules merged commit 7aa1d02 into main Aug 10, 2026
8 checks passed
@MoonModules
MoonModules deleted the next-iteration branch August 10, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants