diff --git a/README.md b/README.md index 3f85e489..6245acac 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque 🧊 **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). πŸ”„ **No reboot to apply a configuration change**: edit a pin map, a strand length, an output protocol, or the mic on a running device and it takes effect on the very next frame, with no init-at-boot step, no restart. Where most LED-controller firmware needs a reboot for a pin or protocol change, projectMM applies it live. (Flashing new *firmware* over OTA still needs the usual power cycle, since that's a binary swap rather than a config change.) diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 473d2194..624ae916 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -20,6 +20,43 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### MoonLive: a script can no longer declare a name the engine supplies (2026-08-10) + +`t` (elapsed milliseconds), `width`/`height`/`depth` (the logical grid) and `x`/`y`/`z` (the light a modifier is transforming) are now **system variables** the engine supplies, so a script cannot declare one. Previously each binding faked them by prepending hidden declarations to the script, which meant an effect could declare its own `width` and quietly disagree with the layer it was drawing into. + +Each module supplies only the names it writes, so what is reserved depends on the module: a layout gets `t` alone, an effect adds the grid, a modifier adds the coordinate. **`x` and `y` remain usable as loop counters in a layout or an effect.** + +**Action: *update a file*, for scripted layouts only.** + +A **layout** is the one script that legitimately used those names for its own controls: it *defines* where lights are, so it has no grid to be handed. A persisted layout script declaring `uint8_t width = 16;` now fails to compile with `name is a system variable`, and the layout places no lights β€” the fixture is **dark** until the script is edited. + +| What | Why | What to do | +|---|---|---| +| A scripted layout declaring `width`/`height` | The name is what the layout is defining, so the declaration is a compile error and no lights are placed | Edit the script's `source` control, renaming its own controls (the shipped `grid.mlv` uses `cols`/`rows`) | +| A scripted **modifier** using `x`, `y` or `z` as a loop variable | A modifier IS handed a coordinate under those names, so they cannot also be counters there | Rename the loop variable to something the modifier is not handed (`i`, `n`) | + +Effects and modifiers need no change: they were already being handed these values, just through a preamble instead of by name. The error names the clash, and the module shows it on its card, so a broken script says why rather than failing silently. + +### The `Layers` container is renamed to `Effects` (2026-08-08) + +The three top-level light containers are now **Layouts, Effects, Drivers** β€” L.E.D. The old name sat one character from its own child (`Layers` holding `Layer`s) and read as a near-twin of `Layouts`, which is the pair a newcomer actually has to tell apart. The tree is unchanged in shape: `Effects` β†’ `Layer`s β†’ effects and modifiers. + +**Action: *re-add a module* and *re-save presets*.** + +The type name is the persisted filename and the preset capture key, so two things do not survive the update: + +| What | Why | What to do | +|---|---|---| +| 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 name 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 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 inside each `/.config/presets/*.json` both `"Layers"` β†’ `"Effects"` (the captured container) and `"layer"` β†’ `"effects"` (the role, which is what tints the pad). Nothing else in either file changes. + +The child `Layer` keeps its name, as does everything under it. + + ### The `peripheral` options are renamed to name the peripheral, not the bus protocol (2026-07-30) The `peripheral` dropdown no longer says `i80` / `MoonI80`. "i80" is the Intel 8080 bus shape `esp_lcd` speaks β€” it is not a peripheral any ESP32 datasheet lists, and it matched nothing a user could look up: on the classic ESP32 that backend **is the I2S peripheral**, on the S3/P4/S31 it is the **LCD** peripheral. The new labels name the silicon block plus who drives it, which is the actual choice being made. diff --git a/docs/architecture.md b/docs/architecture.md index 164c8265..69b5d031 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ Coding conventions live in [coding-standards.md](coding-standards.md); how to bu - [The pipeline](#the-pipeline) - [3D from the start](#3d-from-the-start) - [Layouts and Layout](#layouts-and-layout) - - [Layers and Layer](#layers-and-layer) + - [Effects and Layer](#effects-and-layer) - [Effects](#effects) - [Dimensionality](#dimensionality) - [Robustness rules](#robustness-rules) @@ -284,7 +284,7 @@ A **service** is a MoonModule (role `ModuleRole::Service`) that bridges to the o The defining line is the **data relationship, not the connector**: *does the module consume the light output buffer?* If yes it's a **driver** (ArtNet, DMX, SPI-LED all consume the buffer, differing only in transport; a DMX sender uses a UART/RS-485 transport but is a driver because it sends the rendered buffer). If no, it's a **service**. -Services are **user-add/deletable children of the `Services` container** β€” the core-domain twin of the light pipeline's `Layers`/`Drivers`: a top-level container holding user-added children of one role. The firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (`Services` declares `acceptsChildRoles("service")`). Fixed device infrastructure (identity, network, the inspection tools Tasks/I2cScan) lives under **System** instead, wired by code, not user-added β€” that is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one `Service` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/services.md` (enforced by `check_specs.py`). Most poll in `tick20ms`/`tick1s`; the exception is a service whose data an effect consumes *every frame*: [AudioService](moonmodules/core/moxygen/AudioService.md) reads + analyses its IΒ²S microphone in `tick()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation. +Services are **user-add/deletable children of the `Services` container** β€” the core-domain twin of the light pipeline's `Effects`/`Drivers`: a top-level container holding user-added children of one role. The firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (`Services` declares `acceptsChildRoles("service")`). Fixed device infrastructure (identity, network, the inspection tools Tasks/I2cScan) lives under **System** instead, wired by code, not user-added β€” that is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one `Service` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/services.md` (enforced by `check_specs.py`). Most poll in `tick20ms`/`tick1s`; the exception is a service whose data an effect consumes *every frame*: [AudioService](moonmodules/core/moxygen/AudioService.md) reads + analyses its IΒ²S microphone in `tick()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation. **An effect reads a service's data** via the shared-struct pull pattern from [Β§ Data exchange](#data-exchange-between-modules), no new mechanism: the service owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioService produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects.md) consume. It reaches the frame through a static `AudioService::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `release()`, so add/remove in any order returns either the live frame or a static silent one, never null. A service that only *displays* its readings (the gyro today) skips the consumer side entirely. @@ -316,11 +316,11 @@ The light domain is everything specific to driving lights. **Light** here means Modules in the light pipeline can be added, replaced, or removed dynamically at runtime. ```text - Layouts (shared by every Layer in Layers) + Layouts (shared by every Layer in Effects) β”œβ”€β”€ GridLayout ──→ coordinate iterator └── WheelLayout ──→ coordinate iterator β”‚ - Layers + Effects β”Œβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό β–Ό Layer A Layer B Layer C @@ -339,12 +339,12 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at **Data flow.** The pipeline instantiates both core data-exchange shapes (see [Β§ Data exchange between modules](#data-exchange-between-modules)): -- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::prepare()` re-resolves the active `Layer` (`Layers::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild β€” which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference. +- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::prepare()` re-resolves the active `Layer` (`Effects::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild β€” which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference. - *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md). **Graceful degradation under transport backpressure.** The preview is the transport-side sibling of the memory-side [Β§ Degradation cascade](#degradation-cascade): when the browser can't keep up with a full-resolution frame (128Β² = ~49 KB), the producer sheds quality rather than stall the loop, in video-streaming order, frame rate then resolution. The frame streams from the driver buffer with no intermediate copy, a resumable memory-adaptive chunk per tick, and the next frame starts only once the previous drained, so the effective frame rate self-limits to what the link sustains. Only when a single frame can't drain promptly does it downsample via a spatial lattice (the adaptive-bitrate idea behind HLS/DASH, on a binary WebSocket). Each delivered frame is whole (a WebSocket message is atomic), the render loop is charged a bounded slice per tick, and a client blocked past the spin budget is closed and reconnects (a blip, not a freeze). The mechanism is payload-agnostic and lives in [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md) + `HttpServerModule`, so other bulky streams can ride the same transport. -**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. +**Naming convention.** Capital `Layouts`, `Effects`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. ## 3D from the start @@ -369,13 +369,13 @@ Positions are computed algorithmically, not stored. Grid is the most commonly us Multiple layouts can live in one Layouts container. Each layout describes one light type: the model is one light type per layout (LED strips, or par lights), not mixed in a single Layouts. -## Layers and Layer +## Effects and Layer -**Layers** (a MoonModule) is the top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. +**Effects** (a MoonModule) is the top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. **Multi-layer composition.** The container composes more than one Layer's buffer into the shared output: each enabled Layer renders into its own buffer, and the Drivers container's blend+map step composites them in container order (bottomβ†’top) into the physical buffer (which is why that buffer is a *blend* buffer in [Β§ Memory strategy](#memory-strategy)). Each Layer carries a `blendMode` (alpha-over or additive) and an `opacity` β€” inert parameters the Layer never acts on; Drivers reads them and the container child order, and blends bottomβ†’top. The bottom layer clears + overwrites the output; each layer above blends onto the accumulated frame per its mode and opacity. With a single enabled Layer this is the degenerate case: a thin pass-through that hands the driver the Layer's buffer directly (no composite), byte-for-byte the single-layer pipeline. The blend math is integer-only per the hot-path rule (8-bit alpha-over `(srcΒ·Ξ± + dstΒ·(255βˆ’Ξ±))/255`, additive sum-with-clamp); cost scales with the enabled-layer count. -A **Layer** (a MoonModule, child of Layers) owns: +A **Layer** (a MoonModule, child of Effects) owns: - A **buffer**: the light data effects write into (logical space). - A **mapping LUT**: built by the layer from the shared Layouts and the layer's static modifiers. @@ -386,7 +386,7 @@ A layer can have **multiple effects**. Each effect writes to the buffer sequenti A layer applies **all its enabled modifiers as a chain** during the mapping build (`Layer::rebuildLUT`): each modifier is a coordinate fold, and they compose in child order (Mβ‚βˆ˜Mβ‚‚βˆ˜β€¦). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). The fold contract (the three hooks, the physicalβ†’logical build, the live pass) is documented in [ModifierBase](moonmodules/light/moxygen/ModifierBase.md). -Each layer references the shared Layouts. The layer builds its mapping by walking the Layouts container's **physical** coordinates and folding each through the static modifier chain to its logical cell β€” N physical lights folding onto one logical cell is the fan-out (a Multiply kaleidoscope), so the build never produces a fan-out overflow. Different layers in Layers can have different modifiers, producing different mappings from the same Layouts. +Each layer references the shared Layouts. The layer builds its mapping by walking the Layouts container's **physical** coordinates and folding each through the static modifier chain to its logical cell β€” N physical lights folding onto one logical cell is the fan-out (a Multiply kaleidoscope), so the build never produces a fan-out overflow. Different layers in Effects can have different modifiers, producing different mappings from the same Layouts. ## Effects @@ -470,9 +470,13 @@ The engine is a **domain-neutral core** with one narrow seam, structured as thre A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `prepare()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark β€” robust to any input. The module contract is [MoonLiveEffect](moonmodules/light/MoonLiveEffect.md). +**A scripted module differs from a compiled one in one thing only: where its behaviour comes from.** Everything else is the same mechanism β€” the same base class, the same `prepare()`/`release()` lifecycle, the same controls, the same status and memory reporting, the same container contract. A `MoonLiveLayout` is a `LayoutBase` that answers `lightCount()` and `forEachCoord()` like any other; it just answers them by running compiled machine code instead of arithmetic over its members. When a scripted binding needs a mechanism its compiled sibling does not, that is a finding: either the mechanism belongs in the base for everyone, or the divergence needs its reason stated where it is introduced. A binding that drifts into its own lifecycle stops being a module and becomes a second system to maintain. + +The one place this is not yet clean: `applyState()` prepares parent-before-child, so a container asks its children for their extent before those children have prepared. A compiled layout computes its count from its members and does not notice; a scripted one has nothing to answer with until it compiles, so it compiles on demand from a `const` method β€” the `const_cast` and `mutable` members in `MoonLiveLayout` exist for that and for nothing else. Removing them means giving core a way for children to prepare before a container aggregates them, which is a lifecycle change for every module. + ## Modifiers -A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface: the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). A layer applies **all** its enabled modifiers as a chain, in child order β€” each a coordinate fold composed into one mapping (see [Β§ Layers and Layer](#layers-and-layer)). +A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface: the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). A layer applies **all** its enabled modifiers as a chain, in child order β€” each a coordinate fold composed into one mapping (see [Β§ Effects and Layer](#effects-and-layer)). A modifier is a coordinate transform, applied in one of two ways (the fold contract is in [ModifierBase](moonmodules/light/moxygen/ModifierBase.md)): @@ -600,7 +604,7 @@ The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()`: both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override. -The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `service`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md). +The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Effects / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `service`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md). ## Tag emoji legend diff --git a/docs/assets/light/Layers.png b/docs/assets/light/Effects.png similarity index 100% rename from docs/assets/light/Layers.png rename to docs/assets/light/Effects.png diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index de11e598..d2ea1f0c 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -284,4 +284,12 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on **Why it waits.** It is fourteen effects' worth of change across audio-reactive and simulation families, each needing its own judgement about whether to clear, fade, or seed differently β€” not a mechanical sweep. `unit_Effects_gridsweep.cpp` already measures it (`afterFirst`) and asserts only the settled frame, so the number is visible without blocking. +- **A scripted modifier needs a way to drop a light** (2026-08-10). A coordinate slot is a byte, so a script that computes past 255 wraps: `shift.mlv` with a large `amount` lands lights back at the left edge instead of walking them off it. The Layer already drops an out-of-bounds position, but a script has no way to SAY out-of-bounds β€” every value it can write is a valid coordinate. Needs a sentinel the binding recognises (or a wider coordinate slot), at which point the "walks off the edge" behaviour a scroll modifier wants becomes expressible. + +- **A scripted modifier that reshapes the grid** (2026-08-10). `ModifierBase::modifyLogicalSize` lets a modifier change the logical `width`/`height`/`depth` β€” a Multiply kaleidoscope grows the grid, a crop shrinks it β€” and a compiled modifier uses it. A SCRIPTED one cannot: system variables are read-only, so `MoonLiveModifier` writes the box in and never reads it back. Needs a writable system variable β€” the binding reads the slots after the script returns and reports the result through `modifyLogicalSize` β€” which is a new `SysVarKind` (or a mutable flag on `SysVar`) plus the read-back, not a new builtin. Until then a scripted modifier can fold coordinates but not resize the grid they live in. + +- **Drain MoonLive's `print()` through a queue** (2026-08-09). `print(v)` writes to serial directly, and an EFFECT script runs on the render tick β€” so a print inside one blocks the frame for as long as the UART takes. The burst cap bounds it (a handful of writes per compile, then a compare and a return), but bounded is not free, and `tick()` is annotated `MM_NONBLOCKING`. + + **What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for. + (The shared lane-driver scaffolding extraction β€” when a 3rd parallel backend lands β€” is tracked separately under [Β§ Extract shared lane-driver scaffolding](#extract-shared-lane-driver-scaffolding-when-the-3rd-parallel-backend-lands-deferred) above.) diff --git a/docs/backlog/power-functions-analysis-top-down.md b/docs/backlog/power-functions-analysis-top-down.md index f0723d50..93a86680 100644 --- a/docs/backlog/power-functions-analysis-top-down.md +++ b/docs/backlog/power-functions-analysis-top-down.md @@ -245,7 +245,7 @@ What they do *not* remove is the conceptual one: an effect is 3D when its idea i ## 6d. Live performance ("DeeJaying") ❓ *(an argument that it is reachable, not a built capability)* -A stretch goal worth recording because the infrastructure is largely built: **playing effects live from the control surface β€” pads, faders, encoders β€” with no code changes.** What already exists: control changes reach a running module without a rebuild (`MoonModule::onControlChanged`), the surface routes faders and encoders through `Scheduler::setControl` (the same domain-neutral primitive IR and MQTT use), Layers composite with blend modes and opacity, and presets snapshot and restore whole subtrees. +A stretch goal worth recording because the infrastructure is largely built: **playing effects live from the control surface β€” pads, faders, encoders β€” with no code changes.** What already exists: control changes reach a running module without a rebuild (`MoonModule::onControlChanged`), the surface routes faders and encoders through `Scheduler::setControl` (the same domain-neutral primitive IR and MQTT use), Effects composite with blend modes and opacity, and presets snapshot and restore whole subtrees. Power functions sharpen this in a specific way: **the more of an effect's mechanics live in shared, control-driven primitives, the more of it is playable rather than fixed.** A hand-rolled accumulator is private state a surface cannot reach; a `BeatPhase` fed from a control is a tempo a performer can ride. The same holds for `particles` (gravity, drag, emission as live parameters) and the field family (warp amount, octaves). diff --git a/docs/backlog/system-modules.md b/docs/backlog/system-modules.md index e0fbd85b..42f5db19 100644 --- a/docs/backlog/system-modules.md +++ b/docs/backlog/system-modules.md @@ -62,7 +62,7 @@ The split maps onto one rule, matching how OS system managers behave (Task Manag - **Everything under `System` is FIXED** β€” always present, **no add/delete**, wired-by-code. That's System's own vitals **and** the System Modules (Tasks, I2cScan; Memory, Pins later) **and** the always-there infrastructure (Network, Firmware, Improv). You don't delete a System Module any more than you delete Task Manager. - **Everything under `Services` is USER-MANAGED** β€” add/delete/replace, `ModuleRole::Service`. Audio, IR. (MQTT stays code-wired under Network as always-there infra; Devices is fleet-scope, see its note.) -**Services is to System what Layouts / Layers / Drivers are to the light pipeline** β€” this is the unifying insight, and the strongest justification (*Common patterns first*): projectMM *already* has the "top-level container holding user-added children of one role" pattern in the light domain (`Layers` holds effects you add, `Drivers` holds drivers you add). **`Services` is that exact same container shape applied to the core domain** β€” not a new concept, the existing one reused. So the split isn't inventing anything; it's recognising that Audio/MQTT/IR belong in a `Layers`-style container, and that container is `Services`. +**`Services` is the existing container shape, applied to the core domain** β€” the strongest justification (*Common patterns first*). "A top-level container holding the user-added children of one role" is a pattern the codebase already runs on; `Services` reuses it rather than inventing a second arrangement. So the split adds no new concept: it recognises that Audio and IR are user-added children of one role, and that is what a container of that shape is for. This settles the role question: **Services children = `ModuleRole::Service` (user-managed); System's fixed children carry no user-editable affordance** (wired-by-code, no delete). It **fixes TasksModule's current stopgap** β€” it was given `Peripheral`+delete only to render the delete button, which is now the *wrong* answer: Tasks is a fixed System child, so it should be wired-by-code with no delete, like ImprovProvisioning under Network. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 55e0a96f..464cd7c0 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -67,7 +67,7 @@ When a `switch (type)` outside the type's home file is legitimate: the caller ha ## File shape: header-only vs `.h` + `.cpp` -- **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Layers`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` β€” no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h β†’ EffectBase.h β†’ MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain. +- **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Effects`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` β€” no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h β†’ EffectBase.h β†’ MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain. - **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header β€” incremental builds are 2–5Γ— faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* β€” `types.h`, `color.h`, `version.h`, `BinaryBroadcaster.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` β€” stay header-only. Templates (e.g. `ModuleFactory::registerType`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only. - **A catalog module includes ONLY its base header.** Every effect, modifier, layout, and concrete driver leads with exactly one include β€” `light/effects/EffectBase.h`, `light/layouts/LayoutBase.h`, `light/modifiers/ModifierBase.h`, or `light/drivers/DriverBase.h` β€” the base class it subclasses, and nothing else at the top of the file. That base header is the module author's **standard library**: it declares the base class AND pulls in the render context, the common domain helpers (`draw` / `Palette` / `math8` / `noise` / `color` / `crc` for effects; `DriverBase`'s own `Layer`/`Buffer`/`Correction`/platform for drivers; the base + integer trig for modifiers/layouts), the lifecycle primitives (`ScratchBuffer`), the audio source, AND the standard-library headers the bodies use (``, ``, ``, ``; drivers add ``, ``). Bundling this whole surface is **byte-free** β€” unused declarations emit no code (measured: the ESP32 image did not grow when the set was maximised), so the reflex is *add the common header to the base, don't scatter it per file*. That keeps every module in a domain reading identically and the copy-edit-register workflow free of include guesswork; it's also the surface a scripted MoonLive module gets uniformly. This is a *maximal* (prelude-style) bundle on purpose β€” a recognisable pattern (Rust's `std::prelude`, a project-wide `framework.h`), justified at the introduction site in each base header's comment. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 82e51d07..6fd8dc88 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -269,14 +269,14 @@ on **serpentine** if your strip zig-zags back and forth. > [Layouts](moonmodules/light/supporting.md) -**Layers** β€” what plays on the lights. Add an **effect** (a moving pattern), stack +**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) **Drivers** β€” where the colors go. Set overall **brightness** and color order, then add an output: real LED strips on a pin, or send the frame over the network diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 2a6fbad6..9fbe2804 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,73 +1,73 @@ { - "commit": "2e23f158", + "commit": "746c4e1c", "flash": { - "esp32": 1744736, - "esp32p4-eth": 1503280, + "esp32": 1762368, + "esp32p4-eth": 1600848, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1739968, + "esp32s3-n16r8": 1752384, "esp32s3-n8r8": 1666992, - "esp32s31": 1932624, - "desktop": 1100648 + "esp32s31": 2024656, + "desktop": 1137800 }, "perf": { "desktop": { - "tick_us": 140, - "fps": 7142 + "tick_us": 122, + "fps": 8196 }, "esp32": { - "tick_us": 4164, - "fps": 240 + "tick_us": 2151, + "fps": 464 } }, "loc": { - "core": 16537, - "light": 23604, - "platform": 12590, - "ui": 6467, - "test": 39761, - "moondeck": 20039 + "core": 16973, + "light": 24268, + "platform": 12841, + "ui": 6468, + "test": 41226, + "moondeck": 20323 }, "comments": { "core": { - "lines": 6216, - "ratio": 0.409 + "lines": 6395, + "ratio": 0.41 }, "light": { - "lines": 9092, - "ratio": 0.426 + "lines": 9406, + "ratio": 0.429 }, "platform": { - "lines": 4233, - "ratio": 0.372 + "lines": 4353, + "ratio": 0.374 }, "ui": { "lines": 1670, "ratio": 0.274 }, "test": { - "lines": 6777, - "ratio": 0.197 + "lines": 7161, + "ratio": 0.201 }, "moondeck": { - "lines": 3198, + "lines": 3246, "ratio": 0.183 } }, "tests": { - "cases": 1267, - "scenarios": 22 + "cases": 1324, + "scenarios": 23 }, "docs": { - "md_files": 176, - "md_lines": 24123, + "md_files": 178, + "md_lines": 24393, "plans_files": 91, - "backlog_lines": 3611, - "lessons_lines": 418, + "backlog_lines": 3625, + "lessons_lines": 454, "claude_md_lines": 135 }, "complexity": { - "functions": 2417, - "over_threshold": 149, + "functions": 2470, + "over_threshold": 151, "worst_ccn": 93 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index c91ce5c6..ed4679fb 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `2e23f158`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `746c4e1c`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,55 +8,55 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,075 KB (+1 KB) ⚠ | -| esp32 | 1,704 KB | -| esp32p4-eth | 1,468 KB | +| desktop | 1,111 KB | +| esp32 | 1,721 KB | +| esp32p4-eth | 1,563 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,699 KB (+3 KB) ⚠ | +| esp32s3-n16r8 | 1,711 KB (+0 KB) ⚠ | | esp32s3-n8r8 | 1,628 KB | -| esp32s31 | 1,887 KB | +| esp32s31 | 1,977 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 140 Β΅s (+8 Β΅s) ⚠ | 7,142 (βˆ’433) ⚠ | -| esp32 | 4,164 Β΅s | 240 | +| desktop | 122 Β΅s (βˆ’4 Β΅s) βœ“ | 8,196 (+260) βœ“ | +| esp32 | 2,151 Β΅s | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 16,537 (+104) ⚠ | 6,216 | 40.9 % (βˆ’0.1 %) βœ“ | -| light | 23,604 (+282) ⚠ | 9,092 | 42.6 % (+0.1 %) ⚠ | -| platform | 12,590 | 4,233 | 37.2 % | -| ui | 6,467 | 1,670 | 27.4 % | -| test | 39,761 (+215) ⚠ | 6,777 | 19.7 % | -| moondeck | 20,039 | 3,198 | 18.3 % | +| core | 16,973 (+16) ⚠ | 6,395 | 41.0 % | +| light | 24,268 (+4) ⚠ | 9,406 | 42.9 % | +| platform | 12,841 | 4,353 | 37.4 % | +| ui | 6,468 | 1,670 | 27.4 % | +| test | 41,226 (+64) ⚠ | 7,161 | 20.1 % (+0.1 %) ⚠ | +| moondeck | 20,323 | 3,246 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,267 (+13) βœ“ | -| scenarios | 22 | +| unit cases | 1,324 (+2) βœ“ | +| scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,417 (+12) βœ“ | -| over threshold | 149 (βˆ’2) βœ“ | +| functions | 2,470 | +| over threshold | 151 | | worst CCN | 93 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 176 | -| markdown lines | 24,123 (+94) ⚠ | +| markdown files | 178 | +| markdown lines | 24,393 | | plan files | 91 | -| backlog lines | 3,611 (+62) ⚠ | -| lessons lines | 418 | +| backlog lines | 3,625 | +| lessons lines | 454 | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/core/control.md b/docs/moonmodules/core/control.md index 0582428e..d0507b68 100644 --- a/docs/moonmodules/core/control.md +++ b/docs/moonmodules/core/control.md @@ -2,7 +2,7 @@ The device's control surface β€” the place that says "put the device into this state", whatever asked for it. A preset applied from the grid, and later a fader moved on a MIDI desk, arrive at the same code. Its first capability is presets; the surface layout exists so external controllers map onto something that already looks like them. -`ControlModule` is a top-level module, a peer of Layouts / Layers / Drivers rather than a child of Services: it reaches *across* the top-level modules, so it cannot sit inside one. +`ControlModule` is a top-level module, a peer of Layouts / Effects / Drivers rather than a child of Services: it reaches *across* the top-level modules, so it cannot sit inside one. ## Control modules @@ -35,22 +35,22 @@ A preset captures **exactly one** top-level subtree, recorded in the file: ```json { "slot": 12, - "captures": "Layers", - "Layers.enabled": true, "Layers.0.type": "Layer", "Layers.0.0.type": "NoiseEffect" + "captures": "Effects", + "Effects.enabled": true, "Effects.0.type": "Layer", "Effects.0.0.type": "NoiseEffect" } ``` Each captured subtree is exactly the bytes the persistence engine already writes for that module, namespaced under a `.` key prefix. Save and restore therefore reuse the engine that reconciles a tree against JSON ([`saveSubtreeTo` / `applySubtree`](moxygen/FilesystemModule.md)) rather than a second serializer that could drift from it. -One subtree per preset is the whole model: a preset is *a look*, or *a geometry*, or *a hardware setup*, or *a service configuration. Never a combination. A `Layers` preset is a look, and applies to a board with completely different hardware; a `Drivers` preset carries pin maps and is device-specific. Choosing the role is a single radio button when saving, and the pad's color says which role it holds. +One subtree per preset is the whole model: a preset is *a look*, or *a geometry*, or *a hardware setup*, or *a service configuration. Never a combination. A `Effects` preset is a look, and applies to a board with completely different hardware; a `Drivers` preset carries pin maps and is device-specific. Choosing the role is a single radio button when saving, and the pad's color says which role it holds. A preset naming a subtree this build does not have is refused with a reason rather than partially applied, and a file written by an older build that names several subtrees is listed but not applied, so it can be seen and deleted rather than silently vanishing. A malformed file leaves the live tree untouched. ### One active preset per role -Each subtree is a **role**: layout, layer, driver, service. A preset holds its own role and leaves the other three alone, so a layout preset and a look can be active at the same time, and applying a new look replaces only the look. +Each subtree is a **role**: layout, effects, driver, service. A preset holds its own role and leaves the other three alone, so a layout preset and a look can be active at the same time, and applying a new look replaces only the look. -A pad is tinted by its role: layout blue, layer violet, driver green, service amber. +A pad is tinted by its role: layout blue, effects violet, driver green, service amber. ### Applying is a rebuild @@ -60,7 +60,7 @@ Structural mutation quiesces the render worker, and mutations run inline on the ## Home Assistant -Looks reach Home Assistant two ways, and only `Layers` presets travel either of them. +Looks reach Home Assistant two ways, and only `Effects` presets travel either of them. **The WLED integration** (`/presets.json`) is the native path: HA renders looks in its own preset dropdown, shows which one is applied, and applies one when it is chosen. This is what HA calls a preset. diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 3d7d8315..5e13217a 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -1,12 +1,12 @@ # Core services -The user-added **Service** modules β€” capability bridges the device provides or consumes, added and removed at runtime in the `Services` container (the core-domain twin of the light domain's `Layers`/`Drivers`). Fixed device infrastructure (identity, network, inspection tools) lives under **System** β€” see [core/system.md](system.md). Every row links to its generated technical page (the full API, from the `.h`) and its tests. +The user-added **Service** modules β€” capability bridges the device provides or consumes, added and removed at runtime in the `Services` container (the core-domain twin of the light domain's `Effects`/`Drivers`). Fixed device infrastructure (identity, network, inspection tools) lives under **System** β€” see [core/system.md](system.md). Every row links to its generated technical page (the full API, from the `.h`) and its tests. -### Services +## Services -The top-level container the Service modules hang under β€” a grouping node with no controls of its own, the same shape as `Layers`/`Drivers` in the light domain. Adds/removes its children (Audio, IR) at runtime via the generic module machinery. +The top-level container the Service modules hang under β€” a grouping node with no controls of its own, the same shape as `Effects`/`Drivers` in the light domain. Adds/removes its children (Audio, IR) at runtime via the generic module machinery. Detail: [technical](moxygen/Services.md) diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 737ac378..1237af97 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -4,7 +4,7 @@ MoonLive is projectMM's **live-script engine** β€” author an effect as text and Scripts call the same [power functions](power-functions.md) compiled effects use, reached through the builtin table β€” so the vocabulary is shared, in its flat scalar form. -A scripted effect carries its **script source** as an editable, persisted multi-line text control (a resizable `textarea` in the UI), and a front-end (lexer β†’ parser β†’ IR β†’ per-ISA assembler) compiles it to native code on the next tick. The grammar is a function-call statement with **expression arguments** β€” any argument may be a literal or a nested call: +A scripted effect carries its **script source** as an editable, persisted multi-line text control (a resizable `textarea` in the UI), and a front-end (lexer β†’ parser β†’ IR β†’ per-ISA assembler) compiles it to native code on the next tick. The grammar is a sequence of **statements** β€” a function call, or a `for` loop over them β€” with **expression arguments**, so any argument may be a literal or a nested call: ``` setRGB(random16(256), 0, 0, 255); // a random pixel, blue @@ -27,6 +27,47 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 Declaring the variable is what **creates** the control: `uint8_t = ;` becomes a `` slider (default ``, range `0..255`). The trailing `// @control ..` only **adjusts that control's range**; it's optional. A declared name used in a statement reads the control's **current** value. Editing a control's slider does **not** recompile β€” the value lands in the engine's control-values arena and the next render tick reads it (the live-edit guarantee, the *no-reboot* principle). Editing the `source` recompiles and re-derives the control set; a control kept across the edit keeps its slider value, a removed control's saved value drops. Stage 1 is `uint8` only. +### System variables β€” what the engine hands a script + +Some names are **reserved**: the engine defines them, the script only reads them, and a declaration that reuses one is a compile error (`name is a system variable`). Each module supplies the names it actually writes, so a name a script cannot be given is simply unknown there rather than silently reading 0. + +| name | what it is | layout | effect | modifier | +|---|---|:-:|:-:|:-:| +| `t` | elapsed milliseconds β€” the clock an animation is written against | βœ“ | βœ“ | βœ“ | +| `width`, `height`, `depth` | the **logical grid** the script renders into, `0..255` | | βœ“ | βœ“ | +| `x`, `y`, `z` | the light being transformed, `0..255` | | | βœ“ | + +Every one but `t` is a byte, because it lives in the controls arena. A grid extent past 255 reports 255 rather than wrapping to a small number, and a modifier handed a coordinate outside `0..255` passes it through untransformed instead of folding a wrong position β€” so a script never silently sees a value that means something else. + +Supplying a name is also what reserves it, so the tight lists are what leave `x` and `y` usable as ordinary loop counters in a layout or an effect β€” neither is handed a coordinate. + +`width`/`height`/`depth` are the Layer's own dimensions, derived from the layouts and the modifier chain. An effect is *told* its canvas rather than declaring it: a size restated as a control is a second answer that can disagree with the first, and a script that sets `width` to 16 on an 8Γ—8 panel draws off the edge. A [layout](MoonLiveLayout.md) is upstream of that grid β€” it is what the dimensions are derived *from* β€” so it is not given them at all, and names its own controls instead (`cols`, `rows`). + +Reserving is what makes the guarantee hold: without it a declaration would silently shadow the value the engine handed in, and the script would disagree with its layer with no error anywhere. + +### The vocabulary β€” what a script can call + +Registered by the light domain, not built into the compiler (the core owns only the grammar and a generic call/inline mechanism), so the list is one edit in `MoonLiveBuiltins_light.h`. + +| call | does | +|---|---| +| `setRGB(index, r, g, b)` | write one light | +| `setXYZ(index, x, y, z)` | write one position (a [modifier](MoonLiveModifier.md)) | +| `fill(r, g, b)` | write every light | +| `addLight(x, y, z)` | place the next light (a [layout](MoonLiveLayout.md)) | +| `random16(n)` | a value in `[0, n)` | +| `mod(a, b)` | `a % b` β€” the wrap a cyclic animation needs | +| `beat(bpm, t)` | a `0..65535` sawtooth at `bpm` | +| `beatsin(bpm, t, high)` | a sine `0..high` at `bpm` | +| `scale(value, n)` | a `0..65535` value onto `0..n-1` β€” lands a wave on an axis | +| `sin(angle)`, `cos(angle)` | the circle; one turn is `0..65535`, result biased to `1..65535` centred at 32768 | +| `turn(n)` | one revolution split `n` ways β€” the angle step for placing `n` points on a circle | +| `print(v)` | log a value and return it ([what it costs](../../../moonlive/README.md#debugging-print)) | + +`sin`/`cos` return an **unsigned** wave, so a coordinate comes from scaling by the full span and not by half of it: `scale(cos(a), radius * 2 + 1)` sweeps a whole axis, where scaling by `radius` alone would only ever reach one side of centre. + +`turn(n)` exists because a full revolution is 65536 β€” one past the largest number a script can write β€” and the grammar has no division. Without it, placing `n` points evenly on a circle is not expressible. + ### Wire contract β€” control declaration The controls are **derived from `source`** (one per declared `uint8` control; the optional `@control` annotation only refines a control's range), then **surfaced in `/api/state`** β€” the device JSON view the integrator consumes β€” as regular `uint8` controls alongside `source`. So an integrator sees and writes them exactly like any other control β€” e.g. `POST /api/control` with `{"module": "ML", "control": "speed", "value": 80}`; they're fully present in the device JSON, just authored in the script rather than fixed in the module. The script's `\n` line breaks are standard JSON string escapes the device decodes, so a multi-line `source` round-trips. @@ -36,7 +77,7 @@ The controls are **derived from `source`** (one per declared `uint8` control; th - **`MoonLive`** (`src/core/moonlive/MoonLive.h/.cpp`) β€” the **domain-neutral engine core**. Owns a block of executable memory; `compile(source, table)` runs the front-end against a host builtin table and places the emitted code, `run(buf, nLights, cpl, t)` calls it. Includes only ``, the compiler/emitter seams, and the platform seam β€” never `EffectBase`, `Buffer`, or any LED type. - **`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. - **per-ISA assembler + lowering** (`src/platform//moonlive_asm_*` + `moonlive_lower_*`) β€” a tiny named-instruction MacroAssembler with label back-patching, and the IRβ†’bytes lowering that drives it. Xtensa for the classic/S3 (`__XTENSA__`), the host ISA on desktop (arm64/x86-64). Adding an ISA is a new assembler + lowering; the front-end and IR are unchanged. (`emitFill`/`emitAnimatedFill` remain as the hand-encoded `fill` references the assembler's output is checked against.) - **`MoonLiveEffect`** (`src/light/moonlive/MoonLiveEffect.h`) β€” the **thin binding**: a first-class `EffectBase` carrying the `source` control, whose `tick()` delegates to the engine over its own `buffer()` and passes the light builtin table to `compile`. The engine is projectMM-agnostic; the binding is the only coupled layer. diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md new file mode 100644 index 00000000..d46d77b3 --- /dev/null +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -0,0 +1,86 @@ +# MoonLiveLayout + +A **layout written as a live script**: where the lights physically are, authored as text on a running device instead of compiled in as a C++ class. Same [MoonLive](MoonLiveEffect.md) engine as a scripted effect or [modifier](MoonLiveModifier.md), pointed at the third job. + +A [layout](layouts.md) is the one part of the pipeline that differs for every physical build β€” a ring, a spiral staircase, a car grille, a costume sewn last night. Each one has meant writing a C++ class, rebuilding and reflashing. A script means the person who hung the lights can describe where they went, on the device, and see it immediately. + +MoonLiveLayout + +## Writing one + +The script places every light itself, with a loop. That is the difference from a scripted modifier: the Layer calls a modifier once per light, so its script transforms a single coordinate β€” a layout has no such per-light call to ride on. + +```c +uint8_t cols = 16; // @control 1..64 +uint8_t rows = 16; // @control 1..64 + +for (y = 0; y < rows; y = y + 1) { + for (x = 0; x < cols; x = x + 1) { + addLight(x, y, 0); + } +} +``` + +That is the default: a plain grid, one light per cell. `addLight(x, y, z)` places the next light along the strand β€” no index, because the order the script calls it in *is* the strand order. + +The `cols` and `rows` lines are the script's own controls, not something the module hands it. A layout is never told how big it is: the pipeline works out the bounding box from the coordinates the layouts actually place, so a size passed in from outside would be a second answer that could disagree with the first. + +They are named `cols`/`rows` because `width`, `height` and `depth` are [system variables](MoonLiveEffect.md#system-variables--what-the-engine-hands-a-script) β€” the logical grid the Layer hands an effect or a modifier. A layout is upstream of that grid, so it names its own controls. + +A few shapes that are one line here and a new class otherwise: + +```c +// a strand that runs right to left +for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); } + +// a diagonal +for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); } + +// two rows, stacked +for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); } + +// a circle: lights and grid cells are not the same number +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); +} +``` + +### What a script can read + +A script reads whatever it declares. `uint8_t cols = 16; // @control 1..64` becomes a real slider in the UI, and the loop reads it β€” which is how a panel gets resized without editing code. + +`t` is the one [system variable](MoonLiveEffect.md#system-variables--what-the-engine-hands-a-script) a layout is given, and it is always **0** here: the script runs twice per rebuild (once to count, once to place) and must agree with itself, so it is handed a fixed clock rather than a live one β€” a moving `t` would let the two passes disagree on how many lights there are. `width`/`height`/`depth` name the grid a layout is *defining*, so asking for one is a compile error rather than a silent zero; `x` and `y` are free to use as loop counters. + +### Seeing inside a script + +`print(v)` logs a value and returns it, so it wraps any part of an expression: `addLight(print(x), y, 0)`. +It is for debugging and comes back out again β€” [what print costs](../../../moonlive/README.md#debugging-print). + +## How the count is known + +A layout has to answer **how many lights** before it produces a single coordinate β€” the Layer sizes its buffer from that number and only then asks where each light is. A script cannot be asked "how many?" without running it. + +So it runs twice. On the first pass `addLight` counts; on the second it emits each position to whoever asked. Same script, same arithmetic, so as long as the script is deterministic the two answers cannot drift apart β€” which is exactly what the compiled layouts do (`SphereLayout` walks its shell twice for the same reason). A script that calls `random16` breaks that condition. The two passes disagree on the COUNT only when the random value decides a loop bound or how many times `addLight` runs; a random COORDINATE keeps the count right and simply places the lights somewhere else on the second pass, so the fixture is the size it claims but not the shape. See [Limits](#limits). + +**Nothing is stored between the passes.** Staging 16,384 coordinates would cost 48 KB, which a classic ESP32 driving that many lights does not have spare. Running the script again is cheaper than remembering what it said, and it means a scripted layout costs the same as a compiled one: the JIT'd program, and nothing that grows with the light count. + +## Limits + +**The grammar is arithmetic, calls and `for`** β€” `+`, `-`, `*`, parentheses, nested loops. Division, `%` and `if` are not in the language yet, so a serpentine over an arbitrary number of rows (every other row reversed) is not expressible today. A fixed few rows can be written out as one loop per direction β€” `two-rows.mlv` does exactly that β€” but each row costs its own loop, so it does not scale to a panel. + +**A script runs twice per rebuild**, once to count and once to place, so it has to be deterministic. With `random16` in a loop bound or around an `addLight` call, the two passes disagree on the count; with `random16` in a coordinate, the count holds and only the positions move. + +## Controls + +| control | what it does | +|---|---| +| `source` | the script; editing it recompiles and re-places the lights live | + +Plus one control per `@control` the script declares. + +Editing any of them rebuilds the pipeline, because every one can change where the lights are. A script that fails to compile leaves a fixture with no lights, shows the parse error on the module, and the device keeps running. + +Detail: [technical](moxygen/MoonLiveLayout.md) diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md new file mode 100644 index 00000000..43ee9ff6 --- /dev/null +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -0,0 +1,52 @@ +# MoonLiveModifier + +A **modifier written as a live script**: the coordinate transform that decides where each light sits in the pattern, authored as text on a running device instead of compiled in as a C++ class. Same [MoonLive](MoonLiveEffect.md) engine as a scripted effect, pointed at a different job. + +A [modifier](modifiers.md) reshapes how a Layer's output maps onto the physical lights β€” mirror it, shift it, swap its axes. Each hand-written one is a class, a rebuild and a reflash. A scripted one is a line of text, applied as you type. + +MoonLiveModifier + +## Writing one + +The script transforms **one coordinate**. It needs no loop over the lights, because the Layer already does that: it calls the script once per physical light while it builds its mapping. (A `for` is available if the arithmetic wants one β€” it just is not how the script reaches the next light.) + +```c +setXYZ(0, width - 1 - x, y, z); // mirror along x +setXYZ(0, y, x, z); // swap the axes +setXYZ(0, x + 4, y, z); // shift by four +setXYZ(0, (width - 1 - x) * 2, y, z); // mirror, then stretch +``` + +`setXYZ(index, x, y, z)` writes the transformed position, mirroring `setRGB(index, r, g, b)`. The index is the destination slot: today the script is handed a single coordinate, so it is always `0`. + +### What a script can read + +`x`, `y`, `z` (the light being folded) and `width`, `height`, `depth` (the box it lives in) are [system variables](MoonLiveEffect.md#system-variables--what-the-engine-hands-a-script) β€” the engine writes them per call, and a script cannot declare a name that shadows one. + +`width` matters more than it looks. A mirror written against a fixed `255` sends every light of a 16-wide grid far outside the grid, the Layer discards each one as out of bounds, and the fixture goes black β€” with no error anywhere, because the script itself ran perfectly. + +### Seeing inside a script + +`print(v)` logs a value and returns it, so it wraps any part of an expression: `setXYZ(0, print(width - 1 - x), y, z)`. +It is for debugging and comes back out again β€” [what print costs](../../../moonlive/README.md#debugging-print). + +## Limits + +**A coordinate is a byte, so an axis spans 0..255.** A position handed TO a script outside that range is passed through untransformed rather than wrapped. A position a script COMPUTES past 255 keeps its low byte, so `(width - 1 - x) * 2` on a grid wider than 128 lands somewhere unintended β€” keep a computed result inside the box. + +**A script cannot resize the logical box.** A modifier has two hooks: one reshapes the box once per rebuild, one folds each coordinate. A script drives only the second, so transforms that keep the box the same size work, and ones that halve it (the way the built-in [Mirror](modifiers.md#mirror) does) need the compiled modifier. + +**The grammar is arithmetic over calls** β€” `+`, `-`, `*`, parentheses, the usual precedence, and `for`. Division and `if` are not in the language yet. + +## Controls + +| control | what it does | +|---|---| +| `source` | the script; editing it recompiles and re-maps live | + +Plus one control per `@control` the script declares β€” `uint8_t amount = 4; // @control 0..64` +becomes a slider, and moving it rebuilds the mapping just as editing the script does. + +Editing the script asks the Layer to rebuild its mapping, so a change is visible immediately. A script that fails to compile shows the parse error on the module and the mapping falls back to passing coordinates straight through β€” the transform disappears until the script parses again, and the device keeps rendering throughout. + +Detail: [technical](moxygen/MoonLiveModifier.md) diff --git a/docs/moonmodules/light/supporting.md b/docs/moonmodules/light/supporting.md index a60bbb45..fe3992d2 100644 --- a/docs/moonmodules/light/supporting.md +++ b/docs/moonmodules/light/supporting.md @@ -18,13 +18,13 @@ Detail: [technical](moxygen/Layer.md) -### Layers +### Effects The container of layers β€” composites them (blend mode + opacity per layer) into the final light buffer. -Layers container +Effects container -Detail: [technical](moxygen/Layers.md) +Detail: [technical](moxygen/Effects.md) [Tests](../../tests/unit-tests.md#layers) diff --git a/docs/usecases/build-your-own-moonmodules.md b/docs/usecases/build-your-own-moonmodules.md index 733343ca..f38478ae 100644 --- a/docs/usecases/build-your-own-moonmodules.md +++ b/docs/usecases/build-your-own-moonmodules.md @@ -23,7 +23,7 @@ A projectMM light show is a small tree of MoonModules: ``` Layouts β†’ where the LEDs are in space (a Grid, a sphere, a strip) -Layers β†’ a stack of images being drawn +Effects β†’ a stack of images being drawn Layer β†’ one image, built by… Effect β†’ draws color into the image (the fun part) Modifier β†’ bends/masks/repeats the image @@ -319,7 +319,7 @@ You get all of that "release the pin on disable" behaviour by implementing the s ## What to read next - **The effects catalog:** [docs/moonmodules/light/effects.md](../moonmodules/light/effects.md) β€” every shipped effect, with screenshots and controls. The best source of copy-and-tweak starting points. -- **The architecture doc:** [docs/architecture.md](../architecture.md) β€” the render pipeline (Layouts β†’ Layers β†’ Effects/Modifiers β†’ Drivers) and the hot-path rules (why we avoid heap and floats inside `tick()`). +- **The architecture doc:** [docs/architecture.md](../architecture.md) β€” the render pipeline (Layouts β†’ Effects β†’ Layer β†’ Effect/Modifier β†’ Drivers) and the hot-path rules (why we avoid heap and floats inside `tick()`). - **Coding standards:** [docs/coding-standards.md](../coding-standards.md) β€” the house style (header-only light modules, `constexpr`, naming) so your module reads like the rest. - **The real modules:** the smallest ones make the best teachers β€” `RainbowEffect` (a clean loop), `GameOfLifeEffect` (the memory lifecycle), `GridLayout` (`forEachCoord`). diff --git a/docs/usecases/home-automation.md b/docs/usecases/home-automation.md index 26301d02..33802a5d 100644 --- a/docs/usecases/home-automation.md +++ b/docs/usecases/home-automation.md @@ -108,7 +108,7 @@ Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambien To set it up: -1. **Add a Hue driver.** In the device's web UI pipeline (**Layers β†’ a Layer β†’ its Drivers**), add a **Hue** driver. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)). +1. **Add a Hue driver.** In the device's web UI pipeline, add a **Hue** driver to the top-level **Drivers** container. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)). 2. **Pair with the bridge.** Press the physical **link button** on the Hue bridge, then click the driver's **`pair`** button within ~30 seconds. The device claims an app key (stored on the driver as `appKey`) β€” a one-time step; the status line reports `paired, N lights`. 3. **Pick what it drives.** The driver lists the bridge's color-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window. 4. **Run an effect.** Any effect on the layer now drives the bulbs β€” the global brightness slider and color-order correction apply to them just like a physical strip (brightness 0 turns a bulb off). diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index dfbbf9a3..015f8a66 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -949,11 +949,22 @@ Typical use: forcing a fresh-first-boot after firmware experiments leave the Lit Monitor serial output. Long-running β€” shows Stop button. ```bash -uv run moondeck/run/monitor_esp32.py --port /dev/tty.usbserial-0001 +uv run moondeck/run/monitor_esp32.py --port /dev/tty.usbserial-0001 --firmware esp32s3-n16r8 ``` Reads serial at 115200 baud. Output streams to MoonDeck's log and is saved to `esp32/monitor.log` for later inspection (useful when crashes flood the output). +**Panic backtraces are decoded.** A crash prints `Backtrace: 0x4038456d:0x3fcae310 …`, which says nothing on its own; with `--firmware` each address is resolved against that build's ELF and the function, file and line print underneath: + +```text +Guru Meditation Error: Core 0 panic'ed (LoadProhibited) +Backtrace: 0x4210b93b:0x3fcc8fa0 0x4200fbf8:0x3fcc8fc0 + #0 src/light/moonlive/MoonLiveLayout.h:119 + #1 src/light/moonlive/MoonLiveBuiltins_light.h:82 +``` + +So a panic names its source line in the monitor rather than starting a separate addr2line session. Same purpose as PlatformIO's `esp32_exception_decoder` monitor filter; here it is the toolchain's own `addr2line` against `build/esp32-/projectMM.elf`, picking the Xtensa or RISC-V tool from the firmware name. Without `--firmware`, or when that build has no ELF, addresses print raw and the monitor runs as before β€” decoding must never cost you the serial output. + ### improv_provision Push WiFi credentials to a running projectMM device over USB-serial. Uses the [Improv-WiFi](https://www.improv-wifi.com/serial/) protocol β€” the same wire format the browser flow at improv-wifi.com uses. Device must be running a firmware that includes the Improv listener. diff --git a/moondeck/build/erase_flash_esp32.py b/moondeck/build/erase_flash_esp32.py index c1fd3b31..9682d9c1 100755 --- a/moondeck/build/erase_flash_esp32.py +++ b/moondeck/build/erase_flash_esp32.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 """Erase the entire ESP32 flash. Useful when on-device state (e.g. /.config persistence) -is wedged and needs a fresh start. Triggers `idf.py erase-flash` on the selected port.""" +is wedged and needs a fresh start β€” a module that crashes at boot is rebuilt from that state on +every boot, so an app reflash alone cannot break the loop. + +Runs esptool directly rather than `idf.py erase-flash`. Erasing needs only a chip and a port, but +idf.py additionally validates the build directory, so it aborted on an unrelated python-env mismatch +("... is currently active while the project was configured with ...") and left the flash untouched. +""" import argparse import subprocess @@ -11,11 +17,12 @@ ESP32_DIR = ROOT / "esp32" sys.path.insert(0, str(Path(__file__).resolve().parent)) -from build_esp32 import find_idf, idf_env, idf_cmd +from build_esp32 import find_idf, idf_env, find_idf_python def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", required=True, help="Serial port") + parser.add_argument("--firmware", help="Firmware variant, to name the chip. Omitted: detect.") args = parser.parse_args() if not ESP32_DIR.exists(): @@ -27,12 +34,31 @@ def main(): print("ESP-IDF not found. Install it or set IDF_PATH.") sys.exit(1) - env = idf_env(idf_path) - cmd = idf_cmd(idf_path) + env = idf_env(idf_path) # esptool comes from the IDF env; no idf.py wrapper needed + + fw = args.firmware or "" + chip = "esp32s3" if "s3" in fw else "esp32p4" if "p4" in fw else "esp32" if fw else "auto" - print(f"Erasing flash on {args.port}...") - r = subprocess.run(cmd + ["erase-flash", "-p", args.port], + print(f"Erasing flash on {args.port} (chip: {chip})...") + sys.stdout.flush() + # esptool lives in the IDF's own venv, not in whatever interpreter is running this script. + # find_idf_python returns the venv DIRECTORY; the interpreter is bin/python inside it + # (Scripts/python.exe on Windows). + venv = find_idf_python(idf_path) + python = sys.executable + if venv: + for rel in ("bin/python", "Scripts/python.exe"): + cand = Path(venv) / rel + if cand.exists(): + python = str(cand) + break + r = subprocess.run([python, "-m", "esptool", "--chip", chip, + "--port", args.port, "erase_flash"], cwd=ESP32_DIR, env=env) + # Say which happened. Printing "Erasing..." and exiting non-zero read as success, so a failed + # erase looked done and the next flash went onto un-erased state. + print("Flash erased." if r.returncode == 0 else + f"ERASE FAILED (exit {r.returncode}) - the flash was NOT erased.") sys.exit(r.returncode) if __name__ == "__main__": diff --git a/moondeck/check/check_specs.py b/moondeck/check/check_specs.py index 0b1da891..7bf01d96 100644 --- a/moondeck/check/check_specs.py +++ b/moondeck/check/check_specs.py @@ -80,7 +80,7 @@ def find_moonmodules(): # else keeps a per-module page named for the type. The match is purely on the type-name **suffix**, so # EVERY *Layout module (CarLightsLayout, CubeLayout, PanelLayout, RingLayout, …) routes to layouts.md, # every *Driver to drivers.md, etc. β€” not just a hand-picked subset. New effect/modifier/layout/driver -# types fold in automatically. (Layouts/Layers/Drivers are CONTAINERS, not leaf modules β€” none of those +# types fold in automatically. (Layouts/Effects/Drivers are CONTAINERS, not leaf modules β€” none of those # stems ends in a suffix below ("Drivers" β‰  "Driver"), so each container keeps its own per-module page; # the CRTP base ParallelLedDriver is skipped in discover_modules as a template.) CONSOLIDATED_PAGES = { diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index 27d046f3..64d9c084 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -84,7 +84,7 @@ def asset_dir_for(type_name: str) -> Path: return ASSETS / "light" / "layouts" if type_name.endswith("Driver"): return ASSETS / "light" / "drivers" - if type_name in ("Layouts", "Layers", "Drivers"): + if type_name in ("Layouts", "Effects", "Drivers"): return ASSETS / "light" return ASSETS / "core" # SystemModule, FilesystemModule, DevicesModule, … and the rest @@ -123,7 +123,7 @@ def asset_dir_for(type_name: str) -> Path: ] # Container types that exist in the pipeline but are not added via REST -CONTAINERS = ["Layouts", "Layers", "Drivers"] +CONTAINERS = ["Layouts", "Effects", "Drivers"] # Core modules: always present in the pipeline, never added/deleted via REST. # Each entry: type_name β€” the module's type string as reported by /api/types. @@ -415,7 +415,7 @@ def find_parent_ids(host: str) -> tuple[dict[str, str], dict[str, str]]: parents maps role β†’ module name to use as parent_id when adding a child. nav_roots maps role β†’ top-level nav sidebar name (what to click). - Layer is nested inside Layers, so its nav root is "Layers" not "Layer". + Layer is nested inside Effects, so its nav root is "Effects" not "Layer". """ r = _get(f"http://{host}/api/state", timeout=5) if not r.ok: @@ -448,7 +448,7 @@ def walk(modules: list, nav_root: str | None = None) -> None: def find_container_nav_names(host: str) -> dict[str, str]: """Return a map of container type β†’ module name for top-level containers. - Used to screenshot Layouts, Layers, Drivers cards directly. + Used to screenshot Layouts, Effects, Drivers cards directly. """ r = _get(f"http://{host}/api/state", timeout=5) if not r.ok: @@ -539,7 +539,7 @@ def screenshot_module(page: Page, host: str, module_id: str, def screenshot_container(page: Page, host: str, container_name: str, out_path: Path) -> bool: - """Screenshot a top-level container card (Layouts, Layers, Drivers).""" + """Screenshot a top-level container card (Layouts, Effects, Drivers).""" _load_page(page, host) _click_nav(page, container_name) return _screenshot_card(page, container_name, out_path) @@ -772,7 +772,7 @@ def _sweep_orphans(modules: list) -> None: if filter_allows: if not overview_path.exists() or args.force: print(" ui_overview …", end=" ", flush=True) - # Use Layers nav to show a populated view + # Use Effects nav to show a populated view nav = nav_roots.get("Layer", "") ok = screenshot_fullpage(page, args.host, overview_path, nav_root=nav) print(f"saved β†’ {overview_path.relative_to(ROOT)}" if ok else "failed") @@ -812,7 +812,7 @@ def _sweep_orphans(modules: list) -> None: if args.extras_only: raise _ExtrasOnlyDone() - # --- Container cards (Layouts, Layers, Drivers) --- + # --- Container cards (Layouts, Effects, Drivers) --- for container_type in CONTAINERS: if filt and filt not in container_type.lower(): continue diff --git a/moondeck/docs/update_module_docs.py b/moondeck/docs/update_module_docs.py index 9f176d0c..56cd9d50 100644 --- a/moondeck/docs/update_module_docs.py +++ b/moondeck/docs/update_module_docs.py @@ -41,7 +41,7 @@ def asset_dir_for(type_name: str): return ASSETS / "light" / "layouts" if type_name.endswith("Driver"): return ASSETS / "light" / "drivers" - if type_name in ("Layouts", "Layers", "Drivers"): + if type_name in ("Layouts", "Effects", "Drivers"): return ASSETS / "light" return ASSETS / "core" diff --git a/moondeck/event/_gates.py b/moondeck/event/_gates.py index 7209b64e..24bb82f3 100644 --- a/moondeck/event/_gates.py +++ b/moondeck/event/_gates.py @@ -136,6 +136,14 @@ def when(*prefixes, exclude=()): when(*COMPILES_DESKTOP, "test/scenarios/")), Gate("platform boundary", UV + ["moondeck/check/check_platform_boundary.py"], when("src/", exclude=("src/platform/",))), + # The clang build above cannot see what CI sees: GCC warns where clang is silent + # (-Wstringop-truncation, -Wformat-truncation) and does not leak standard headers + # transitively, so a missing #include is green locally and red on every CI job. With + # -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"], + when(*COMPILES_DESKTOP)), # Reports what the compiler proved about THIS change: -Wfunction-effects checks the # render path transitively, and `--incremental` restricts the rebuild to what the commit # touched, so the gate answers "did this add a blocking call" in ~1s rather than diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 411697c5..cf353c5d 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -411,6 +411,7 @@ "help": "monitor_esp32", "script": "run/monitor_esp32.py", "needs_port": true, + "needs_firmware": true, "long_running": true, "process_name": "monitor_esp32" }, diff --git a/moondeck/moonlive/disasm.py b/moondeck/moonlive/disasm.py new file mode 100644 index 00000000..49f3a655 --- /dev/null +++ b/moondeck/moonlive/disasm.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Disassemble the machine code MoonLive emits for a script, without a device. + +Why this exists: an emitted-code bug on a device shows up as "the script did nothing" β€” it +compiles, reports no error, and places no lights. Reasoning about hand-written encoders from that +symptom is guesswork; five hypotheses in a row were wrong before this tool read the instructions and +answered it in one run. The bug was `Mov` lowering to add-immediate-zero, which Xtensa cannot encode +(the ISA reuses that slot for -1), so a loop counter started at -1 and every loop exited immediately. + +The per-ISA assemblers are ordinary C++ behind a target guard, so they build and run on the host: +this compiles a script through the real Xtensa backend and pipes the bytes to the ESP-IDF objdump. + + uv run moondeck/moonlive/disasm.py "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }" +""" + +import binascii +import glob +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TOOL_SRC = os.path.join(ROOT, "moondeck", "moonlive", "emit_xtensa.cpp") + + +def objdump() -> str: + """The ESP-IDF Xtensa objdump, wherever the toolchain was installed.""" + hits = glob.glob(os.path.expanduser( + "~/.espressif/tools/xtensa-esp-elf/*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-objdump")) + if not hits: + sys.exit("xtensa-esp32s3-elf-objdump not found β€” install the ESP-IDF Xtensa toolchain") + return sorted(hits)[-1] + + +def main() -> int: + script = sys.argv[1] if len(sys.argv) > 1 else \ + "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }" + + with tempfile.TemporaryDirectory() as tmp: + emitter = os.path.join(tmp, "emit") + 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], + capture_output=True, text=True) + if build.returncode != 0: + print(build.stderr[:2000]) + return 1 + + run = subprocess.run([emitter, script], capture_output=True, text=True) + print(run.stdout.split("\n")[0]) # the script + if run.returncode != 0: + print(run.stdout.strip() or run.stderr.strip()) + return 1 + + # Keep only lines that ARE hex. The emitter echoes the script as a `# …` comment, but a + # MULTI-LINE script only gets `#` on its first line, so a "not a comment" filter fed the + # remaining source lines into unhexlify and died on an odd-length string. + def is_hex(line: str) -> bool: + s = line.replace(" ", "") + return bool(s) and all(c in "0123456789abcdefABCDEF" for c in s) + + hexbytes = "".join(line for line in run.stdout.splitlines() if is_hex(line)) + raw = binascii.unhexlify(hexbytes.replace(" ", "")) + print(f"# {len(raw)} bytes\n") + + binpath = os.path.join(tmp, "code.bin") + with open(binpath, "wb") as f: + f.write(raw) + dis = subprocess.run([objdump(), "-D", "-b", "binary", "-m", "xtensa", binpath], + capture_output=True, text=True) + # Skip objdump's file header; the instructions start after the section line. + lines = dis.stdout.splitlines() + start = next((i for i, line in enumerate(lines) if line.startswith("00000000")), 0) + print("\n".join(lines[start:])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/moonlive/emit_xtensa.cpp b/moondeck/moonlive/emit_xtensa.cpp new file mode 100644 index 00000000..9649aa45 --- /dev/null +++ b/moondeck/moonlive/emit_xtensa.cpp @@ -0,0 +1,38 @@ +#include +#include +#include +#include +// The Xtensa backend is `#if defined(__XTENSA__)`, so on this host it compiles to nothing β€” there +// is no library to link against, which is the whole point: this tool runs the REAL Xtensa emitter on +// the development machine so an encoding can be read without flashing a board. Defining the macro +// and including the sources is what makes that possible; the target build never sees this file, so +// there is still exactly one backend definition in the firmware. +#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__ + +#include "core/moonlive/MoonLiveCompiler.h" +#include "light/moonlive/MoonLiveBuiltins_light.h" +using namespace mm; +int main(int argc, char** argv) { + const char* src = argc > 1 ? argv[1] : "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"; + uint8_t buf[4096]; + // The WIDEST system-variable list on purpose: this tool disassembles whatever script is passed + // on the command line β€” layout, effect or modifier β€” so it must accept every name any binding + // supplies. A narrower list would refuse the scripts it exists to inspect. + auto r = moonlive::compileSource(src, moonlive::lightBuiltins(), + moonlive::modifierSysVars(), buf, sizeof(buf)); + if (!r.ok) { printf("compile failed: %s\n", r.error); return 1; } + printf("# %s\n# %zu bytes\n", src, r.len); + for (size_t i = 0; i < r.len; i++) printf("%02x%s", buf[i], (i % 16 == 15) ? "\n" : " "); + printf("\n"); + return 0; +} diff --git a/moondeck/run/monitor_esp32.py b/moondeck/run/monitor_esp32.py index 6b00b91b..8b83ecb4 100644 --- a/moondeck/run/monitor_esp32.py +++ b/moondeck/run/monitor_esp32.py @@ -2,17 +2,62 @@ # /// script # dependencies = ["pyserial"] # /// -"""Monitor the ESP32 serial output. Saves to esp32/monitor.log.""" +"""Monitor the ESP32 serial output, decoding panic backtraces. Saves to esp32/monitor.log. + +A crash prints `Backtrace: 0x4038456d:0x3fcae310 …` β€” raw addresses that say nothing on their own. +Pass ``--firmware`` and each one is annotated with its function, file and line, so a panic names the +faulting source line in the monitor instead of starting an addr2line session. Same idea as +PlatformIO's ``esp32_exception_decoder`` monitor filter; here it is the toolchain's own addr2line +against the ELF the firmware was built from. +""" import argparse +import glob +import os +import re import serial +import subprocess import sys import time +from contextlib import suppress from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent LOG_FILE = ROOT / "esp32" / "monitor.log" +# A panic line: "Backtrace: 0xPC:0xSP 0xPC:0xSP …" (Xtensa) β€” the PCs are what we resolve. +BACKTRACE_RE = re.compile(r"Backtrace:((?:\s*0x[0-9a-fA-F]+:0x[0-9a-fA-F]+)+)") +# A bare "PC : 0x…" register-dump line names the faulting instruction directly. +PC_RE = re.compile(r"^(PC|EXCVADDR)\s*:\s*(0x[0-9a-fA-F]+)") +# The device announces which firmware it is running; used to catch a stale ELF. +SHA_RE = re.compile(r"ELF file SHA256:\s*([0-9a-f]+)") + + +def find_addr2line(firmware: str): + """The addr2line for this firmware's ISA, and the ELF to resolve against. + + Returns (tool, elf) or (None, None) when either is missing β€” decoding is then skipped and the + monitor still runs, because a missing toolchain must not cost you the serial output. + """ + elf = ROOT / "build" / f"esp32-{firmware}" / "projectMM.elf" + if not elf.exists(): + return None, None + # P4 is RISC-V; every other supported target is Xtensa. + pattern = ("riscv32-esp-elf/bin/riscv32-esp-elf-addr2line" if "p4" in firmware + else "xtensa-esp-elf/bin/xtensa-esp32*-elf-addr2line") + hits = glob.glob(os.path.expanduser(f"~/.espressif/tools/*/*/{pattern}")) + return (sorted(hits)[-1], str(elf)) if hits else (None, None) + + +def decode(tool: str, elf: str, addrs: list[str]) -> list[str]: + """Resolve addresses to `function at file:line`, one line each. Empty on any failure.""" + try: + out = subprocess.run([tool, "-pfiaC", "-e", elf, *addrs], + capture_output=True, text=True, timeout=20) + return [line for line in out.stdout.splitlines() if line.strip()] + except Exception: + return [] + # Shared moondeck.json + logLevel-toggle helpers (one level up, reachable from check/ and run/). sys.path.insert(0, str(ROOT / "moondeck")) from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 @@ -21,8 +66,31 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", required=True, help="Serial port") parser.add_argument("--baud", type=int, default=115200, help="Baud rate") + parser.add_argument("--firmware", help="Firmware variant whose ELF decodes panic backtraces " + "(e.g. esp32s3-n16r8). Without it, addresses print raw.") args = parser.parse_args() + tool = elf = elf_sha = None + # Cleared for the rest of the session once the device reports a firmware SHA that is not this + # ELF's. Starts true: until the device says otherwise, the requested ELF is the best answer. + sha_ok = True + if args.firmware: + tool, elf = find_addr2line(args.firmware) + if tool: + import hashlib + # The ELF can vanish or turn unreadable between find_addr2line and here (a rebuild + # mid-monitor). Decoding is the accessory; the serial output is the point β€” so a + # failure here drops to raw addresses rather than ending the session. + try: + elf_sha = hashlib.sha256(Path(elf).read_bytes()).hexdigest() + print(f"Decoding backtraces against build/esp32-{args.firmware}/projectMM.elf " + f"({elf_sha[:9]})") + except OSError as e: + tool = elf = elf_sha = None + print(f"Cannot read the ELF for {args.firmware} ({e}) β€” addresses print raw.") + if not tool: + print(f"No ELF or toolchain for {args.firmware} β€” addresses print raw.") + # Raise the device(s) to Info so the tick line shows while monitoring; restore each device's ORIGINAL # level on exit (monitor failure and normal Ctrl+C alike), so a device the user set to Debug/Error # keeps its choice. Best-effort β€” an un-networked device is skipped (and already logs at Info for its @@ -42,16 +110,78 @@ def main(): with open(LOG_FILE, "w") as log: try: while True: - line = ser.readline().decode("utf-8", errors="replace").rstrip("\r\n") + # A USB-CDC port on an S3/P4 is provided BY the firmware, so it disappears and + # re-enumerates on every reboot β€” exactly when the log matters most. Reconnect + # instead of dying: a crash-loop used to end the monitor with a traceback, losing + # the panic that caused it. Ctrl+C still stops, because that raises separately. + try: + raw = ser.readline() + except (serial.SerialException, OSError): + note = " -- device disconnected (reboot?), waiting for it to come back --" + print(note) + log.write(note + "\n") + log.flush() + sys.stdout.flush() + with suppress(Exception): + ser.close() + ser = None + while ser is None: + time.sleep(0.3) + with suppress(Exception): + ser = serial.Serial(args.port, args.baud, timeout=1) + note = " -- reconnected --" + print(note) + log.write(note + "\n") + log.flush() + sys.stdout.flush() + continue + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") if line: print(line) - sys.stdout.flush() log.write(line + "\n") + # Annotate a panic in place: the decoded frames follow the raw line, so the + # log keeps the original AND the reading, and a crash names its source line. + if tool: + # The device prints the SHA of the firmware it is running. If that is not + # the ELF we decode against, every resolved line would be from a different + # build β€” confidently wrong, which is worse than raw addresses. + sha = SHA_RE.match(line) + if sha and elf_sha and not elf_sha.startswith(sha.group(1)): + sha_ok = False + warn = (f" !! running firmware {sha.group(1)} != this ELF " + f"{elf_sha[:9]} β€” reflash to decode; addresses print raw") + print(warn) + log.write(warn + "\n") + addrs = [] + m = BACKTRACE_RE.search(line) + if m: + addrs = [p.split(":")[0] for p in m.group(1).split()] + if "CORRUPTED" in line: + note = (" !! stack chain corrupted β€” frames past the first are " + "not real; the fault is a stack overflow or a wild jump") + print(note) + log.write(note + "\n") + else: + pc = PC_RE.match(line) + if pc and pc.group(1) == "PC": + addrs = [pc.group(2)] + # Only decode against the ELF the device is actually running. A + # mismatch resolves every address in a different build's layout, which + # reads as fact and sends you to the wrong file. + for i, d in enumerate(decode(tool, elf, addrs) if sha_ok else []): + out = f" #{i} {d}" + print(out) + log.write(out + "\n") + sys.stdout.flush() log.flush() except KeyboardInterrupt: pass finally: - ser.close() + # `ser` is None while the reconnect loop waits for a rebooting board to come back, + # which is exactly when Ctrl+C is likely β€” closing unconditionally would end the + # session on an AttributeError traceback instead of the clean line below. + if ser is not None: + ser.close() print(f"\nStopped. Full log: {LOG_FILE}") if __name__ == "__main__": diff --git a/moondeck/scenario/run_live_scenario.py b/moondeck/scenario/run_live_scenario.py index ad0e5a1e..27602f5a 100644 --- a/moondeck/scenario/run_live_scenario.py +++ b/moondeck/scenario/run_live_scenario.py @@ -211,7 +211,7 @@ def find(modules): # Containers whose USER-ADDED children a scenario may clear/rebuild β€” the tree the # snapshot/restore protects. A scenario that clear_children's one of these destroys # the board's real config; restoring the snapshot afterward leaves the board as found. -_SNAPSHOT_CONTAINERS = {"Layouts", "Layers", "Drivers", "Services", "Layer"} +_SNAPSHOT_CONTAINERS = {"Layouts", "Effects", "Drivers", "Services", "Layer"} def _snapshot_tree(state: dict) -> list: @@ -331,7 +331,7 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, live_state = client.get("/api/state") target = _detect_target(live_state) # Walk the steps in order, growing the reachable set as add_module steps - # create ids. The containers (Layouts/Layers/Drivers) are always present. + # create ids. The containers (Layouts/Effects/Drivers) are always present. reachable = _collect_module_names(live_state) missing = [] for step in scenario.get("steps", []): diff --git a/moonlive/README.md b/moonlive/README.md new file mode 100644 index 00000000..9f133ea4 --- /dev/null +++ b/moonlive/README.md @@ -0,0 +1,28 @@ +# MoonLive scripts + +Scripts for the [MoonLive](../docs/moonmodules/light/MoonLiveEffect.md) engine, one file per script, +grouped by the module that runs it. Paste one into a module's `source` control on a running device +and it compiles to native code on the next tick. + +| folder | run by | a script writes | +|---|---|---| +| `layouts/` | [MoonLiveLayout](../docs/moonmodules/light/MoonLiveLayout.md) | where the lights physically are β€” `addLight(x, y, z)` | +| `effects/` | [MoonLiveEffect](../docs/moonmodules/light/MoonLiveEffect.md) | a colour per light β€” `setRGB(index, r, g, b)` | +| `modifiers/` | [MoonLiveModifier](../docs/moonmodules/light/MoonLiveModifier.md) | where one light lands β€” `setXYZ(0, x, y, z)` | + +Each module ships one of these as its default, so the folder doubles as the reference for what a +working script looks like. + +`unit_MoonLiveScripts` compiles every file here, so a script that stops parsing when the language +changes fails the build rather than waiting to be pasted into a device. + +## Debugging: print + +`print(v)` writes a value to the serial log and returns it, so it wraps any part of an expression +without changing the result β€” `addLight(print(xx), yy, 0)` places the same light and tells you what +`xx` was. + +**Take it out again when the script works.** A serial write blocks, and a script runs on the render +tick, so a print costs frame time every frame it survives. Each compile grants a short burst and then +goes quiet, which bounds the damage and gives every edit a fresh window; it does not make a print +free. No script in this folder ships with one. diff --git a/moonlive/effects/gradient.mlv b/moonlive/effects/gradient.mlv new file mode 100644 index 00000000..8cb47dd2 --- /dev/null +++ b/moonlive/effects/gradient.mlv @@ -0,0 +1,5 @@ +// A gradient painted by a loop: red rises across the strand while blue falls. +// The script that first proved `for` reaches the emitter with a distinct value each pass. +for (i = 0; i < 256; i = i + 1) { + setRGB(i, i, 255 - i, 60); +} diff --git a/moonlive/effects/lines.mlv b/moonlive/effects/lines.mlv new file mode 100644 index 00000000..88e4d988 --- /dev/null +++ b/moonlive/effects/lines.mlv @@ -0,0 +1,12 @@ +// A red column and a green row sweeping the grid. +// `width`/`height` come from the LAYER. The fill clears last frame. +uint8_t bpm = 30; // @control 1..240 + +fill(0, 0, 0); + +for (y = 0; y < height; y = y + 1) { + setRGB(y * width + scale(beat(bpm, t), width), 255, 0, 0); +} +for (x = 0; x < width; x = x + 1) { + setRGB(scale(beat(bpm, t), height) * width + x, 0, 255, 0); +} diff --git a/moonlive/effects/random-pixel.mlv b/moonlive/effects/random-pixel.mlv new file mode 100644 index 00000000..4a546814 --- /dev/null +++ b/moonlive/effects/random-pixel.mlv @@ -0,0 +1,3 @@ +// One random light in a random colour per frame. The shipped default: always visibly alive, +// and the smallest script that shows the engine running. +setRGB(random16(256), random16(256), random16(256), random16(256)); diff --git a/moonlive/layouts/diagonal.mlv b/moonlive/layouts/diagonal.mlv new file mode 100644 index 00000000..335da1a6 --- /dev/null +++ b/moonlive/layouts/diagonal.mlv @@ -0,0 +1,6 @@ +// A diagonal run β€” light i at (i, i). The kind of fixture that otherwise needs its own class. +uint8_t count = 16; // @control 1..64 + +for (i = 0; i < count; i = i + 1) { + addLight(i, i, 0); +} diff --git a/moonlive/layouts/grid.mlv b/moonlive/layouts/grid.mlv new file mode 100644 index 00000000..37977c85 --- /dev/null +++ b/moonlive/layouts/grid.mlv @@ -0,0 +1,10 @@ +// A grid, the layout almost every panel is. +// `cols`/`rows` are this layout's own controls; the logical grid comes from what it places. +uint8_t cols = 16; // @control 1..64 +uint8_t rows = 16; // @control 1..64 + +for (y = 0; y < rows; y = y + 1) { + for (x = 0; x < cols; x = x + 1) { + addLight(x, y, 0); + } +} diff --git a/moonlive/layouts/reversed-row.mlv b/moonlive/layouts/reversed-row.mlv new file mode 100644 index 00000000..f6d998b2 --- /dev/null +++ b/moonlive/layouts/reversed-row.mlv @@ -0,0 +1,6 @@ +// A strand wired right to left: light 0 sits at the far end. +uint8_t cols = 16; // @control 1..64 + +for (i = 0; i < cols; i = i + 1) { + addLight(cols - 1 - i, 0, 0); +} diff --git a/moonlive/layouts/ring.mlv b/moonlive/layouts/ring.mlv new file mode 100644 index 00000000..c2f04d48 --- /dev/null +++ b/moonlive/layouts/ring.mlv @@ -0,0 +1,10 @@ +// A circle: `count` lights evenly around a centre, spanning 2*radius+1 cells. +// Lights and grid cells differ -- 24 lights in an 11x11 box. +// `cos`/`sin` run 0..65535 centred at 32768, so scaling by the DIAMETER lands the whole circle. +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); +} diff --git a/moonlive/layouts/two-rows.mlv b/moonlive/layouts/two-rows.mlv new file mode 100644 index 00000000..3c0b9c6e --- /dev/null +++ b/moonlive/layouts/two-rows.mlv @@ -0,0 +1,10 @@ +// Two rows from one strand: out along y=0, back along y=1. +// The return row counts x DOWN -- the strand turns around at the far end. +uint8_t cols = 16; // @control 1..64 + +for (i = 0; i < cols; i = i + 1) { + addLight(i, 0, 0); +} +for (i = 0; i < cols; i = i + 1) { + addLight(cols - 1 - i, 1, 0); +} diff --git a/moonlive/modifiers/mirror.mlv b/moonlive/modifiers/mirror.mlv new file mode 100644 index 00000000..a206564a --- /dev/null +++ b/moonlive/modifiers/mirror.mlv @@ -0,0 +1,2 @@ +// Mirror along x. Reflecting around `width` (not a fixed 255) keeps every light in the grid. +setXYZ(0, width - 1 - x, y, z); diff --git a/moonlive/modifiers/shift.mlv b/moonlive/modifiers/shift.mlv new file mode 100644 index 00000000..0542817a --- /dev/null +++ b/moonlive/modifiers/shift.mlv @@ -0,0 +1,5 @@ +// Slide along x. A coordinate is a byte, so keep amount small enough that x + amount stays under +// 256 -- past that it wraps and the light reappears at the left edge. +uint8_t amount = 4; // @control 0..64 + +setXYZ(0, x + amount, y, z); diff --git a/moonlive/modifiers/transpose.mlv b/moonlive/modifiers/transpose.mlv new file mode 100644 index 00000000..4116e54e --- /dev/null +++ b/moonlive/modifiers/transpose.mlv @@ -0,0 +1,2 @@ +// Swap the axes: rows become columns. +setXYZ(0, y, x, z); diff --git a/src/core/ControlModule.h b/src/core/ControlModule.h index 07cda3eb..a1523335 100644 --- a/src/core/ControlModule.h +++ b/src/core/ControlModule.h @@ -21,7 +21,7 @@ namespace mm { /// generic β€” MoonLight's presets carry effects and modifiers only, ours carry whichever top-level /// subtrees the user chose to capture. /// -/// Top-level by necessity rather than convention: a preset reaches ACROSS Layouts, Layers, Drivers +/// Top-level by necessity rather than convention: a preset reaches ACROSS Layouts, Effects, Drivers /// and Services, so this module cannot be a child of any of them. /// /// **Not to be confused with `LightPresetsModule`**, which despite the name is a different thing: a @@ -39,7 +39,7 @@ namespace mm { /// The `capture` controls choose which top-level subtrees a save includes, and the file records the /// choice, so applying one is never a surprise about what it will touch. /// -/// That choice is what decides **portability**. A preset capturing `Layers` alone is a look: effects, +/// That choice is what decides **portability**. A preset capturing `Effects` alone is a look: effects, /// modifiers, their settings, and nothing about the hardware β€” it applies on any board and drives /// whatever that board has. Adding `Drivers` makes it a device snapshot that carries pin maps and /// lane counts, which is what you want for cloning a board and NOT what you want for sharing a look. @@ -70,19 +70,20 @@ class ControlModule : public MoonModule, public ListSource { static constexpr uint8_t kMaxNameLen = 32; /// The top-level subtrees a preset can carry. Names are `typeName()`s, which is what the file /// records and what `Scheduler` resolves them back to. - static constexpr const char* kCapturable[] = {"Layouts", "Layers", "Drivers", "Services"}; - /// The role each capturable subtree holds, so a pad can show what a preset covers with the same - /// emoji the module cards use (ROLE_EMOJI in the UI): one vocabulary rather than a second set - /// invented here. Index-aligned with kCapturable. - static constexpr const char* kCaptureRole[] = {"layout", "layer", "driver", "service"}; + static constexpr const char* kCapturable[] = {"Layouts", "Effects", "Drivers", "Services"}; + /// What each capturable subtree covers, named after the CONTAINER rather than after a module + /// inside it: a preset that captures `Effects` reports "effects". Calling it "layer" named the + /// container's child type, which reads as though the preset held a single Layer. Index-aligned + /// with kCapturable; the UI maps these to the same emoji the module cards use (ROLE_EMOJI). + static constexpr const char* kCaptureRole[] = {"layout", "effects", "driver", "service"}; static constexpr uint8_t kCaptureCount = sizeof(kCapturable) / sizeof(kCapturable[0]); static_assert(sizeof(kCapturable) / sizeof(kCapturable[0]) == sizeof(kCaptureRole) / sizeof(kCaptureRole[0]), "kCapturable and kCaptureRole are index-aligned"); - /// Index of "Layers" within kCapturable β€” the role a pure look occupies. - static constexpr uint8_t kLayersRole = 1; - static_assert(kCapturable[kLayersRole][0] == 'L' && kCapturable[kLayersRole][1] == 'a' && - kCapturable[kLayersRole][5] == 's', "kLayersRole must index Layers"); + /// Index of "Effects" within kCapturable β€” the role a pure look occupies. + static constexpr uint8_t kEffectsRole = 1; + static_assert(kCapturable[kEffectsRole][0] == 'E' && kCapturable[kEffectsRole][1] == 'f' && + kCapturable[kEffectsRole][6] == 's', "kEffectsRole must index Effects"); /// How many faders the bank shows. Fixed for now; the surfaces we will map onto this have 8 /// (X-Touch) or 9 (nanoKONTROL), so the count becomes a control once a second surface needs it. @@ -207,7 +208,7 @@ class ControlModule : public MoonModule, public ListSource { // ---- Presets as an external surface (Home Assistant, and any future consumer) -------------- // - // A preset carrying ONLY Layers is a look: it changes what the lights show and nothing else. One + // A preset carrying ONLY Effects is a look: it changes what the lights show and nothing else. One // that also carries Drivers or Layouts rewires pins or geometry, which must not be reachable from // a voice assistant or an automation that thinks it is picking a colour scheme. These two calls // are the whole seam a publisher needs, so no consumer has to learn the file format or the @@ -224,8 +225,8 @@ class ControlModule : public MoonModule, public ListSource { return n == 1 ? found : kCaptureCount; } - /// Is this preset a pure look? With one role per preset this is simply "its role is Layers". - bool isLookOnly(uint8_t row) const { return roleOf(row) == kLayersRole; } + /// Is this preset a pure look? With one role per preset this is simply "its role is Effects". + bool isLookOnly(uint8_t row) const { return roleOf(row) == kEffectsRole; } /// The preset's name, or null for an out-of-range row. const char* presetName(uint8_t row) const { @@ -254,7 +255,7 @@ class ControlModule : public MoonModule, public ListSource { /// The look applied most recently, or "" when none is. Reports the LAYER role's holder, since a /// look is by definition what occupies that role. - const char* currentLook() const { return current_[kLayersRole]; } + const char* currentLook() const { return current_[kEffectsRole]; } bool isEditableList() const override { return true; } @@ -687,7 +688,7 @@ class ControlModule : public MoonModule, public ListSource { } /// Is `type` in the comma-separated `captures` header? Whole-token match, so "Layer" never - /// matches "Layers". + /// matches "Effects". static bool listHas(const char* list, const char* type) { const size_t tlen = std::strlen(type); for (const char* p = list; *p;) { @@ -740,7 +741,7 @@ class ControlModule : public MoonModule, public ListSource { /// Which ONE subtree the next save captures, as an index into kCapturable. A preset carries /// exactly one role: "this preset is a look" is a thing a user can hold in their head, where /// "a look and a geometry, lit for one and superseded for the other" is not. - uint8_t captureRole_ = kLayersRole; // a look, by default + uint8_t captureRole_ = kEffectsRole; // a look, by default /// Which preset currently holds each capturable role, index-aligned with kCapturable. A /// preset that carries layout+layer claims both, so applying a layer-only preset afterwards /// replaces the layer holder and leaves the layout one lit. That is what a mixed preset means diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h index 6d8e85ad..2675e47c 100644 --- a/src/core/FilesystemModule.h +++ b/src/core/FilesystemModule.h @@ -137,7 +137,7 @@ class FilesystemModule : public MoonModule { /// caller that stores a subtree elsewhere (ControlModule's presets, one file per named preset) /// gets the persistence format for free rather than growing a second serializer that could /// drift from this one. Emits the enclosing `{}`; returns false only on allocation failure. - /// `prefix` namespaces every key ("Layers.0.type"), so several subtrees can share one flat + /// `prefix` namespaces every key ("Effects.0.type"), so several subtrees can share one flat /// object and `applySubtree` reads each back with the same prefix. Empty for a bare subtree. bool saveSubtreeTo(MoonModule* m, JsonSink& sink, const char* prefix = ""); diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index a9f0d096..b037b35a 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -1643,7 +1643,7 @@ HttpServerModule::OpResult HttpServerModule::applyAddModule( char* outName, size_t outNameLen) { if (!typeName || typeName[0] == 0) return OpResult::BadRequest; - // Top-level modules (Layouts/Layers/Drivers/Filesystem/System/Network/HttpServer) + // Top-level modules (Layouts/Effects/Drivers/Filesystem/System/Network/HttpServer) // are policy-fixed and wired in main.cpp at boot. Only *child* adds are allowed β€” // anything else would orphan the module (never ticked, leaked). if (!parentId || parentId[0] == 0) return OpResult::BadRequest; @@ -1805,7 +1805,7 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c return; } - // Top-level modules (Layouts/Layers/Drivers/Filesystem/System/Network/HttpServer) + // Top-level modules (Layouts/Effects/Drivers/Filesystem/System/Network/HttpServer) // have no parent β€” they're registered via Scheduler::addModule in main.cpp and the // top-level shape is policy-fixed. Reject the delete here instead of release+delete'ing // a module that the scheduler still holds a pointer to (which would dangle on next tick). diff --git a/src/core/ModuleFactory.h b/src/core/ModuleFactory.h index f5367300..ab7fbb60 100644 --- a/src/core/ModuleFactory.h +++ b/src/core/ModuleFactory.h @@ -109,7 +109,7 @@ class ModuleFactory { // Driver β†’ strip "Driver" (PreviewDriver β†’ Preview) // Generic β†’ strip "Module" (FilesystemModule β†’ Filesystem) // Layer β†’ no suffix (the class is literally named "Layer") - // Names without the suffix are returned unchanged (Layouts, Layers, Drivers). + // Names without the suffix are returned unchanged (Layouts, Effects, Drivers). static const char* displayNameFor(const char* typeName, ModuleRole role) { const char* suffix = ""; switch (role) { diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index c06dc30c..3deaa4a2 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -17,7 +17,7 @@ namespace mm { /// Read-vs-write is NOT a role distinction β€” direction is a per-module decision, not a role split β€” /// so one role spans the category, justified by that named roster, not one member (core grows /// slower than the domain, see CLAUDE.md). Services is the core-domain twin of the light domain's -/// `Layers`/`Drivers`: a top-level container of user-added children of one role. +/// `Effects`/`Drivers`: a top-level container of user-added children of one role. enum class ModuleRole : uint8_t { Generic, Effect, Modifier, Driver, Layout, Layer, Service }; /// Lowercase role name for JSON/API output. Single source of truth so the role diff --git a/src/core/Services.h b/src/core/Services.h index a94805fd..15018a4d 100644 --- a/src/core/Services.h +++ b/src/core/Services.h @@ -6,7 +6,7 @@ namespace mm { /// Top-level container for the user-added **Service** modules β€” capability bridges the device /// provides or consumes (Audio, IR): optional, per-board, added and removed at runtime. It is the -/// core-domain twin of the light domain's `Layers`/`Drivers`: a top-level container holding +/// core-domain twin of the light domain's `Effects`/`Drivers`: a top-level container holding /// user-added children of a single role, so the generic add/replace/delete/persistence machinery /// applies unchanged. Fixed device infrastructure (Tasks, I2cScan, Network, …) lives under /// `System`, not here β€” Services is exactly the mutable half of that split. diff --git a/src/core/math16.h b/src/core/math16.h index 93761a15..4515f11a 100644 --- a/src/core/math16.h +++ b/src/core/math16.h @@ -371,4 +371,29 @@ inline angle16 kaleido(angle16 a, uint8_t segments) { return static_cast(within); } +// --- 16-bit waveforms ------------------------------------------------------------------------ +// The 8-bit forms in math8.h cap at 255, which is short of the fixtures this drives: a 128x128 wall +// is 16384 lights, and an animation indexed through a 0..255 ramp quantises to coarse steps across +// it. These are the same textbook shapes at full 16-bit range, so a position scales to any axis +// length without the caller rescaling. + +// Triangle wave: 0 to 65535 over the first half of the cycle and back over the second β€” the fold of +// a ramp, the 16-bit twin of triwave8. Cheaper and sharper than a sine where an effect wants a +// linear sweep out and back. +constexpr uint16_t triwave16(uint16_t i) { + return i < 32768 ? static_cast(i * 2) + : static_cast((65535 - i) * 2); +} + +// beat16: a 0..65535 sawtooth completing `bpm` cycles per minute, measured from `timebase`. +// The 16-bit twin of beat8 β€” same FastLED semantics, full range, so `beat16(bpm) * n >> 16` lands +// on any axis length evenly rather than in 1/256ths. +constexpr uint16_t beat16(uint8_t bpm, uint32_t ms, uint32_t timebase = 0) { + if (bpm == 0) return 0; + const uint32_t period = 60000u / bpm; + if (period == 0) return 0; + const uint32_t pos = (ms - timebase) % period; + return static_cast((pos * 65536u) / period); +} + } // namespace mm diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index cd81bdbf..85872b1c 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -4,13 +4,6 @@ namespace mm::moonlive { -// Fixed cap for an emitted routine. Sized for the heaviest realistic single statement β€” a -// setRGB with all four arguments a host call (4 Γ— a full register-save call sequence, ~140 -// bytes each on RISC-V, the bulkiest ISA, plus the inline store). The emitter returns the real -// length and the unused tail is harmless; exec memory is cheap, so we size for the worst case -// rather than grow per script. Word-aligned so allocExec/writeExec's word-rounding never -// exceeds it. -static constexpr size_t kCodeCap = 768; // Drop the prior compilation's CODE (the exec block + the typed fn pointers), but NOT the control // arena β€” the arena's address must survive a recompile so a control pointer the binding bound to @@ -55,9 +48,9 @@ bool MoonLive::compile(uint8_t r, uint8_t g, uint8_t b) { return true; } -bool MoonLive::compile(const char* source, const BuiltinTable& table) { +bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysVarTable& sysvars) { uint8_t staging[kCodeCap]; - CompileResult cr = compileSource(source, table, staging, kCodeCap); + CompileResult cr = compileSource(source, table, sysvars, staging, kCodeCap); if (!cr.ok) { freeCode(); error_ = cr.error; return false; } // surface the parse diagnostic // Allocate the control arena (fixed address) and seed new slots, BEFORE publishing the control // set β€” ensureArena reads the previous controlCount_ to know which slots are new. @@ -89,9 +82,9 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table) { // control preserves the slider position). Returns false on alloc failure. bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { if (!ctrlArena_) { - ctrlArena_ = static_cast(platform::alloc(kMaxCtrls)); + ctrlArena_ = static_cast(platform::alloc(kArenaBytes)); if (!ctrlArena_) return false; - for (uint8_t i = 0; i < kMaxCtrls; i++) ctrlArena_[i] = 0; + for (uint8_t i = 0; i < kArenaBytes; i++) ctrlArena_[i] = 0; } for (uint8_t i = controlCount_; i < count; i++) ctrlArena_[i] = static_cast(decls[i].def); return true; diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index a3280dc8..bac22e45 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -37,7 +37,7 @@ class MoonLive { // built-ins β€” see MoonLiveBuiltins.h). The front-end parses an expression-call statement // and lowers it through the IR + per-ISA assembler. A parse/codegen error leaves the engine // !ok() with error() pointing at the diagnostic β€” the script editor's failure path. - bool compile(const char* source, const BuiltinTable& table); + bool compile(const char* source, const BuiltinTable& table, const SysVarTable& sysvars); // Compile the animated routine (color derived from the per-frame `t`). bool compileAnimated(); @@ -68,6 +68,14 @@ class MoonLive { // accounting. 0 until compiled / after free(). size_t codeCap() const { return codeCap_; } + /// Every heap byte this engine holds β€” the exec block plus the control arena. + /// + /// What a binding reports as its dynamicBytes: the card is supposed to show the memory the + /// module actually costs, and codeCap() alone missed the arena. The arena is small but it is a + /// real allocation with the module's lifetime, and "roughly right" is how a memory figure stops + /// being worth reading. + size_t heapBytes() const { return codeCap_ + (ctrlArena_ ? kArenaBytes : 0); } + // The controls the last compile() declared (empty if none / not a source compile). The binding // reads this to create real MoonModule controls bound to the arena slots. const DeclaredControl* declaredControls(uint8_t& count) const { count = controlCount_; return controls_; } @@ -75,7 +83,11 @@ class MoonLive { // reference here. nullptr if offset is out of range. The arena is allocated once at full // capacity (ensureArena) and never moves, so a bound control pointer stays valid for the // engine's lifetime, across every recompile (the stable-slot contract). - uint8_t* controlSlot(uint8_t offset) { return (ctrlArena_ && offset < controlCount_) ? &ctrlArena_[offset] : nullptr; } + /// The live byte at an arena offset: a script-declared control (offset < kMaxCtrls) or a host + /// system variable (above it). Bounded by the ARENA, not by controlCount_ β€” a system variable's + /// slot exists whether or not the script declared any control, and the binding writes it every + /// frame. Returns nullptr for an offset the arena does not hold. + uint8_t* controlSlot(uint8_t offset) { return (ctrlArena_ && offset < kArenaBytes) ? &ctrlArena_[offset] : nullptr; } private: // Shared post-emit step: copy `len` staged bytes into a fresh exec block. Returns the @@ -101,7 +113,7 @@ class MoonLive { CtrlFn ctrl_ = nullptr; // front-end-compiled routine (5-arg, reads the controls arena) const char* error_ = ""; - uint8_t* ctrlArena_ = nullptr; // live control-value bytes (platform::alloc, kMaxCtrls, fixed) + uint8_t* ctrlArena_ = nullptr; // live control + system-variable bytes (platform::alloc, kArenaBytes, fixed) uint8_t controlCount_ = 0; // controls the current program declared DeclaredControl controls_[kMaxCtrls] = {}; // the declared-control metadata for the binding }; diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 29bea357..f7fd8cf3 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -34,10 +34,17 @@ enum class InlineOp : uint8_t { enum class BuiltinKind : uint8_t { Call, Inline }; -// A host callable: one unsigned arg in, one unsigned result out (the shape a unary script -// helper like random16 has). A typed alias keeps the table and IR type-safe across desktop and -// ESP32 instead of threading a bare void*. -using HostCallFn = uint32_t (*)(uint32_t); +// A host callable. THREE unsigned args in, one unsigned result out. +// +// One argument covered a unary helper like random16, but a binding that hands the host a POSITION +// needs three at once β€” `addLight(x, y, z)` is the shape MoonLight's own script binding uses +// (`void addLight(uint16_t,uint16_t,uint16_t)`), and it is what lets a scripted layout emit a light +// instead of writing into an array the host has to size in advance. A unary helper simply ignores +// the arguments it was not given; the caller passes 0 for them. +// +// A typed alias keeps the table and IR type-safe across desktop and ESP32 instead of threading a +// bare void*. +using HostCallFn = uint32_t (*)(uint32_t, uint32_t, uint32_t); struct Builtin { const char* name = nullptr; // the script-visible name (host-owned) @@ -71,4 +78,92 @@ struct BuiltinTable { } }; +static constexpr uint8_t kMaxCtrls = 8; // a script declares a handful of controls; fixed, no heap + +// The controls arena holds two kinds of byte, in one allocation with a fixed split: +// [0 .. kMaxCtrls) script-declared controls, offset == declaration index +// [kMaxCtrls .. kArenaBytes) host system variables (width/height/…), offset assigned by +// the host and CONSTANT for the program's life +// System variables sit ABOVE the script's range so that adding or removing a control β€” which +// renumbers every control offset β€” cannot move them. The binding caches their slot pointers, so a +// moving offset would silently write the wrong byte. +// Fixed cap for an emitted routine, shared by the engine's staging buffer and EVERY backend's code +// buffer β€” they must agree, or a script that fits the caller's buffer still overflows the +// assembler's. One constant is what makes that structural instead of a comment in four files. +// +// It was once sized for the heaviest single STATEMENT, but a real effect is several statements, and +// the shipped `lines.mlv` emits 908 bytes on RISC-V against 461 on Xtensa for the same script: RISC-V +// is fixed-4-byte and saves the whole register pool around every call, so it needs roughly twice the +// room for identical work. Sizing to the DENSEST backend silently made a script that runs on an S3 +// fail on an S31. +// +// 2 KB covers the measured worst case with headroom. The emitter returns the real length and the +// live exec block is allocated to THAT, so the tail costs nothing beyond one staging buffer during +// the compile. Word-aligned so allocExec/writeExec's word-rounding never exceeds it. +static constexpr size_t kCodeCap = 2048; + +// The script text a binding holds. 1 KB, not 512 B: 512 could not hold a DOCUMENTED script β€” the +// shipped lines.mlv is ~490 characters with its comments β€” and a script that overruns is silently +// truncated mid-token, so it fails to compile with no hint that length was the reason. A binding is +// ~2 KB at this size, which the smallest board still carries. +static constexpr size_t kMaxScriptBytes = 1024; + +static constexpr uint8_t kMaxSysVars = 8; +static constexpr uint8_t kArenaBytes = kMaxCtrls + kMaxSysVars; + +/// A name the HOST defines and the script only reads: `width`, `height`, `depth`. Reserved β€” a +/// script cannot declare one, so the name means the same thing in every script (the `t` rule, one +/// construct wider). Distinct from a control: nobody sets it in the UI, and it never appears in +/// declaredControls(), so no binding has to hide it. +/// +/// The common case is read from the controls arena like a control is, because the value changes per frame and +/// the emitted code must not bake it in. The difference is ownership: the BINDING owns the slot +/// and writes it (from the layer), and the compiler reserves the slot rather than the script +/// declaring it. +enum class SysVarKind : uint8_t { + Arena, // a byte in the controls arena the binding writes per frame (width/height/depth) + Arg, // an argument register the host passes on every run (t) β€” costs no instruction +}; + +struct SysVar { + const char* name = nullptr; + SysVarKind kind = SysVarKind::Arena; + uint8_t where = 0; // Arena: byte offset into the arena. Arg: the VReg (kArg0..kArg4). +}; + +/// The system variables one host domain defines. Same shape and lookup as BuiltinTable β€” a host +/// hands the compiler both, and the compiler resolves names against them without knowing the domain. +struct SysVarTable { + // Bounded by the arena's system range, not chosen independently: a host that could register + // more system variables than the arena reserves would hand out an offset controlSlot() rejects, + // and the binding's per-frame write would be silently dropped. + static constexpr uint8_t kMax = kMaxSysVars; + SysVar items[kMax]; + uint8_t count = 0; + + // Rejects an offset the arena cannot hold, rather than storing it and failing at run time: + // controlSlot() would return nullptr for it and the binding's per-frame write would vanish + // with no error anywhere. An Arena slot must sit in the system range (above the script's + // controls, inside the arena); an Arg must name a real argument register. + bool add(const SysVar& v) { + if (count >= kMax || v.name == nullptr) return false; + if (v.kind == SysVarKind::Arena && (v.where < kMaxCtrls || v.where >= kArenaBytes)) + return false; + // kArg4 is the last argument register (MoonLiveIr.h owns the enum, and includes THIS + // header, so the bound is spelled here rather than referenced). + if (v.kind == SysVarKind::Arg && v.where > 4) return false; + items[count++] = v; + return true; + } + const SysVar* find(const char* name, size_t len) const { + for (uint8_t i = 0; i < count; i++) { + const char* n = items[i].name; + size_t j = 0; + for (; j < len && n[j]; j++) if (n[j] != name[j]) break; + if (j == len && n[j] == 0) return &items[i]; + } + return nullptr; + } +}; + } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index ad21ef72..d6d0622e 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -12,7 +12,8 @@ namespace { // `ControlAnno` is a captured `// @control min..max` comment (a control's UI range). A plain // `//` line comment is skipped like whitespace; only the @control form becomes a token, carrying // its min/max in annoMin/annoMax. `Assign` is `=` (a control declaration's initializer). -enum class Tok { Ident, Number, Assign, LParen, RParen, Comma, Semicolon, ControlAnno, End, Error }; +enum class Tok { Ident, Number, Assign, LParen, RParen, LBrace, RBrace, Comma, Semicolon, + ControlAnno, Plus, Minus, Star, Less, End, Error }; struct Lexer { const char* p; @@ -76,6 +77,17 @@ struct Lexer { if (c == ')') { p++; kind = Tok::RParen; return; } if (c == ',') { p++; kind = Tok::Comma; return; } if (c == ';') { p++; kind = Tok::Semicolon; return; } + if (c == '+') { p++; kind = Tok::Plus; return; } + if (c == '-') { p++; kind = Tok::Minus; return; } + if (c == '*') { p++; kind = Tok::Star; return; } + if (c == '{') { p++; kind = Tok::LBrace; return; } + if (c == '}') { p++; kind = Tok::RBrace; return; } + if (c == '<') { p++; kind = Tok::Less; return; } + // '/' only reaches here when it is NOT the `//` a comment starts with (handled above). + // '/' and '%' are deliberately NOT tokens yet. No ISA here has a cheap integer divide, so + // both would lower to a host call β€” which the light domain already ships as `mod(a, b)` and + // `turn(n)`, so the capability exists under a name instead of an operator. A script using + // the character gets "unexpected character", which is the honest answer. Backlogged. if (isDigit(c)) { long v = 0; readNumber(v); number = v; kind = Tok::Number; return; @@ -98,10 +110,19 @@ struct Lexer { struct Parser { Lexer& lex; const BuiltinTable& table; + const SysVarTable& sysvars; IrProgram& ir; VReg nextTemp = kFirstTemp; // high-water mark β€” also IrProgram.vregsUsed VReg freeStack[kMaxVRegs] = {}; // recycled temps (LIFO), so a dead vreg is reused uint8_t freeCount = 0; + // Script-local variables β€” today only a `for` loop's counter. Distinct from a declared control + // (a control is a UI value the script READS; a local is one the script WRITES) and from a temp + // (a temp is write-once and recycled). Held in a vreg for the loop's lifetime. + struct Local { const char* name; size_t nameLen; VReg reg; }; + Local locals[4] = {}; + uint8_t localCount = 0; + uint8_t nextLabel = 0; // IR label ids, handed out in source order + DeclaredControl controls[kMaxCtrls] = {}; // controls the script declared (decl lines) uint8_t controlCount = 0; const char* error = ""; @@ -132,7 +153,13 @@ struct Parser { fail("script too complex (out of registers)"); return kFirstTemp; } - void freeTemp(VReg v) { if (v >= kFirstTemp && freeCount < kMaxVRegs) freeStack[freeCount++] = v; } + void freeTemp(VReg v) { + // A loop variable's register is live for the whole loop, and parseExpr hands it back + // directly (see parsePrimary), so a consumer freeing "its" operand would recycle a vreg the + // loop still reads and the counter would be overwritten mid-iteration. + for (uint8_t i = 0; i < localCount; i++) if (locals[i].reg == v) return; + if (v >= kFirstTemp && freeCount < kMaxVRegs) freeStack[freeCount++] = v; + } // Append an IR op, failing the compile if the program is full or names an out-of-budget // vreg (IrProgram::push validates both). Centralises the check so no call site forgets it. @@ -144,11 +171,73 @@ struct Parser { return true; } - // expr := number | ident | call. Returns the vreg holding the value (or 0 on failure). A bare - // ident that names a declared control reads its live value (a LoadCtrl of its arena offset); an - // ident followed by `(` is a call. + // expr := term { ("+" | "-") term } + // term := primary { "*" primary } + // primary := number | ident | call | "(" expr ")" + // + // Precedence climbing, the textbook shape: each level consumes the tighter-binding one below + // it, so `2 + 3 * 4` is 14 rather than 20 without any special case. Every operator lowers to IR + // the three backends ALREADY have (Const/Add/Mul) β€” a - b is emitted as a + (b * -1), because + // no ISA here has a subtract and Xtensa's add-immediate encodes only 1..15, so negating the + // immediate would silently produce a wrong constant. VReg parseExpr() { + VReg lhs = parseTerm(); + while (!failed && (lex.kind == Tok::Plus || lex.kind == Tok::Minus)) { + const bool negate = (lex.kind == Tok::Minus); + lex.advance(); + VReg rhs = parseTerm(); + if (failed) return 0; + if (negate) { + VReg m = alloc(); + emit({IrOp::Const, m, 0,0,0,0, -1, nullptr, {}}); + VReg n = alloc(); + emit({IrOp::Mul, n, rhs, m, 0,0, 0, nullptr, {}}); + freeTemp(m); freeTemp(rhs); + rhs = n; + } + VReg dst = alloc(); + emit({IrOp::Add, dst, lhs, rhs, 0,0, 0, nullptr, {}}); + freeTemp(lhs); freeTemp(rhs); + lhs = dst; + } + return lhs; + } + + VReg parseTerm() { + VReg lhs = parsePrimary(); + while (!failed && lex.kind == Tok::Star) { + lex.advance(); + VReg rhs = parsePrimary(); + if (failed) return 0; + VReg dst = alloc(); + emit({IrOp::Mul, dst, lhs, rhs, 0,0, 0, nullptr, {}}); + freeTemp(lhs); freeTemp(rhs); + lhs = dst; + } + return lhs; + } + + // A bare ident that names a declared control reads its live value (a LoadCtrl of its arena + // offset); an ident followed by `(` is a call. + VReg parsePrimary() { if (failed) return 0; + if (lex.kind == Tok::LParen) { // grouping + lex.advance(); + VReg v = parseExpr(); + if (!expect(Tok::RParen, "expected ')'")) return 0; + return v; + } + if (lex.kind == Tok::Minus) { // unary minus: 0 - v, as (v * -1) + lex.advance(); + VReg v = parsePrimary(); + if (failed) return 0; + VReg m = alloc(); + emit({IrOp::Const, m, 0,0,0,0, -1, nullptr, {}}); + VReg dst = alloc(); + emit({IrOp::Mul, dst, v, m, 0,0, 0, nullptr, {}}); + freeTemp(m); freeTemp(v); + return dst; + } if (lex.kind == Tok::Number) { if (lex.number < 0 || lex.number > 65535) { fail("number out of range (0..65535)"); return 0; } VReg v = alloc(); @@ -157,6 +246,29 @@ struct Parser { return v; } if (lex.kind == Tok::Ident) { + // A system variable the host defines: `t` (elapsed ms, an argument register) or a + // per-frame value the binding writes (`width`/`height`/`depth`, arena slots). Resolved + // BEFORE locals and controls so the name means one thing in every script; the + // declaration paths reject the name, so nothing can shadow it. + if (const SysVar* sv = sysvars.find(lex.identBeg, lex.identLen)) { + lex.advance(); + if (sv->kind == SysVarKind::Arg) return sv->where; // free: already in a register + VReg v = alloc(); + emit({IrOp::LoadCtrl, v, 0,0,0,0, sv->where, nullptr, {}}); + return v; + } + for (uint8_t li = 0; li < localCount; li++) { + if (locals[li].nameLen == lex.identLen && + std::strncmp(locals[li].name, lex.identBeg, lex.identLen) == 0) { + // Return the variable's OWN register rather than copying it into a temp. A copy + // per read burns a vreg each time, and the budget is small β€” the lowerer needs + // three scratch registers above the program's high-water mark, so a script has + // about six temps in total. Callers must therefore not freeTemp() a local; the + // free-list only ever holds values alloc() handed out. + lex.advance(); + return locals[li].reg; + } + } int ci = findControl(lex.identBeg, lex.identLen); if (ci >= 0) { // a declared control read VReg v = alloc(); @@ -200,7 +312,7 @@ struct Parser { // 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. - if (fn->kind == BuiltinKind::Call && fn->argc > 1) { fail("multi-argument calls are not supported"); return; } + if (fn->kind == BuiltinKind::Call && fn->argc > 3) { fail("a call takes at most three arguments"); return; } if (resultOut) { if (fn->kind != BuiltinKind::Call || !fn->returns) { fail("this function does not return a value"); return; } @@ -210,7 +322,7 @@ struct Parser { // safe even when result == arg. for (uint8_t i = 0; i < n; i++) freeTemp(args[i]); VReg r = alloc(); - emit({IrOp::Call, r, args[0], 0,0,0, 0, fn->fn, {}}); + emit({IrOp::Call, r, args[0], args[1], args[2], 0, 0, fn->fn, {}}); *resultOut = r; } else { // A statement call. Call kinds with a result are also allowed as statements (result @@ -218,7 +330,7 @@ struct Parser { if (fn->kind == BuiltinKind::Call) { for (uint8_t i = 0; i < n; i++) freeTemp(args[i]); VReg r = alloc(); - emit({IrOp::Call, r, args[0], 0,0,0, 0, fn->fn, {}}); + emit({IrOp::Call, r, args[0], args[1], args[2], 0, 0, fn->fn, {}}); freeTemp(r); } else { // Inline op: hand the operand vregs to the backend via an Inline IR op. The @@ -235,6 +347,7 @@ struct Parser { if (lex.kind != Tok::Ident) { fail("expected a control name after the type"); return; } const char* name = lex.identBeg; size_t nameLen = lex.identLen; if (nameLen >= kMaxControlName) { fail("control name too long"); return; } // no silent truncation downstream + if (sysvars.find(name, nameLen)) { fail("name is a system variable"); return; } if (findControl(name, nameLen) >= 0) { fail("duplicate control name"); return; } // A control name must not shadow a builtin: a declared `random16` would make `random16(…)` // ambiguous (control read vs call). Reject it at the source so the resolution never collides. @@ -274,16 +387,164 @@ struct Parser { // program := { decl } { stmt }. Declarations (control vars) come first, then one-or-more // call statements. (Multi-statement now: a script has decl lines AND a statement line.) + /// stmt := call ";" | forStmt + /// forStmt := "for" "(" ident "=" expr ";" ident "<" expr ";" ident "=" expr ")" "{" {stmt} "}" + /// + /// C-style deliberately: it is the form a script author already knows, and the third clause is + /// what a serpentine layout needs (`i = i + 2`, or counting down) without inventing more syntax. + /// + /// Lowered as a BOTTOM-TESTED loop, which is the shape FillElems has always emitted by hand and + /// so is proven on all three ISAs: + /// + /// i = init + /// BranchGe i, limit -> done ; an empty range runs the body zero times + /// top: + /// body + /// i = step + /// BranchNe i, limit -> top ; back edge + /// done: + /// + /// The back edge tests NOT-EQUAL rather than less-than because no ISA here has branch-if-less; + /// that is exact for the `i = i + 1` case and terminates for any step that eventually hits the + /// limit. A step that overshoots (`i = i + 3` over a limit it skips past) would not β€” so the + /// limit is re-tested with BranchGe at the top of each iteration instead. See below. + bool parseFor() { + lex.advance(); // consume `for` + if (!expect(Tok::LParen, "expected '(' after for")) return false; + + // --- init: ident = expr --- + if (lex.kind != Tok::Ident) { fail("expected a loop variable"); return false; } + if (localCount >= 4) { fail("too many nested loops"); return false; } + const char* varName = lex.identBeg; + const size_t varLen = lex.identLen; + if (sysvars.find(varName, varLen)) { fail("name is a system variable"); return false; } + // A nested loop reusing the enclosing loop's name would bind a SECOND register to that name: + // the inner step then writes the register the outer back edge tests, and the emitted program + // never terminates β€” a hang on the render task from a script a user can type. Refused for the + // same reason a duplicate control name is. + for (uint8_t li = 0; li < localCount; li++) + if (locals[li].nameLen == varLen && + std::strncmp(locals[li].name, varName, varLen) == 0) { + fail("loop variable already in use"); return false; + } + lex.advance(); + if (!expect(Tok::Assign, "expected '=' in the for's first clause")) return false; + VReg init = parseExpr(); + if (failed) return false; + VReg counter = alloc(); + emit({IrOp::Mov, counter, init, 0,0,0, 0, nullptr, {}}); + freeTemp(init); + const uint8_t myLocal = localCount; + locals[localCount++] = {varName, varLen, counter}; + if (!expect(Tok::Semicolon, "expected ';' after the for's first clause")) return false; + + // --- condition: ident < expr (the only comparison the language has) --- + // The name must be the loop variable: the emitted code tests `counter` whatever is written + // here, so a different name compiles clean and runs as if it said the right one. That is a + // wrong fixture with no diagnostic anywhere β€” the failure mode hardest to trace back to a + // typo. (`for (y…) { for (x = 0; y < cols; x = x + 1) … }` is the realistic version.) + if (lex.kind != Tok::Ident) { fail("expected the loop variable in the condition"); return false; } + if (lex.identLen != varLen || std::strncmp(lex.identBeg, varName, varLen) != 0) { + fail("the condition must test the loop variable"); return false; + } + lex.advance(); + if (!expect(Tok::Less, "expected '<' β€” it is the only comparison a for condition takes")) return false; + // Hold the bound in the vreg parseExpr produced rather than copying it into a fresh one. + // The copy cost a register for the whole body, and the budget is small: Xtensa maps twelve + // registers, five of which are argument slots, so a NESTED loop plus a three-argument call + // ran out and the compile was refused on that target while succeeding on the host. + VReg limit = parseExpr(); + if (failed) return false; + if (!expect(Tok::Semicolon, "expected ';' after the for's condition")) return false; + + // --- step: ident = expr (parsed now, emitted after the body) --- + if (lex.kind != Tok::Ident) { fail("expected the loop variable in the step"); return false; } + if (lex.identLen != varLen || std::strncmp(lex.identBeg, varName, varLen) != 0) { + fail("the step must advance the loop variable"); return false; // it advances `counter` regardless + } + lex.advance(); + if (!expect(Tok::Assign, "expected '=' in the for's third clause")) return false; + const char* stepSrc = lex.tokBeg; // re-lexed after the body + // Skip the step expression without emitting: scan to the closing ')'. + int depth = 0; + while (!failed && lex.kind != Tok::End) { + // A lexer error stops the scan. Tok::Error is not Tok::End and advance() does not move + // past the offending character, so without this the loop spins forever on a script with + // a stray character in the step expression β€” a hang, not a diagnostic. + 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(); + } + if (!expect(Tok::RParen, "expected ')' to close the for")) return false; + if (!expect(Tok::LBrace, "expected '{' β€” a for's body is braced")) return false; + + if (nextLabel + 2 > kIrLabels) { fail("too many loops in one script"); return false; } + const uint8_t lDone = nextLabel++; + const uint8_t lTop = nextLabel++; + + emit({IrOp::BranchGe, 0, counter, limit, 0,0, lDone, nullptr, {}}); // empty range + emit({IrOp::Label, 0, 0,0,0,0, lTop, nullptr, {}}); + + while (!failed && lex.kind != Tok::RBrace && lex.kind != Tok::End) { + if (!parseStatement()) return false; + } + if (!expect(Tok::RBrace, "expected '}' to close the for's body")) return false; + + // The step, re-lexed from the source it was skipped over. + { + Lexer stepLex(stepSrc); + Lexer save = lex; + lex = stepLex; + VReg s = parseExpr(); + if (failed) return false; + // parseExpr stops at the first token it cannot consume, so without this the step + // silently ignores whatever follows it β€” `i = i + 1 garbage` compiled clean. The + // skip-scan above already found the real ')', so anything else here is a typo. + if (lex.kind != Tok::RParen) { + lex = save; fail("unexpected token in the for's step"); return false; + } + emit({IrOp::Mov, counter, s, 0,0,0, 0, nullptr, {}}); + freeTemp(s); + lex = save; + } + // Re-test the limit at the top rather than relying on equality alone: a step that jumps + // PAST the limit would never make counter == limit, and the loop would run away. + emit({IrOp::BranchGe, 0, counter, limit, 0,0, lDone, nullptr, {}}); + emit({IrOp::BranchNe, 0, counter, limit, 0,0, lTop, nullptr, {}}); + emit({IrOp::Label, 0, 0,0,0,0, lDone, nullptr, {}}); + + freeTemp(limit); + localCount = myLocal; // the loop variable leaves scope + // ...and its REGISTER goes back to the pool. Dropping only the name left the vreg allocated + // for the rest of the compile, so every `for` a script wrote cost one permanently β€” two + // sequential loops held two counters even though the first was dead. That is what put a + // two-loop effect one register over the smallest file (Xtensa has twelve) while each loop + // compiled fine alone. Freed only AFTER localCount drops, since freeTemp refuses to recycle + // a register any live local still names. + freeTemp(counter); + return true; + } + + /// One statement: a call, or a for. + bool parseStatement() { + if (lex.kind == Tok::Ident && lex.identLen == 3 && + std::strncmp(lex.identBeg, "for", 3) == 0) { + return parseFor(); + } + if (lex.kind != Tok::Ident) { fail("expected a function call"); return false; } + parseCall(nullptr); + if (failed) return false; + return expect(Tok::Semicolon, "expected ';'"); + } + bool parseProgram() { while (!failed && atTypeKeyword()) { lex.advance(); parseDecl(); } if (failed) return false; if (lex.kind == Tok::End) { fail("empty program (no statement)"); return false; } bool any = false; while (!failed && lex.kind != Tok::End) { - if (lex.kind != Tok::Ident) { fail("expected a function call"); return false; } - parseCall(nullptr); // a statement - if (failed) return false; - if (!expect(Tok::Semicolon, "expected ';'")) return false; + if (!parseStatement()) return false; any = true; } if (!any) { fail("expected a statement"); return false; } @@ -293,18 +554,19 @@ struct Parser { } // namespace -CompileResult compileSource(const char* source, const BuiltinTable& table, uint8_t* out, size_t cap) { +CompileResult compileSource(const char* source, const BuiltinTable& table, + const SysVarTable& sysvars, uint8_t* out, size_t cap) { CompileResult r; if (!source) { r.error = "no source"; return r; } if (!out || cap == 0) { r.error = "no code buffer"; return r; } Lexer lex(source); IrProgram ir; - Parser parser{lex, table, ir}; + Parser parser{lex, table, sysvars, ir}; if (!parser.parseProgram()) { r.error = parser.error; r.errorCol = parser.errorCol; return r; } size_t len = lowerToBytes(ir, out, cap); - if (len == 0) { r.error = "codegen failed (unsupported on this target, or too large)"; return r; } + if (len == 0) { r.error = kCodegenFailed; return r; } r.ok = true; r.len = len; // Surface the declared controls so the binding can create real MoonModule controls. diff --git a/src/core/moonlive/MoonLiveCompiler.h b/src/core/moonlive/MoonLiveCompiler.h index 791591d6..de6b4f98 100644 --- a/src/core/moonlive/MoonLiveCompiler.h +++ b/src/core/moonlive/MoonLiveCompiler.h @@ -20,6 +20,12 @@ namespace mm::moonlive { +/// The diagnostic when lowering produces nothing: no backend for this ISA, or a program too large. +/// Named so a test can distinguish "this host has no JIT" from "this script is wrong" without +/// matching on prose. +inline constexpr const char* kCodegenFailed = "codegen failed (unsupported on this target, or too large)"; + + // Result of compiling source: on success, ok==true and the bytes are in out[0..len). On // failure, ok==false and error points at a static diagnostic (1-based column, 0 if n/a). struct CompileResult { @@ -35,6 +41,9 @@ struct CompileResult { // Compile `source` to machine code in `out` (capacity `cap`), resolving calls against `table`. // Pure: no I/O, no allocation beyond the caller's buffer. -CompileResult compileSource(const char* source, const BuiltinTable& table, uint8_t* out, size_t cap); +/// `sysvars` are names the HOST defines and a script may only read (`t`, `width`, …). They are +/// reserved: a declaration that reuses one fails to compile, so a name means one thing everywhere. +CompileResult compileSource(const char* source, const BuiltinTable& table, + const SysVarTable& sysvars, uint8_t* out, size_t cap); } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 27e7591a..598e0b83 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -33,19 +33,34 @@ static constexpr uint8_t kMaxVRegs = 16; // a statement uses a handful; no a static constexpr uint8_t kMaxIrOps = 64; // a statement is a handful of ops; fixed, no heap // The op set β€” neutral. Three-address form: dst plus up to three source operands. (Counted -// loops and bounds guards are not IR ops: the StoreElem/FillElems inline ops carry their own -// loop + bounds-guard in the per-ISA lowering. They'll arrive here when a script statement needs -// a general loop β€” added then, not speculatively now.) +// Control flow arrived with the script-level `for`, which is what the note here anticipated: the +// StoreElem/FillElems inline ops still carry their own loop in the per-ISA lowering, but a loop a +// SCRIPT writes cannot live inside one opcode. Three ops carry it, and they are deliberately the +// smallest set that every backend already has instructions for β€” a label is a position, and the +// only branch every ISA here exposes is compare-and-branch-if-greater-or-equal (arm64 spells it +// cmp + b.hs, Xtensa and RISC-V have bgeu directly). enum class IrOp : uint8_t { Const, // dst = imm Add, // dst = a + b AddImm, // dst = a + imm Mul, // dst = a * b - Call, // dst = (*callFn)(a) β€” call a host-registered function (callFn = the C fn ptr) + Call, // dst = (*callFn)(a, b, c) β€” call a host-registered function. Three operands + // because a binding that hands the host a POSITION needs them at once; a + // unary helper ignores b and c, and the compiler passes a zero vreg. Inline, // a host-registered inline op (inlineOp tag); operands a/b/c/d (op-specific) LoadCtrl, // dst = ((const uint8_t*)kArg4)[imm] β€” read a control value byte at offset imm + Mov, // dst = a β€” the assignment a loop variable needs (vregs are otherwise write-once) + Label, // a branch target; `imm` is the label id. Emits no instruction. + BranchGe, // if (a >= b) goto label `imm` β€” UNSIGNED. The loop's ENTRY guard: skip a loop + // whose range is empty, which is also what makes `for (i = 0; i < 0; …)` correct. + BranchNe, // if (a != b) goto label `imm` β€” the BACKWARD edge that closes the loop. }; +// Why these two branches and no unconditional jump: a bottom-tested loop needs exactly an entry +// guard and a back edge, and every backend here already has both (bgeu / bne on Xtensa and RISC-V, +// cmp + b.hs / b.ne on arm64). It is the shape FillElems has always lowered by hand, so the +// instruction sequence is proven on all three ISAs before a script could ever emit one. + struct IrInst { IrOp op; VReg dst = 0; @@ -69,7 +84,14 @@ struct DeclaredControl { uint8_t offset = 0; // byte offset into the controls arena (declaration order) }; -static constexpr uint8_t kMaxCtrls = 8; // a script declares a handful of controls; fixed, no heap +/// Branch targets one IR program may use. Two per `for` (entry guard + back edge), and the counter +/// runs for the whole program rather than per scope β€” a label is never reused once a loop closes β€” +/// so this bounds the TOTAL number of loops in a script (8), not how deeply they nest. The +/// assemblers carry the same ceiling in their own label tables, and the compiler fails loudly +/// rather than silently miscompiling past it. Nesting depth is bounded separately, by `locals`. +static constexpr uint8_t kIrLabels = 16; + + static constexpr uint8_t kMaxControlName = 24; // max control-name length (incl. NUL); the compiler // rejects longer names so the binding's name pool // can't truncate distinct names into a collision @@ -90,6 +112,20 @@ struct IrProgram { if (i.dst + 1 > vregsUsed) vregsUsed = static_cast(i.dst + 1); return true; } + + /// Which inline ops this program contains, so a backend reserves scratch only for what is there. + /// + /// The backends reserved their maximum unconditionally, which cost a register no matter what the + /// script did β€” and that one register is what made a nested loop refuse to compile on the + /// smallest target, since a layout script neither fills nor stores elements. How MANY scratch + /// registers each op costs is per-ISA (the host needs one for StoreElem, which Xtensa and RISC-V + /// fold into the index vreg) and stays with each backend; WHICH ops are present is a property of + /// the program, so it is answered once here. + bool hasInline(InlineOp which) const { + for (uint8_t i = 0; i < count; i++) + if (ops[i].op == IrOp::Inline && ops[i].inlineOp == which) return true; + return false; + } }; } // namespace mm::moonlive diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 33c111eb..8af36c73 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -5,7 +5,7 @@ #include "core/ActiveInstance.h" // the summary-seat election (the seat + its RAII vacate) #include "light/layers/Buffer.h" #include "light/layers/Layer.h" -#include "light/layers/Layers.h" +#include "light/layers/Effects.h" #include "light/layers/BlendMap.h" #include "light/drivers/Correction.h" #include "light/Palette.h" // the global active palette + its select control @@ -24,7 +24,7 @@ namespace mm { /// **Naming convention.** Capital `Drivers` is the container class; lowercase /// "driver"/"drivers" is the English singular/plural for individual `DriverBase` /// children. Capitalisation disambiguates "the Drivers container" from "two drivers -/// running" (same rule for `Layouts`/layout and `Layers`/layer). +/// running" (same rule for `Layouts`/layout and `Effects`/effect). /// /// **Shared output buffer.** Necessary because blend+map writes to arbitrary physical /// positions via LUT β€” the output is not filled sequentially, so a driver cannot read @@ -35,7 +35,7 @@ namespace mm { /// (the zero-copy fast path, at the cost of parallelism). /// /// **Multi-layer composition.** When two or more layers are enabled, Drivers composites -/// them into the shared output buffer each frame in Layers container order (bottomβ†’top, +/// them into the shared output buffer each frame in Effects container order (bottomβ†’top, /// via `forEachEnabledLayer`). The bottom layer clears and overwrites the buffer; each /// layer above blends onto the accumulated frame per its own `blendMode` and `opacity` /// (the inert per-Layer controls). Drivers owns the orchestration because only it sees @@ -176,18 +176,18 @@ class Drivers : public MoonModule { uint8_t palette = 0; // Two ways to wire the source Layer: - // - setLayers(Layers*): bind the container; layer_ is re-resolved from + // - setEffects(Effects*): bind the container; layer_ is re-resolved from // activeLayer() at every prepareTree. This makes the link self-healing β€” // a Layer cleared and rebuilt via the API (clear_children + add_module) // is picked up on the next prepareTree without re-running main.cpp wiring. // - setLayer(Layer*): pin a specific Layer directly (test rigs that build a - // Layer outside a Layers container). Skips re-resolution. - void setLayers(Layers* layers) { - layers_ = layers; - if (layers_) layer_ = layers_->activeLayer(); + // Layer outside an Effects container). Skips re-resolution. + void setEffects(Effects* layers) { + effects_ = layers; + if (effects_) layer_ = effects_->activeLayer(); } void setLayer(Layer* layer) { - layers_ = nullptr; // explicit pin overrides container resolution + effects_ = nullptr; // explicit pin overrides container resolution layer_ = layer; } @@ -283,8 +283,8 @@ class Drivers : public MoonModule { void prepare() override { // Re-resolve the active Layer from the bound container so a Layer that // was cleared and rebuilt via the API is picked up here (self-healing). - // setLayer() pins a Layer directly and leaves layers_ null β€” skip then. - if (layers_) layer_ = layers_->activeLayer(); + // setLayer() pins a Layer directly and leaves effects_ null β€” skip then. + if (effects_) layer_ = effects_->activeLayer(); // The output (composition) buffer is needed when we must blend into a // physical-space buffer rather than hand a driver a Layer's logical buffer // directly: whenever β‰₯2 layers composite, OR a single layer has a LUT @@ -300,10 +300,10 @@ class Drivers : public MoonModule { // fallback activeLayer() may return (which exists only so geometry stays // queryable while every layer is toggled off). With no enabled layer there // is nothing to emit, so no output buffer β€” drivers go idle (see - // passBufferToDrivers). A pinned setLayer() (layers_ null) is always treated + // passBufferToDrivers). A pinned setLayer() (effects_ null) is always treated // as the live source. - Layer* const out = layers_ ? layers_->firstEnabledLayer() : layer_; - const uint8_t enabled = layers_ ? layers_->enabledLayerCount() : (layer_ ? 1 : 0); + Layer* const out = effects_ ? effects_->firstEnabledLayer() : layer_; + const uint8_t enabled = effects_ ? effects_->enabledLayerCount() : (layer_ ? 1 : 0); const bool needOutput = out && (enabled > 1 || out->lut().hasLUT()); // The render↔encode split wants an outputBuffer_ EVEN in the identity case (a lone no-LUT @@ -377,7 +377,7 @@ class Drivers : public MoonModule { bool firstOutputRgb(uint8_t out[3]) const override { const Buffer* src = nullptr; if (outputBuffer_.data()) src = &outputBuffer_; - else if (Layer* l = layers_ ? layers_->firstEnabledLayer() : layer_; l && l->buffer().data()) + else if (Layer* l = effects_ ? effects_->firstEnabledLayer() : layer_; l && l->buffer().data()) src = &l->buffer(); if (!src || src->count() == 0 || src->channelsPerLight() < 3) return false; const uint8_t* p = src->data(); @@ -408,9 +408,9 @@ class Drivers : public MoonModule { // The single-layer source, resolved ONCE: both single-layer branches below need the same // value, and declaring it per-branch in an if-init shadowed the outer one (MSVC C4456 β€” // legitimately: two `Layer* out` in one chain reads as a bug even when it isn't). - Layer* srcLayer = layers_ ? layers_->firstEnabledLayer() : layer_; + Layer* srcLayer = effects_ ? effects_->firstEnabledLayer() : layer_; - if (outputBuffer_.data() && layers_ && layers_->enabledLayerCount() > 1) { + if (outputBuffer_.data() && effects_ && effects_->enabledLayerCount() > 1) { // Multi-layer composite: blend each enabled layer in container order. // The first (bottom) layer clears + overwrites; each subsequent layer // blends onto the accumulated frame per its own blendMode + opacity. @@ -418,7 +418,7 @@ class Drivers : public MoonModule { // specialized loop each β€” no-LUT layers blend 1:1, LUT layers map), // and a full-opacity additive/overwrite layer pays no alpha math, so // cost scales with enabled-layer count only. - layers_->forEachEnabledLayer([&](Layer* L, bool first) { + effects_->forEachEnabledLayer([&](Layer* L, bool first) { BlendOp op = first ? BlendOp::Overwrite : L->blendOp(); uint8_t op_opacity = first ? 255 : L->opacity; blendMap(L->buffer(), outputBuffer_, L->lut(), L->channelsPerLight(), @@ -478,7 +478,7 @@ class Drivers : public MoonModule { void quiesce() override { if (!quiesceEncode()) stopEncodeTask(); } private: - Layers* layers_ = nullptr; // bound container; layer_ re-resolved from it at prepareTree + Effects* effects_ = nullptr; // bound container; layer_ re-resolved from it at prepareTree Layer* layer_ = nullptr; Buffer outputBuffer_; @@ -626,8 +626,8 @@ class Drivers : public MoonModule { // while the split encodes from outputBuffer_ would output a stale frame. // The source is the first *enabled* layer, never the disabled fallback activeLayer() returns // when all layers are off β€” with no enabled layer buf stays null and every driver idles (its - // last frame is not re-sent). A pinned setLayer() (layers_ null) is always the live source. - Layer* const out = layers_ ? layers_->firstEnabledLayer() : layer_; + // last frame is not re-sent). A pinned setLayer() (effects_ null) is always the live source. + Layer* const out = effects_ ? effects_->firstEnabledLayer() : layer_; Buffer* buf = out ? (outputBuffer_.data() ? &outputBuffer_ : &out->buffer()) : nullptr; for (uint8_t i = 0; i < childCount(); i++) { diff --git a/src/light/layers/BlendMap.h b/src/light/layers/BlendMap.h index 856744b2..57349101 100644 --- a/src/light/layers/BlendMap.h +++ b/src/light/layers/BlendMap.h @@ -89,6 +89,21 @@ inline void blendMap(const Buffer& src, Buffer& dst, const MappingLUT& lut, const nrOfLightsType logCount = lut.logicalCount(); const bool full = (opacity == 255); + // How many whole lights each buffer actually holds. Every mapped access below is bounded by + // these, because a LUT entry is only valid against the buffer it was BUILT for. + // + // A reshape rebuilds the mapping and the driver's output buffer in separate steps of the same + // prepareTree sweep (Layouts, then the Layer, then Drivers), and a render tick can land between + // them β€” with the new mapping's physical indices and the old, smaller buffer. Unbounded, that + // writes past the end and corrupts the heap; the failure then surfaces later in an unrelated + // allocation, which is what made resizing a layout look intermittently fatal. The identity path + // above has always clamped to min(src, dst) for the same reason; the mapped path did not. + // + // This is a bound, not a fix for the ordering β€” the window is still there and the frame drawn + // inside it is briefly wrong. It cannot corrupt memory, which is the property that matters. + const size_t dstLights = channelsPerLight ? dst.bytes() / channelsPerLight : 0; + const size_t srcLights = channelsPerLight ? src.bytes() / channelsPerLight : 0; + // Overwrite is the default op (single layer / bottom of a composite). It // defers to the LUT's own overwrites() flag: a mapping where each physical // cell is written once (mirror, shuffle, sparse boxβ†’driver) plain-copies; @@ -102,8 +117,10 @@ inline void blendMap(const Buffer& src, Buffer& dst, const MappingLUT& lut, // --- Plain overwrite (replace) β€” single-write LUT; copy, no read-back. --- if (op == BlendOp::Overwrite && full && lut.overwrites()) { for (nrOfLightsType li = 0; li < logCount; li++) { + if (li >= srcLights) break; const uint8_t* srcLight = src.data() + static_cast(li) * channelsPerLight; lut.forEachDestination(li, [&](nrOfLightsType physIdx) { + if (physIdx >= dstLights) return; uint8_t* dstLight = dst.data() + static_cast(physIdx) * channelsPerLight; for (uint8_t c = 0; c < channelsPerLight; c++) dstLight[c] = srcLight[c]; }); @@ -114,8 +131,10 @@ inline void blendMap(const Buffer& src, Buffer& dst, const MappingLUT& lut, // --- Additive with clamp; opacity scales the source. full-opacity skips the scale. --- if (effectiveAdditive) { for (nrOfLightsType li = 0; li < logCount; li++) { + if (li >= srcLights) break; const uint8_t* srcLight = src.data() + static_cast(li) * channelsPerLight; lut.forEachDestination(li, [&](nrOfLightsType physIdx) { + if (physIdx >= dstLights) return; uint8_t* dstLight = dst.data() + static_cast(physIdx) * channelsPerLight; for (uint8_t c = 0; c < channelsPerLight; c++) { uint16_t s = full ? srcLight[c] : div255(static_cast(srcLight[c]) * opacity); @@ -130,8 +149,10 @@ inline void blendMap(const Buffer& src, Buffer& dst, const MappingLUT& lut, // --- Alpha (over): dst = src*Ξ± + dst*(255-Ξ±). full-opacity collapses to overwrite. --- const uint16_t inv = static_cast(255 - opacity); for (nrOfLightsType li = 0; li < logCount; li++) { + if (li >= srcLights) break; const uint8_t* srcLight = src.data() + static_cast(li) * channelsPerLight; lut.forEachDestination(li, [&](nrOfLightsType physIdx) { + if (physIdx >= dstLights) return; uint8_t* dstLight = dst.data() + static_cast(physIdx) * channelsPerLight; for (uint8_t c = 0; c < channelsPerLight; c++) { if (full) { dstLight[c] = srcLight[c]; continue; } diff --git a/src/light/layers/Layers.h b/src/light/layers/Effects.h similarity index 85% rename from src/light/layers/Layers.h rename to src/light/layers/Effects.h index 4b2032a2..68ba922e 100644 --- a/src/light/layers/Layers.h +++ b/src/light/layers/Effects.h @@ -11,11 +11,11 @@ namespace mm { /// /// **Why a container:** multi-layer composition (alpha-blend, additive, layered overlays) needs a place to walk every layer in order so drivers can merge their buffers before consuming the result. With one child Layer this is a thin pass-through: tick() runs the child Layer's tick() in order; behaviour matches the single-Layer pipeline byte-for-byte (Drivers takes its single-layer fast path). /// -/// **No buffer of its own:** each Layer owns its buffer and the `Drivers` container owns the composited output. Layers wires the shared `Layouts` into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer` (the first enabled child, or a disabled one as fallback) answers physical dimensions and feeds the single-layer fast path, and `forEachEnabledLayer` walks the enabled children in container order (bottomβ†’top) marking the bottom layer that clears the buffer. `enabledLayerCount` lets Drivers pick the fast path (one enabled layer β†’ hand its buffer straight to the driver) versus the composite path (β‰₯2 β†’ blend into the output buffer). +/// **No buffer of its own:** each Layer owns its buffer and the `Drivers` container owns the composited output. Effects wires the shared `Layouts` into every child so each can size its buffer. Two queries serve the Drivers compositor: `activeLayer` (the first enabled child, or a disabled one as fallback) answers physical dimensions and feeds the single-layer fast path, and `forEachEnabledLayer` walks the enabled children in container order (bottomβ†’top) marking the bottom layer that clears the buffer. `enabledLayerCount` lets Drivers pick the fast path (one enabled layer β†’ hand its buffer straight to the driver) versus the composite path (β‰₯2 β†’ blend into the output buffer). /// -/// **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). +/// @card Effects.png +class Effects : public MoonModule { public: const char* acceptsChildRoles() const override { return "layer"; } @@ -43,9 +43,9 @@ class Layers : public MoonModule { } /// Role-filtered loop propagation: only tick children that are Layers. - /// The factory / UI shouldn't allow non-Layer children of a Layers + /// The factory / UI shouldn't allow non-Layer children of an Effects /// container, but if one slips in (test fixture, hand-crafted config), - /// ticking it through Layers would run its loop at the wrong tree + /// ticking it through Effects would run its loop at the wrong tree /// depth (an Effect that should be ticked inside a Layer). Matches /// the role-filter precedent in setLayouts / activeLayer above. void tick() MM_NONBLOCKING override { diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index d4e42aa1..70034a47 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -14,7 +14,7 @@ namespace mm { -/// A `Layer` MoonModule (role `ModuleRole::Layer`, child of the `Layers` container) owns a buffer, a mapping LUT, an ordered effect list, and an ordered modifier list, and references the shared `Layouts` that describes the physical topology. +/// A `Layer` MoonModule (role `ModuleRole::Layer`, child of the `Effects` container) owns a buffer, a mapping LUT, an ordered effect list, and an ordered modifier list, and references the shared `Layouts` that describes the physical topology. /// /// **Ownership:** a `Buffer` (logical light data, sized to the logical box); a `MappingLUT` (logical lights to physical positions); effects (write lights into the buffer, dynamic heap-grown list, no fixed max); modifiers (transform the LUT or light values, same dynamic list). /// @@ -474,8 +474,9 @@ class Layer : public MoonModule { Coord3D logical; nrOfLightsType logicalCount; // final box, for the flatten + guard nrOfLightsType* counts; // pass A: per-cell count. pass B: per-cell write cursor. nrOfLightsType* dests; // pass B only. + nrOfLightsType destCap; // what dests actually holds β€” pass B must not exceed it. bool scatter; - } fctx{this, logical, logicalCount, counts, dests, /*scatter=*/false}; + } fctx{this, logical, logicalCount, counts, dests, driverCount, /*scatter=*/false}; auto onCoord = [](void* c, nrOfLightsType driverIdx, lengthType x, lengthType y, lengthType z) { auto* f = static_cast(c); @@ -494,8 +495,20 @@ class Layer : public MoonModule { static_cast(pos.y) * static_cast(f->logical.x) + static_cast(pos.x); if (li >= f->logicalCount) return; // defensive - if (f->scatter) f->dests[f->counts[li]++] = driverIdx; // pass B: write at the cursor - else f->counts[li]++; // pass A: bump the count + // Pass B writes where pass A counted β€” safe only while both passes see the SAME + // coordinates. A scripted layout compiles lazily inside forEachCoord, so a control + // edited between the two passes makes pass B emit more lights than pass A counted and + // the scatter runs past dests. That corrupts the heap; the failure then surfaces in an + // unrelated allocation, which is what made resizing a scripted layout crash at random. + // The bound makes a disagreement cost a dropped destination, never memory. + if (f->scatter) { + const nrOfLightsType slot = f->counts[li]; + if (slot >= f->destCap) return; + f->dests[slot] = driverIdx; + f->counts[li]++; + } else { + f->counts[li]++; // pass A: bump the count + } }; // A GAP (black pixel) is DROPPED from the LUT: its physical slot is already counted in diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index 2bc5473d..103790c5 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -9,7 +9,7 @@ namespace mm { -/// Top-level container for one or more `LayoutBase` children β€” it defines the physical light topology of the installation and is shared by every Layer in the `Layers` container (one Layouts describing the physical setup, multiple Layers render into it). +/// Top-level container for one or more `LayoutBase` children β€” it defines the physical light topology of the installation and is shared by every Layer in the `Effects` container (one Layouts describing the physical setup, multiple Effects render into it). /// /// **Coordinate iteration is owned by the container, not the layer:** `forEachCoord` walks every enabled child layout's coordinates in registration order, offsetting physical indices so multiple layouts (for example 16 strips making one panel) stitch into a single flat physical address space without overlap. A Layer *uses* those coordinates to build its LUT. `totalLightCount` (the sum across enabled children) sizes both the layer buffer and the driver output buffer. /// @@ -25,7 +25,7 @@ class Layouts : public MoonModule { /// Sum of `lightCount` across enabled children β€” sizes the layer buffer and the /// driver output buffer. Disabled children are skipped, the same gate - /// Layer/Layers/Drivers apply to their children. Indices of subsequent enabled + /// Layer/Effects/Drivers apply to their children. Indices of subsequent enabled /// layouts shift down to close the gap β€” disable Layout A and Layout B's lights /// move to indices 0..N. Users who need a stable index-to-fixture mapping disable /// the driver, not the layout. diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index 898ce6b7..161aa712 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -1,9 +1,15 @@ #pragma once +#include + #include "core/moonlive/MoonLiveBuiltins.h" +#include "core/moonlive/MoonLiveIr.h" // kArg3 β€” the register `t` is passed in #include +#include "core/math8.h" // beatsin16 β€” the shared time vocabulary +#include "core/math16.h" // beat16 / triwave16 β€” full-range waveforms + // MoonLive β€” the LIGHT-DOMAIN built-in registration. This is the only place the LED vocabulary // lives: the function NAMES (`setRGB`, `fill`, `random16`), their arg counts, and the meaning // of the inline opcodes (StoreElem = an RGB pixel write, FillElems = fill every light). The core @@ -16,22 +22,246 @@ namespace mm::moonlive { // random16(n) β†’ a pseudo-random value in [0, n). A simple LCG, deterministic enough that the // runtime Bounds guard always sees an in-range index; the same implementation on every target // so a script behaves identically. The one host helper exposed as a Call so far. -extern "C" inline uint32_t mm_light_random16(uint32_t n) { +extern "C" inline uint32_t mm_light_random16(uint32_t n, uint32_t, uint32_t) { static uint32_t s = 0x2545F491u; s = s * 1664525u + 1013904223u; return n ? (s >> 16) % n : 0u; } +// mod(a, b) β†’ a % b, the wrap a cyclic animation needs. `t` grows without bound, so every effect +// that repeats has to fold it back into a range: `mod(t * speed, width)` is a sweep that returns to +// the start instead of running off the end once and never coming back. +// +// A Call rather than an operator because no ISA here has a cheap integer divide β€” Xtensa has none at +// all, and emitting a division routine inline would cost more code than the whole script. One host +// function, called like any other builtin, keeps the emitted code small and the three backends +// identical. b == 0 returns 0 rather than trapping: a script must degrade, never fault. +extern "C" inline uint32_t mm_light_mod(uint32_t a, uint32_t b, uint32_t) { + return b ? a % b : 0u; +} + +// beat(bpm) / beatsin(bpm, low, high) β†’ the TIME vocabulary an animation is actually written in. +// +// An effect does not think in milliseconds, it thinks in beats: `beat` is a rising sawtooth at a +// given BPM, `beatsin` a sine oscillating between two bounds. Both wrap math8.h's beat8/beatsin16 β€” +// the same functions the compiled effects use (GEQ3D, FreqSaws, Lines) with the same FastLED +// semantics, so a script writes what an effect writer writes. +// +// SIXTEEN bit, not eight. A script's values are 32-bit, so an 8-bit beat would throw away range for +// nothing and cap a sweep at 255 β€” short of the 128x128 walls this drives, and short of what +// LinesEffect itself computes (a 16-bit beat scaled by the axis length). The full-scale range means +// `beat(30) * width` and a shift is the sweep position on ANY fixture size. +// +// `ms` is an explicit argument β€” a script writes `beat(30, t)`. Threading the clock implicitly was +// tried and is worse: a Call receives exactly the arguments the script names, so an implicit `ms` +// arrives as zero and the animation silently stands still. Explicit also matches the C++ signature +// (beat16(bpm, ms)), so a script and an effect read the same. The modulo and divide these need live +// in the host function, which is why they are Calls β€” no ISA here has a cheap integer divide. +extern "C" inline uint32_t mm_light_beat(uint32_t bpm, uint32_t ms, uint32_t) { + return beat16(static_cast(bpm), ms); +} +extern "C" inline uint32_t mm_light_beatsin(uint32_t bpm, uint32_t ms, uint32_t high) { + // low is 0 and high is the caller's: a Call carries three arguments and bpm + ms take two, so + // the common "oscillate from 0 up to N" form is the one exposed rather than a packed pair. + return beatsin16(static_cast(bpm), ms, 0, static_cast(high)); +} + +// scale(value, n) β†’ map a 0..65535 value onto 0..n-1. The other half of `beat`: a beat is full-scale +// by design so it is fixture-independent, and this is what lands it on an actual axis. `beat(30, t)` +// then `scale(…, width)` is the sweep position, which is exactly what LinesEffect computes +// (`beat * n / 65536`) β€” including the detail that it REACHES n-1, where the naive `/ 65535` form +// truncates one short and the last column never lights. +// sin(angle) / cos(angle) β€” the full-turn wave, angle 0..65535 for one revolution. +// +// math16's sin16/cos16 return SIGNED -32768..32767; a script's values are unsigned, so the result +// is biased into 0..65535 with the zero line at 32768. A script that wants a coordinate scales the +// result: `scale(sin(a), width)` sweeps the whole axis, which is the same `scale` a beat uses. +extern "C" inline uint32_t mm_light_sin(uint32_t angle, uint32_t, uint32_t) { + return static_cast(sin16(static_cast(angle)) + 32768); +} +extern "C" inline uint32_t mm_light_cos(uint32_t angle, uint32_t, uint32_t) { + return static_cast(cos16(static_cast(angle)) + 32768); +} + +// turn(n) β†’ the angle step that divides one full revolution into n parts. A full turn is 65536 β€” +// one past the largest number a script can write β€” so even with a divide operator the expression +// could not be spelled. A circle therefore needs this as a builtin rather than as arithmetic. +extern "C" inline uint32_t mm_light_turn(uint32_t n, uint32_t, uint32_t) { + return n ? 65536u / n : 0u; +} + +extern "C" inline uint32_t mm_light_scale(uint32_t value, uint32_t n, uint32_t) { + return (value * n) >> 16; +} + +// print(v) β†’ write one value to the serial log, and return it so `print` can be dropped into an +// expression without changing what it computes (`setXYZ(0, print(x), y, z)` still stores x). +// +// This is the only way to see INSIDE a running script. A script that compiles cleanly and produces +// a black fixture gives no other clue: every part reports success and the result is simply wrong. +// That case cost a long debugging session before this existed. +// +// **Rate-limited, because the call sites are per-light.** A modifier's script runs once per light +// per mapping rebuild β€” 16,384 times on a 128x128 wall. Printing all of them would flood the serial +// line, stall the render (a UART write blocks) and bury the first values, which are the useful +// ones. So a burst is capped and the rest are counted, not printed: the tail of a flood tells you +// nothing the head did not. +/// The remaining print budget. A binding resets it when it compiles, so every edit of a script gets +/// a fresh window β€” without that, one burst silences the debugging tool for the life of the process, +/// which is exactly when a second look at a misbehaving script is most needed. +inline uint32_t& printBudget() { static uint32_t n = 0; return n; } + +/// Grant a fresh burst. Call from the binding's prepare(), alongside the compile. +/// +/// print() writes to serial, which blocks, and an effect script runs on the render tick β€” so the +/// burst is what bounds the cost: a handful of writes per compile, after which the call is a compare +/// and a return. Draining through a queue would take the last of it off the tick; backlogged. +inline void resetPrintBudget() { printBudget() = 32; } + +extern "C" inline uint32_t mm_light_print(uint32_t v, uint32_t, uint32_t) { + uint32_t& left = printBudget(); + if (left > 0) { + std::printf("[script] %u\n", static_cast(v)); + if (--left == 0) std::printf("[script] (burst spent; edit the script for a fresh one)\n"); + } + return v; +} + +// addLight(x, y, z) β†’ place one light at a position. The call a scripted LAYOUT is built on. +// +// A layout cannot write into a buffer the way an effect does: it does not know how many lights it +// will place until it has placed them, and on a classic ESP32 a 16k-light fixture would need 48 KB +// of coordinate staging β€” memory that board does not have. So the script CALLS OUT instead, once +// per light, and the host decides what to do with each: count it on the sizing pass, emit it into +// the consumer's sink on the walk. Nothing is stored. +// +// The active sink is set by the binding around each run. Outside a run it is null and a call is +// ignored β€” a script that reaches addLight from an effect places nothing rather than corrupting +// something. +using AddLightFn = void (*)(void* ctx, uint16_t x, uint16_t y, uint16_t z); + +/// THREAD_LOCAL, not one global: the sink belongs to whichever thread is running a script, and more +/// than one does. A layout is asked for its light count and its coordinates from the HTTP task when a +/// control is edited, while the render task walks the same layout for the frame β€” as one global, one +/// thread cleared the sink while the other was mid-run and the built-in called through a live +/// function pointer with a null context. That is a null dereference on the render core, seen as an +/// intermittent crash while resizing a scripted layout. +/// +/// The function and the context are ONE struct so they cannot be observed half-updated. Same shape +/// as the WDT subscription flag in the ESP32 worker, which had the same bug for the same reason. +struct AddLightSink { AddLightFn fn = nullptr; void* ctx = nullptr; }; +inline AddLightSink& addLightSink() { static thread_local AddLightSink s; return s; } + +/// Point addLight at a consumer for the duration of one run; pass nullptr to detach. +inline void setAddLightSink(AddLightFn fn, void* ctx) { addLightSink() = {fn, ctx}; } + +extern "C" inline uint32_t mm_light_addLight(uint32_t x, uint32_t y, uint32_t z) { + // Both halves checked: a sink is only ever installed as a pair, but a context of null with a live + // function is exactly what the crash was, so the guard states the whole precondition. + const AddLightSink s = addLightSink(); + if (s.fn && s.ctx) + s.fn(s.ctx, static_cast(x), static_cast(y), static_cast(z)); + return 0; +} + +// The light-domain SYSTEM VARIABLES: names the host defines and a script may only read. Reserved, +// so a script cannot declare one and a name means the same thing in every script. +// +// `t` is an argument register (free to read); the rest are arena slots the BINDING writes each +// frame from the layer it renders into. Their offsets are fixed constants above the script's +// control range (see kMaxCtrls) β€” a binding caches these slot pointers, so they must never move. +// +// Adding one is a single line here plus the binding writing its slot. +enum : uint8_t { + kSysWidth = kMaxCtrls + 0, + kSysHeight = kMaxCtrls + 1, + kSysDepth = kMaxCtrls + 2, + kSysX = kMaxCtrls + 3, + kSysY = kMaxCtrls + 4, + kSysZ = kMaxCtrls + 5, +}; + +/// The system variables a light script can read. Each binding registers the names it actually +/// WRITES, so an unwritten name stays unknown rather than reading a silent 0 β€” a script that asks +/// for something its host never supplies gets a compile error naming it, which is the honest answer. +/// +/// Registering is also what RESERVES the name: a script cannot declare a control or a loop variable +/// that shadows one. Keeping the lists tight is therefore what leaves `x` and `y` usable as ordinary +/// loop counters in the two bindings that have no coordinate to hand out. +/// +/// Adding one is a single line here plus the binding writing its slot. + +/// `t` alone β€” every script animates, so every list starts here. +inline void addClock(SysVarTable& t) { + // Elapsed milliseconds, passed in kArg3 on every run. An argument register, so it costs no + // instruction and no arena byte. + t.add({"t", SysVarKind::Arg, kArg3}); +} + +/// A LAYOUT: the clock, and nothing else. It is upstream of the logical grid β€” it contributes the +/// physical coordinates that several layouts together bound (architecture.md Β§ Layouts) β€” so there +/// is no size to hand it, and it names its own controls (`cols`, `radius`). +inline SysVarTable layoutSysVars() { + SysVarTable t; + addClock(t); + return t; +} + +/// An EFFECT: the logical grid it renders into. The Layer derives width/height/depth from the +/// layouts and its modifier chain and writes them each tick; an effect is TOLD its canvas rather +/// than declaring it, because a size restated as a control is a second answer that can disagree. +inline SysVarTable effectSysVars() { + SysVarTable t; + addClock(t); + t.add({"width", SysVarKind::Arena, kSysWidth}); + t.add({"height", SysVarKind::Arena, kSysHeight}); + t.add({"depth", SysVarKind::Arena, kSysDepth}); + return t; +} + +/// A MODIFIER: the grid, plus the coordinate of the light being folded, which the binding writes +/// per call. This is the only binding that supplies x/y/z. +inline SysVarTable modifierSysVars() { + SysVarTable t = effectSysVars(); + t.add({"x", SysVarKind::Arena, kSysX}); + t.add({"y", SysVarKind::Arena, kSysY}); + t.add({"z", SysVarKind::Arena, kSysZ}); + return t; +} + // The light-domain built-in table the binding injects into the compiler. setRGB and fill are // Inline (they lower to stores β€” the hot-path writers, no per-call cost); random16 is a Call. inline BuiltinTable lightBuiltins() { BuiltinTable t; // setRGB(index, r, g, b) β†’ write one pixel (bounds-guarded). Inline op StoreElem. t.add({"setRGB", 4, /*returns*/ false, BuiltinKind::Inline, nullptr, InlineOp::StoreElem}); + // setXYZ(index, x, y, z) β†’ write one POSITION (bounds-guarded). The same StoreElem as + // setRGB: three values at index * stride. What differs is the destination the binding hands + // run() β€” a colour buffer for an effect, a coordinate for a modifier β€” so one op serves both + // and the engine stays free of any notion of what the three bytes mean. + t.add({"setXYZ", 4, /*returns*/ false, BuiltinKind::Inline, nullptr, InlineOp::StoreElem}); // fill(r, g, b) β†’ write every light. Inline op FillElems. t.add({"fill", 3, false, BuiltinKind::Inline, nullptr, InlineOp::FillElems}); + // mod(value, limit) β†’ value % limit. The wrap every cyclic animation needs; see above. + t.add({"mod", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_mod, {}}); + // beat(bpm, t) β†’ 0..65535 sawtooth at bpm. The clock an animation is written against. + t.add({"beat", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_beat, {}}); + // beatsin(bpm, t, high) β†’ a sine 0..high at bpm. The same shape an effect reaches for. + t.add({"beatsin", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_beatsin, {}}); + // scale(value, n) β†’ a 0..65535 value onto 0..n-1. Lands a beat on an axis. + t.add({"scale", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_scale, {}}); + // turn(n) β†’ one revolution split n ways, for stepping a circle. + t.add({"turn", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_turn, {}}); // random16(n) β†’ a value in [0,n). A Call to the host helper (typed fn pointer). + // sin(angle) / cos(angle) β†’ the circle. One turn is 0..65535, so a loop over N points steps + // by 65536/N; the result is biased unsigned (see above). + t.add({"sin", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_sin, {}}); + t.add({"cos", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_cos, {}}); t.add({"random16", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_random16, {}}); + // print(v) β†’ log v and return it. The script-level debugger. + t.add({"print", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_print, {}}); + // addLight(x, y, z) β†’ place a light. A scripted layout's whole vocabulary. + t.add({"addLight", 3, /*returns*/ false, BuiltinKind::Call, &mm_light_addLight, {}}); return t; } diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 0280ee95..837b755d 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -4,6 +4,7 @@ #include "core/moonlive/MoonLive.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include +#include // MoonLiveEffect β€” a scripted effect rendered by the MoonLive engine (Β§3.3 of // livescripts-analysis-top-down.md). The thin binding side of the engine/binding seam: it @@ -31,6 +32,8 @@ class MoonLiveEffect : public EffectBase { // recompiles (the script-editor loop), which re-derives the control set. void defineControls() override { controls_.addTextArea("source", source_, sizeof(source_)); + // Every control the script declared. System variables (`width`, `height`, `depth`, `t`) + // are not controls and never appear here, so there is nothing to filter out. uint8_t n = 0; const moonlive::DeclaredControl* decls = engine_.declaredControls(n); for (uint8_t i = 0; i < n; i++) { @@ -59,7 +62,13 @@ class MoonLiveEffect : public EffectBase { // dark, the device keeps running (robustness + no-reboot). A *source* edit re-enters here and // recompiles, so a new script swaps in live; a broken edit just shows its diagnostic. void prepare() override { - if (engine_.compile(source_, moonlive::lightBuiltins())) { + // The script compiles as written. `width`/`height`/`depth` are SYSTEM VARIABLES the light + // domain defines (effectSysVars) β€” an effect is not told its size by a user, it renders into + // whatever layer it sits in, and the layer already knows. A script declaring its own `width` + // would be a second, disagreeing answer: set it to 16 on an 8x8 panel and the effect draws + // off the edge. The compiler reserves the name, so that cannot happen. + moonlive::resetPrintBudget(); + if (engine_.compile(source_, moonlive::lightBuiltins(), moonlive::effectSysVars())) { clearStatus(); } else { setStatus(engine_.error(), Severity::Error); @@ -70,7 +79,7 @@ class MoonLiveEffect : public EffectBase { rebuildControls(); // Report the exec block as the module's heap use (codeCap, the word-rounded allocation), // so the UI card's "+ dynamic" reflects the JIT'd program β€” 0 when the compile failed. - setDynamicBytes(engine_.ok() ? engine_.codeCap() : 0); + setDynamicBytes(engine_.heapBytes()); } void tick() MM_NONBLOCKING override { @@ -79,7 +88,13 @@ class MoonLiveEffect : public EffectBase { // let the last light's +1/+2 write run past the buffer, so a sub-RGB layout renders dark. const auto cpl = channelsPerLight(); if (cpl < 3) return; - if (engine_.ok()) engine_.run(buffer(), nrOfLights(), cpl, elapsed()); + if (!engine_.ok()) return; + // Refresh the system variables before the script runs: a layer can be resized live, and a + // script holding last frame's width would draw to the old geometry. + writeSysVar(moonlive::kSysWidth, width()); + writeSysVar(moonlive::kSysHeight, height()); + writeSysVar(moonlive::kSysDepth, depth()); + engine_.run(buffer(), nrOfLights(), cpl, elapsed()); } void release() override { @@ -87,13 +102,28 @@ class MoonLiveEffect : public EffectBase { EffectBase::release(); } + /// Replace the script. The next prepare() compiles it β€” the same path a UI edit takes, so a + /// test and a user exercise identical code. + void setSource(const char* s) { + if (!s) return; + std::snprintf(source_, sizeof(source_), "%s", s); + } + private: moonlive::MoonLive engine_; // Default script β€” random pixels: each tick lights one random light in a random RGB color. // A live, always-visible starting example (and a good demo-reel slot). The index random16(256) // covers a typical grid; setRGB bounds-guards it (an index past the light count is skipped, and // 0Γ—0 is safe), so most ticks land on a real light and the demo stays visibly lit. - char source_[512] = "setRGB(random16(256), random16(256), random16(256), random16(256));"; + // Publish one system variable into its arena slot, saturating to the uint8 a slot holds β€” a + // layer wider than 255 reports 255 rather than wrapping to a small number and drawing garbage. + void writeSysVar(uint8_t offset, uint16_t value) { + if (uint8_t* slot = engine_.controlSlot(offset)) + *slot = static_cast(value > 255 ? 255 : value); + } + + + char source_[moonlive::kMaxScriptBytes] = "setRGB(random16(256), random16(256), random16(256), random16(256));"; // 512 fits a multi-line // multi-control script (a decl per control + the // statement); grow-on-demand is backlogged for the diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h new file mode 100644 index 00000000..45715e95 --- /dev/null +++ b/src/light/moonlive/MoonLiveLayout.h @@ -0,0 +1,170 @@ +#pragma once + +#include "core/moonlive/MoonLive.h" +#include "light/layouts/LayoutBase.h" +#include "light/moonlive/MoonLiveBuiltins_light.h" +#include +#include + +// MoonLiveLayout β€” a scripted LAYOUT: where the lights physically are, written as text on a running +// device instead of compiled in as a C++ class. +// +// A layout is the one part of the pipeline that differs for every physical build β€” a ring, a spiral +// staircase, a car grille, a costume. Each one has meant a new C++ class, a rebuild and a reflash. +// A script means the person who hung the lights can describe where they went, on the device, and +// see it immediately. +// +// This is the binding that needed the language to grow. A modifier's script transforms ONE +// coordinate because the Layer calls it once per light; a layout has no such per-light call to ride +// on β€” it has to place N lights itself, which takes a loop. That is why `for` landed first. +// +// **It allocates nothing, like every other layout.** `GridLayout` computes its count arithmetically +// and emits straight into the sink; `SphereLayout` walks the same loop twice, counting then +// emitting, "so they never disagree". A scripted layout does exactly the same: the script calls +// `addLight(x, y, z)` per light, and the binding points that call at a counter on the sizing pass +// and at the consumer's sink on the walk. Staging the coordinates in an array instead would cost +// 48 KB on a 16k-light fixture β€” memory a classic ESP32 does not have, and a mechanism no other +// layout uses, which matters because scripted and compiled layouts compose in one `Layouts` +// container and have to behave identically through this interface. +// +// **The count and the coordinates come from the same code.** `lightCount()` runs the script with a +// counting sink; `forEachCoord` runs it again into the caller's. Same script, same arithmetic, so +// the two answers cannot drift apart β€” the property SphereLayout's comment names. + +namespace mm { + +/// Layout whose physical light positions are a live-authored MoonLive script. +class MoonLiveLayout : public LayoutBase { +public: + const char* tags() const override { return "πŸ“"; } // scripted + + void defineControls() override { + controls_.addTextArea("source", source_, sizeof(source_)); + // Every control the SCRIPT declared β€” including any extents it loops over. A layout does not + // RECEIVE a width: the pipeline derives its bounding box from the coordinates the layouts + // actually place (Layouts::prepare, "max coordinate + 1 per axis"), so a width handed in + // from outside would be a second, disagreeing source of truth. A script that wants one + // declares it (`uint8_t width = 16; // @control 1..64`) and it becomes a real slider. + uint8_t n = 0; + const moonlive::DeclaredControl* decls = engine_.declaredControls(n); + for (uint8_t i = 0; i < n; i++) { + uint8_t* slot = engine_.controlSlot(decls[i].offset); + if (!slot) continue; + std::memcpy(ctrlNames_[i], decls[i].name, decls[i].nameLen); + ctrlNames_[i][decls[i].nameLen] = '\0'; + controls_.addUint8(ctrlNames_[i], *slot, decls[i].min, decls[i].max); + } + } + + /// Compile the script. The lights themselves are placed by whoever asks β€” see lightCount(). + void prepare() override { + compile(); + rebuildControls(); + } + + /// Run the script, counting what it places. + /// + /// The Layer sizes its buffer from this before asking for a single coordinate, which is why it + /// cannot come from the walk. Running the script twice is what every other layout does β€” the + /// alternative is caching coordinates, and that is the allocation this design exists to avoid. + nrOfLightsType lightCount() const override { + compile(); + if (!engine_.ok()) return 0; + Counter c{0}; + runScript(&addToCounter, &c); + return c.n; + } + + /// Run the script again, emitting each light into the consumer's sink. + void forEachCoord(const CoordSink& sink) const override { + compile(); + if (!engine_.ok()) return; + Emitter e{&sink, 0}; + runScript(&addToSink, &e); + } + + void release() override { + engine_.free(); + LayoutBase::release(); + } + + /// Replace the script. The next prepare() compiles it β€” the path a UI edit takes. + void setSource(const char* s) { + if (!s) return; + std::snprintf(source_, sizeof(source_), "%s", s); + } + +private: + /// Compile if the source has changed since the program that is loaded. + /// + /// Called from prepare(), and also from lightCount()/forEachCoord β€” because applyState() runs + /// PARENT-FIRST (MoonModule.h): the container computes its bounding box by walking its children + /// before those children have prepared. A layout whose count is arithmetic (GridLayout) does not + /// notice; one that needs a compiled program would report an empty fixture to whoever asked + /// first, and the pipeline would come up dark with no error anywhere. + /// + /// This const_cast is the ONE mechanism a scripted layout needs that a compiled one does not + /// (architecture.md, MoonLive) β€” it exists only because of that prepare ordering. Removing it + /// means letting children prepare before a container aggregates them, which is a core lifecycle + /// change; until then the exception is here, named, rather than spread across the bindings. + void compile() const { + if (engine_.ok() && std::strcmp(source_, compiled_) == 0) return; // already current + auto* self = const_cast(this); + moonlive::resetPrintBudget(); + // A layout is the one script with no layer to ask, so it gets the clock and nothing else: + // it names its own size controls, and `x`/`y` stay free as ordinary loop counters. + if (self->engine_.compile(source_, moonlive::lightBuiltins(), moonlive::layoutSysVars())) + self->clearStatus(); + else self->setStatus(self->engine_.error(), Severity::Error); + std::snprintf(self->compiled_, sizeof(compiled_), "%s", source_); + self->setDynamicBytes(engine_.heapBytes()); + } + + struct Counter { nrOfLightsType n; }; + struct Emitter { const CoordSink* sink; nrOfLightsType idx; }; + + static void addToCounter(void* ctx, uint16_t, uint16_t, uint16_t) { + static_cast(ctx)->n++; + } + static void addToSink(void* ctx, uint16_t x, uint16_t y, uint16_t z) { + auto* e = static_cast(ctx); + e->sink->pixel(e->idx++, static_cast(x), + static_cast(y), static_cast(z)); + } + + /// Point addLight at `fn` and run the script once. + /// + /// The engine writes through a buffer it is handed, but this script writes through addLight + /// instead β€” so it is given a single scratch light, enough to satisfy run()'s "somewhere to + /// write" precondition without staging anything. A script that also calls setXYZ scribbles + /// there harmlessly. + void runScript(moonlive::AddLightFn fn, void* ctx) const { + uint8_t scratch[3] = {0, 0, 0}; + moonlive::setAddLightSink(fn, ctx); + const_cast(engine_).run(scratch, 1, 3, 0); + moonlive::setAddLightSink(nullptr, nullptr); + } + + mutable moonlive::MoonLive engine_; + + // Default script β€” a grid, the layout almost every panel is. The nested loop and the index + // arithmetic are the whole definition, which is the case for scripting a layout at all. + char source_[moonlive::kMaxScriptBytes] = + "uint8_t cols = 16; // @control 1..64\n" + "uint8_t rows = 16; // @control 1..64\n" + "for (y = 0; y < rows; y = y + 1) {\n" + " for (x = 0; x < cols; x = x + 1) {\n" + " addLight(x, y, 0);\n" + " }\n" + "}"; + + // The source the loaded program was built from, so compile() is a no-op when current. + // sizeof(source_), never a literal: a copy too small to hold source_ truncates, never + // compares equal, and the mapping rebuilds every frame β€” the blank-screen loop this + // comparison exists to prevent. + mutable char compiled_[sizeof(source_)] = {}; + + char ctrlNames_[moonlive::kMaxCtrls][moonlive::kMaxControlName] = {}; +}; + +} // namespace mm diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h new file mode 100644 index 00000000..a7e9628e --- /dev/null +++ b/src/light/moonlive/MoonLiveModifier.h @@ -0,0 +1,177 @@ +#pragma once + +#include "core/moonlive/MoonLive.h" +#include "light/modifiers/ModifierBase.h" +#include "light/moonlive/MoonLiveBuiltins_light.h" +#include +#include + +// MoonLiveModifier β€” a scripted MODIFIER: a coordinate transform authored live as a script instead +// of compiled in as a C++ class. +// +// This is the second binding of the MoonLive engine after MoonLiveEffect, and it is what shows the +// engine is domain-neutral: it needed no engine, IR, grammar or backend change. An effect script +// writes a COLOUR per light; a modifier script writes a POSITION. Both are "three values stored at +// an index", which is what the engine's StoreElem already emits. +// +// **The script does not loop, because the Layer already does.** `modifyLogical` is called once per +// physical light by the Layer's fold walk (Layer.h, the mapping build), so a script only ever +// transforms ONE coordinate. That is exactly the shape the grammar has today β€” `call(expr, …)` β€” +// which is why a modifier is the binding that fits the language as it stands. A scripted LAYOUT, by +// contrast, has to place N different positions in a single pass with no per-light call to ride on, +// so it needs a loop the language does not have yet; that is why this comes first. +// +// **How the script reads the coordinate.** `x`, `y` and `z` are system variables the light domain +// defines (`modifierSysVars`), so a bare `x` in an expression compiles to the same LoadCtrl a control +// read uses. Before each call the binding writes the light's position into those arena slots. No +// new IR op β€” the compiler resolves the name, and a script cannot declare one that shadows it. +// +// **Coordinates are bytes, so an axis spans 0..255**, on the way in AND on the way out: a script +// that computes a position past 255 keeps its low byte, so `(width - 1 - x) * 2` on a wide grid +// lands somewhere unintended rather than being discarded. The input guard below rejects an +// out-of-range coordinate before the script sees it; an out-of-range RESULT is the script's own. +// A control slot is one byte, which is the +// price of reusing the control path for inputs. That covers every grid we drive today; a wall +// longer than 255 on one axis (the 48x256 wall is exactly at it) needs the 16-bit element store +// that is backlogged with the same reason. + +namespace mm { + +/// Modifier whose coordinate transform is a live-authored MoonLive script. +class MoonLiveModifier : public ModifierBase { +public: + const char* tags() const override { return "πŸ“"; } // scripted + + void defineControls() override { + controls_.addTextArea("source", source_, sizeof(source_)); + // Every control the script declared. System variables (`x`/`y`/`z`, `width`/`height`/ + // `depth`, `t`) are not controls and never appear here, so there is nothing to filter out. + uint8_t n = 0; + const moonlive::DeclaredControl* decls = engine_.declaredControls(n); + for (uint8_t i = 0; i < n; i++) { + uint8_t* slot = engine_.controlSlot(decls[i].offset); + if (!slot) continue; + std::memcpy(ctrlNames_[i], decls[i].name, decls[i].nameLen); + ctrlNames_[i][decls[i].nameLen] = '\0'; + controls_.addUint8(ctrlNames_[i], *slot, decls[i].min, decls[i].max); + } + } + + /// Compile the script as written. + /// + /// ModifierBase::affectsPrepare returns true for every control, which is right here: a source + /// edit and a scripted-control move both change where lights land, and the Layer has to rebuild + /// its mapping either way. + void prepare() override { + // The script compiles as written. `x`/`y`/`z` (the light being transformed) and + // `width`/`height`/`depth` (the box it sits in) are SYSTEM VARIABLES the light domain + // defines β€” the binding writes their slots per call, and the compiler reserves the names so + // a script cannot declare one and shadow the value it is being handed. + moonlive::resetPrintBudget(); + if (engine_.compile(source_, moonlive::lightBuiltins(), + moonlive::modifierSysVars())) { + clearStatus(); + } else { + setStatus(engine_.error(), Severity::Error); + } + rebuildControls(); + setDynamicBytes(engine_.heapBytes()); + // Ask for a rebuild ONLY when the script actually changed. modifyLogical is the static + // hook β€” it runs while the Layer builds its mapping β€” so an edit is invisible until the + // Layer rebuilds. But the rebuild the Layer performs IS applyState(), which calls prepare() + // again: setting the flag unconditionally makes the two call each other forever, the + // mapping is rebuilt every frame, and the fixture renders nothing at all. Comparing the + // compiled source is what breaks that cycle. + if (std::strcmp(source_, compiled_) != 0) { + std::snprintf(compiled_, sizeof(compiled_), "%s", source_); + needsRebuild_ = true; + } + } + + /// The Layer polls this after ticking its modifiers and rebuilds its mapping once if any asks. + bool consumeNeedsRebuild() override { + const bool r = needsRebuild_; + needsRebuild_ = false; + return r; + } + + /// The Layer hands every modifier the running logical box before it folds any coordinate. + /// Stash it so the script can read `width`/`height`/`depth`. + void modifyLogicalSize(Coord3D& size) override { box_ = size; } + + /// Transform one coordinate. Called by the Layer once per physical light while it builds the + /// mapping β€” the cold path, not per frame. + bool modifyLogical(Coord3D& pos) const override { + if (!engine_.ok()) return true; // a broken script passes coordinates through unchanged + // A control slot is a byte: a coordinate outside 0..255 cannot be represented, so it is + // passed through untransformed rather than silently wrapping to a wrong position. + if (pos.x < 0 || pos.x > 255 || pos.y < 0 || pos.y > 255 || pos.z < 0 || pos.z > 255) + return true; + + auto* self = const_cast(this); + uint8_t* sx = self->engine_.controlSlot(moonlive::kSysX); + uint8_t* sy = self->engine_.controlSlot(moonlive::kSysY); + uint8_t* sz = self->engine_.controlSlot(moonlive::kSysZ); + if (!sx || !sy || !sz) return true; + *sx = static_cast(pos.x); + *sy = static_cast(pos.y); + *sz = static_cast(pos.z); + // The box, clamped into the byte a control slot holds. A grid wider than 255 reports 255, + // which is wrong but bounded β€” and that axis already cannot be scripted at all (the input + // guard above passes it straight through), so no script sees the clamped value. + auto clamp255 = [](lengthType v) { return static_cast(v < 0 ? 0 : (v > 255 ? 255 : v)); }; + if (uint8_t* sw = self->engine_.controlSlot(moonlive::kSysWidth)) *sw = clamp255(box_.x); + if (uint8_t* sh = self->engine_.controlSlot(moonlive::kSysHeight)) *sh = clamp255(box_.y); + if (uint8_t* sd = self->engine_.controlSlot(moonlive::kSysDepth)) *sd = clamp255(box_.z); + + // One light's worth of destination. The script addresses it as index 0 today; the + // index argument is real (setXYZ(index, x, y, z), the same shape as setRGB), so a + // script written against a future `for` loop uses the identical call. + uint8_t out[3] = {*sx, *sy, *sz}; // seeded with the input, so a script that writes + // nothing leaves the coordinate untouched + self->engine_.run(out, 1, 3, 0); + + pos.x = static_cast(out[0]); + pos.y = static_cast(out[1]); + pos.z = static_cast(out[2]); + return true; + } + + void release() override { + engine_.free(); + // Forget what was compiled: release drops the program, so the next prepare() has to be + // treated as a first compile. Keeping it made a disabled-then-re-enabled modifier inert β€” + // the Layer folds while the engine is empty, then prepare() recompiles, sees the same + // source, and never asks for the rebuild that would apply it. + compiled_[0] = '\0'; + ModifierBase::release(); + } + + /// Replace the script. The next prepare() compiles it β€” the same path a UI edit takes, so a + /// test and a user exercise identical code. + void setSource(const char* s) { + if (!s) return; + std::snprintf(source_, sizeof(source_), "%s", s); + } + +private: + mutable moonlive::MoonLive engine_; + + // Default script β€” a mirror on x. Chosen because it is instantly readable on a bench strand + // (the pattern runs the other way) and is a modifier people actually reach for, so a working + // binding looks like something rather than like nothing. + char source_[moonlive::kMaxScriptBytes] = "setXYZ(0, width - 1 - x, y, z);"; + + // The source the CURRENT mapping was built from; a rebuild is needed only when it changes. + // sizeof(source_), never a literal: this is the string compared to decide whether to rebuild, + // so a copy too small to hold source_ truncates, never matches, and the mapping rebuilds every + // frame β€” the blank-screen loop the comparison exists to prevent. + char compiled_[sizeof(source_)] = {}; + + char ctrlNames_[moonlive::kMaxCtrls][moonlive::kMaxControlName] = {}; + + bool needsRebuild_ = false; // a recompile happened; the Layer's mapping is stale + Coord3D box_{0, 0, 0}; // the logical box, from modifyLogicalSize +}; + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 0cd05132..284b5fcc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,5 @@ #include "core/Scheduler.h" -#include "light/layers/Layers.h" +#include "light/layers/Effects.h" #include "light/layouts/GridLayout.h" #include "light/layouts/GridBlacksLayout.h" #include "light/layouts/SphereLayout.h" @@ -25,6 +25,8 @@ #include "light/effects/FireEffect.h" #include "light/effects/ParticlesEffect.h" #include "light/moonlive/MoonLiveEffect.h" +#include "light/moonlive/MoonLiveModifier.h" +#include "light/moonlive/MoonLiveLayout.h" #include "light/effects/SpiralEffect.h" #include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" @@ -155,7 +157,7 @@ static void registerModuleTypes() { // core modules keep a per-module page named for the type. // Containers mm::ModuleFactory::registerType("Layouts", "light/supporting.md#layouts"); - mm::ModuleFactory::registerType("Layers", "light/supporting.md#layers"); + mm::ModuleFactory::registerType("Effects", "light/supporting.md#effects"); mm::ModuleFactory::registerType("Layer", "light/supporting.md#layer"); mm::ModuleFactory::registerType("Drivers", "light/supporting.md#drivers"); mm::ModuleFactory::registerType("LightPresetsModule", "light/supporting.md#lightpresets"); @@ -172,6 +174,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("CarLightsLayout", "light/layouts.md#carlights"); mm::ModuleFactory::registerType("CubeLayout", "light/layouts.md#cube"); mm::ModuleFactory::registerType("HumanSizedCubeLayout", "light/layouts.md#humansizedcube"); + mm::ModuleFactory::registerType("MoonLiveLayout", "light/MoonLiveLayout.md"); mm::ModuleFactory::registerType("PanelsLayout", "light/layouts.md#panels"); mm::ModuleFactory::registerType("TorontoBarGourdsLayout", "light/layouts.md#torontobargourds"); mm::ModuleFactory::registerType("GridLayout", "light/layouts.md#grid"); @@ -245,6 +248,7 @@ static void registerModuleTypes() { // Modifiers β€” alphabetical by display name. mm::ModuleFactory::registerType("BlockModifier", "light/modifiers.md#block"); mm::ModuleFactory::registerType("CheckerboardModifier", "light/modifiers.md#checkerboard"); + mm::ModuleFactory::registerType("MoonLiveModifier", "light/MoonLiveModifier.md"); mm::ModuleFactory::registerType("CircleModifier", "light/modifiers.md#circle"); mm::ModuleFactory::registerType("MirrorModifier", "light/modifiers.md#mirror"); mm::ModuleFactory::registerType("MultiplyModifier", "light/modifiers.md#multiply"); @@ -363,12 +367,12 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { systemModule->addChild(pinsModule); // Services β€” top-level container for user-added capability modules (Audio, IR). - // The core-domain twin of the light domain's Layers/Drivers: a grouping node + // The core-domain twin of the light domain's Effects/Drivers: a grouping node // whose children the user adds/removes at runtime. Added as a root below. auto* servicesModule = static_cast(mm::ModuleFactory::create("Services")); // ControlModule β€” puts the device into a named state. Top-level rather than a Services child - // because a preset reaches ACROSS Layouts/Layers/Drivers/Services, so it cannot live inside one + // because a preset reaches ACROSS Layouts/Effects/Drivers/Services, so it cannot live inside one // of them. Boot-wired: presets are a device capability, not something a user adds. auto* controlModule = static_cast(mm::ModuleFactory::create("ControlModule")); @@ -449,14 +453,14 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { auto* grid = static_cast(mm::ModuleFactory::create("GridLayout")); layouts->addChild(grid); - // Layers: top-level container; one or more layers, each rendering + // Effects: top-level container; one or more layers, each rendering // into its own buffer. Today one Layer with one effect + one modifier. - auto* layersContainer = static_cast(mm::ModuleFactory::create("Layers")); + auto* effectsContainer = static_cast(mm::ModuleFactory::create("Effects")); auto* layer = static_cast(mm::ModuleFactory::create("Layer")); layer->setChannelsPerLight(3); - layersContainer->addChild(layer); + effectsContainer->addChild(layer); // setLayouts wires the shared Layouts to the container AND propagates to every child Layer. - layersContainer->setLayouts(layouts); + effectsContainer->setLayouts(layouts); // One default effect so a bare device (no catalog inject) still shows lights out // of the box β€” but NO default modifier: the boot Layer is just an effect on a @@ -466,13 +470,13 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { layer->addChild(noise); // Drivers: top-level container; one or more Driver children. Bound to the - // Layers container β€” Drivers re-resolves the active Layer from it at every + // Effects container β€” Drivers re-resolves the active Layer from it at every // prepareTree, so a Layer cleared+rebuilt via the API self-heals without // re-running this wiring. Binding the container (not a single Layer) is what // lets a driver read across N Layer buffers from one place β€” the hook // multi-layer blending uses. auto* drivers = static_cast(mm::ModuleFactory::create("Drivers")); - drivers->setLayers(layersContainer); + drivers->setEffects(effectsContainer); // Output drivers (NetworkSend + the LED drivers: RMT / LCD_CAM / Parlio) are // NOT boot-wired. They are added explicitly per board through the catalog @@ -488,7 +492,7 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { // PreviewDriver is the one driver that stays boot-wired: it needs the HTTP // server's WS broadcaster (set below, once httpServer exists), a reference only // main.cpp has and the catalog can't supply. It reads the active Layer (resolved - // by the Drivers container's setLayers above) for the light positions and the + // by the Drivers container's setEffects above) for the light positions and the // sparse buffer it streams; it owns its own scratch buffers. // The light-preset library: a boot-wired singleton under Drivers (child role `preset`). It owns // the named channel-role wirings every driver references by id; exactly one exists, so drivers @@ -529,7 +533,7 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { // network is up), services (the user-added-capability container: Audio, IR β€” placed after // network because a service may use it, e.g. WLED audio sync, and before the light pipeline // so a capability like audio is available to the effects that consume it), light pipeline - // (Layouts β†’ Layers β†’ Drivers), then HTTP. The Scheduler walks roots in this order each + // (Layouts β†’ Effects β†’ Drivers), then HTTP. The Scheduler walks roots in this order each // tick; child propagation happens inside each root. scheduler.addModule(filesystemModule); scheduler.addModule(systemModule); @@ -552,7 +556,7 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { scheduler.addModule(servicesModule); scheduler.addModule(controlModule); scheduler.addModule(layouts); - scheduler.addModule(layersContainer); + scheduler.addModule(effectsContainer); scheduler.addModule(drivers); scheduler.addModule(httpServer); diff --git a/src/platform/desktop/moonlive_asm_host.cpp b/src/platform/desktop/moonlive_asm_host.cpp index 2e9079af..08d6d2e9 100644 --- a/src/platform/desktop/moonlive_asm_host.cpp +++ b/src/platform/desktop/moonlive_asm_host.cpp @@ -16,9 +16,20 @@ namespace mm::moonlive { // the control-values arena pointer, kArg4). R5..R13 = caller-saved scratch x9..x14 then x5..x7. // Index math uses the 64-bit views (xN) for addresses, 32-bit (wN) for counters/colors β€” same // register number, so one map suffices. x15 is the call() address/immediate scratch (not a vreg). -static const uint8_t kArm64Reg[kRegCount] = {0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 14, 5, 6, 7}; +static constexpr uint8_t kArm64Reg[kRegCount] = {0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 14, 5, 6, 7}; static uint8_t mr(Reg r) { return kArm64Reg[r]; } +// A scratch register that is ALSO a vreg silently corrupts values β€” see the RISC-V backend, where +// kScratchFn aliased vreg R12 and every call returned a stale value. Checked here so the map can +// never grow over a scratch. +constexpr bool armScratchOutsideMap() { + constexpr uint8_t scratch[] = {15, 16, 17}; + for (uint8_t r : kArm64Reg) for (uint8_t s : scratch) if (r == s) return false; + return true; +} +static_assert(armScratchOutsideMap(), "a scratch register is also a vreg β€” calls will corrupt it"); + + Label HostAssembler::newLabel() { if (labelCount_ == 0) for (auto& p : labelPos_) p = -1; if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; } // same overflow signal as emit32 @@ -45,8 +56,21 @@ void HostAssembler::emitBytes(const uint8_t* p, size_t n) { std::memcpy(buf_ + len_, p, n); len_ += n; } -void HostAssembler::movImm(Reg d, int32_t imm) { // movz wD, #imm16 - emit32(0x52800000u | ((uint32_t(imm) & 0xffff) << 5) | mr(d)); +void HostAssembler::movImm(Reg d, int32_t imm) { + // movz builds a ZERO-extended 16-bit constant, so a negative immediate would land as its + // unsigned counterpart (-1 as 65535). The compiler emits Const(-1) to express subtraction β€” + // `a - b` is `a + (b * -1)` β€” and a wrapped -1 makes every subtraction correct only modulo 256. + // In a stored colour byte that is invisible; in a bounds-guarded index it silently drops the + // light, and in a host-call argument it is nonsense. movn is the negative form: it writes + // ~imm16, so movn #(~imm) materialises the true negative value. + if (imm < 0) { + // movn writes ~imm16, so it reaches -65536..-1 exactly. Below that the complement no longer + // fits the 16-bit field and the constant would come out wrong in silence. + if (imm < -65536) { overflow_ = true; return; } + emit32(0x12800000u | ((uint32_t(~imm) & 0xffff) << 5) | mr(d)); // movn wD, #~imm16 + return; + } + emit32(0x52800000u | ((uint32_t(imm) & 0xffff) << 5) | mr(d)); // movz wD, #imm16 } void HostAssembler::addImm(Reg d, Reg a, int32_t imm) { // add xD, xA, #imm12 (64-bit) emit32(0x91000000u | ((uint32_t(imm) & 0xfff) << 10) | (mr(a) << 5) | mr(d)); @@ -77,26 +101,40 @@ void HostAssembler::branchIfZero(Reg a, Label l) { // cbz wA, l (offset emit32(0x34000000u | mr(a)); } void HostAssembler::branchIf(Cond c, Label l) { // b.cond l (offset patched) - uint8_t cond = (c == Cond::Lo) ? 0x3 : 0x2; // LO=cc(3), HS=cs(2) + // arm64 condition codes: NE=1, HS/CS=2, LO/CC=3. + const uint8_t cond = (c == Cond::Lo) ? 0x3 : (c == Cond::Ne ? 0x1 : 0x2); addFixup(len_, l, static_cast(1u | (cond << 4))); emit32(0x54000000u | cond); } -void HostAssembler::call(Reg d, Reg a, const void* fn) { +void HostAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { // Preserve EVERY register that may hold a live value across the call: the host args - // (x0/x1/x2), the link register x30 (blr overwrites it; our function is a leaf), and the + // (x0/x1/x2/x3), the link register x30 (blr overwrites it; our function is a leaf), and the // whole vreg scratch pool (x4-x7, x9-x14) β€” because a value computed before the call (e.g. // a first random16's result) can be live across a SECOND call. Saving the full pool makes // the live-vreg-across-call contract hold for any expression; it's a cold path (once per - // call, not per pixel). 112-byte frame (7 pairs) keeps sp 16-aligned. - emit32(0xa9b907e0u); // stp x0, x1, [sp, #-112]! + // call, not per pixel). 128-byte frame (8 pairs) keeps sp 16-aligned. + // + // x3 is kArg3, the elapsed time β€” which scripts now read as the system variable `t`, so a + // built-in clobbering it (legal for any callee under the AAPCS) would be a silent wrong-value + // bug in any animated script that calls anything. Saved before it could become one. It pairs + // with x8, which this backend never uses, because stp works on pairs. + emit32(0xa9b807e0u); // stp x0, x1, [sp, #-128]! emit32(0xa9017be2u); // stp x2, x30, [sp, #16] + emit32(0xa90723e3u); // stp x3, x8, [sp, #112] emit32(0xa90217e4u); // stp x4, x5, [sp, #32] emit32(0xa9031fe6u); // stp x6, x7, [sp, #48] emit32(0xa9042be9u); // stp x9, x10, [sp, #64] emit32(0xa90533ebu); // stp x11,x12, [sp, #80] emit32(0xa9063bedu); // stp x13,x14, [sp, #96] - // arg into x0 (the built-in's first parameter) - emit32(0xaa0003e0u | (uint32_t(mr(a)) << 16)); // mov x0, x + // args into x0/x1/x2 (the built-in's three parameters). Order matters: x0 is written first, + // and a later source register could BE x0 β€” so read the sources before any of them is clobbered + // by moving through a scratch that is outside the vreg pool. + emit32(0xaa0003efu | (uint32_t(mr(a)) << 16)); // mov x15, x + emit32(0xaa0003f0u | (uint32_t(mr(b)) << 16)); // mov x16, x + emit32(0xaa0003f1u | (uint32_t(mr(c)) << 16)); // mov x17, x + emit32(0xaa0f03e0u); // mov x0, x15 + emit32(0xaa1003e1u); // mov x1, x16 + emit32(0xaa1103e2u); // mov x2, x17 // materialise the 64-bit absolute fn address into x15 (movz + 3Γ—movk) uint64_t addr = reinterpret_cast(fn); emit32(0xd2800000u | ((uint32_t(addr) & 0xffff) << 5) | 15); // movz x15, #b0 @@ -108,13 +146,14 @@ void HostAssembler::call(Reg d, Reg a, const void* fn) { // dst register are both in the saved set the restore overwrites. emit32(0xaa0003efu); // mov x15, x0 (result β†’ x15) // restore the full saved set (reverse order) + emit32(0xa94723e3u); // ldp x3, x8, [sp, #112] emit32(0xa9463bedu); // ldp x13,x14, [sp, #96] emit32(0xa94533ebu); // ldp x11,x12, [sp, #80] emit32(0xa9442be9u); // ldp x9, x10, [sp, #64] emit32(0xa9431fe6u); // ldp x6, x7, [sp, #48] emit32(0xa94217e4u); // ldp x4, x5, [sp, #32] emit32(0xa9417be2u); // ldp x2, x30, [sp, #16] - emit32(0xa8c707e0u); // ldp x0, x1, [sp], #112 + emit32(0xa8c807e0u); // ldp x0, x1, [sp], #128 // now move the stashed result into dst (dst is restored/valid; x15 holds the result) emit32(0xaa0f03e0u | uint32_t(mr(d))); // mov x, x15 } diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h index 7e85ba47..31dafe21 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -1,5 +1,7 @@ #pragma once +#include "core/moonlive/MoonLiveIr.h" // kCodeCap β€” one cap for the staging buffer and every backend + #include #include @@ -27,7 +29,7 @@ enum Reg : uint8_t { R0 = 0, R1, R2, R3, R4, R5, R6, R7, R8, R9, using Label = uint8_t; // Branch condition (only the ones the IR needs so far). -enum class Cond : uint8_t { Lo /* unsigned < */, Hs /* unsigned >= */ }; +enum class Cond : uint8_t { Lo /* unsigned < */, Hs /* unsigned >= */, Ne /* != */ }; class HostAssembler { public: @@ -54,16 +56,18 @@ class HostAssembler { void cmp(Reg a, Reg b); // flags = a - b void branchIfZero(Reg a, Label l); // if a == 0 goto l void branchIf(Cond c, Label l); // if flags satisfy c goto l (after cmp) - // Call a host built-in: d = fn(a). Preserves the host-arg registers (R0/R1/R2 = buf, + // Call a host built-in: d = fn(a, b, c). Preserves the host-arg registers (R0/R1/R2 = buf, // nLights, cpl) across the call by saving them on the stack, so they stay live for the // statement after the call β€” the live-vreg-across-Call contract. `fn` is an absolute - // function pointer (materialised into a scratch register). Caller-saved vregs other than - // R0..R2 must not be live across a call (the front-end orders ops so none are). - void call(Reg d, Reg a, const void* fn); + // function pointer (materialised into a scratch register). The implementation saves the WHOLE + // vreg pool, not just R0..R2, so any value may be live across a call β€” a loop counter and its + // limit are, whenever the body calls anything, which is most real effects. + void call(Reg d, Reg a, Reg b, Reg c, const void* fn); void ret(); private: - static constexpr size_t kCap = 768; + // The emitted-code buffer, sized by the engine's shared cap (kCodeCap). + static constexpr size_t kCap = kCodeCap; static constexpr uint8_t kMaxLabels = 16; static constexpr uint8_t kMaxFixups = 32; diff --git a/src/platform/desktop/moonlive_lower_host.cpp b/src/platform/desktop/moonlive_lower_host.cpp index 2a93f02d..b3264516 100644 --- a/src/platform/desktop/moonlive_lower_host.cpp +++ b/src/platform/desktop/moonlive_lower_host.cpp @@ -24,13 +24,34 @@ Reg reg(VReg v) { return static_cast(v); } size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { // Reserve three scratch regs above the program's vregs for the inline ops' temps. - if (!out || cap == 0 || ir.vregsUsed + 3 > kRegCount) return 0; - const Reg sOff = static_cast(ir.vregsUsed); // base byte offset of the current light - const Reg sCtr = static_cast(ir.vregsUsed + 1); // loop counter - const Reg sAddr = static_cast(ir.vregsUsed + 2); // per-channel address (off, off+1, off+2) + // Reserve scratch only for the inline ops this program actually contains. Unlike Xtensa and + // RISC-V, THIS backend's StoreElem needs one scratch too (sAddr β€” it does not fold the address + // into the index vreg), so the two cases differ: FillElems needs three, StoreElem one, neither + // needs any. Reserving the maximum unconditionally cost a register every script paid for. + const uint8_t scratch = ir.hasInline(InlineOp::FillElems) ? 3 + : ir.hasInline(InlineOp::StoreElem) ? 1 : 0; + if (!out || cap == 0 || ir.vregsUsed + scratch > kRegCount) return 0; + // sAddr FIRST, because it is the one StoreElem also uses: a store-only program reserves a single + // scratch, so the shared one has to be the lowest index or it would name a register outside the + // reservation. sOff/sCtr are FillElems-only and sit above it. + const Reg sAddr = static_cast(ir.vregsUsed); // per-channel address (off, off+1, off+2) + const Reg sOff = static_cast(ir.vregsUsed + 1); // base byte offset of the current light + const Reg sCtr = static_cast(ir.vregsUsed + 2); // loop counter HostAssembler a; + // An IR label id becomes an assembler label ON FIRST USE. Allocating the whole range up front + // exhausts the assembler's fixed label table, and the inline ops (StoreElem's bounds guard, + // FillElems' loop) then get nothing when they ask for their own β€” which broke every program + // that contains no loop at all. Lazy allocation costs one lookup and leaves the table for the + // labels a program actually has. + Label labels[kIrLabels]; + bool labelMade[kIrLabels] = {}; + auto labelFor = [&](int32_t id) -> Label { + if (!labelMade[id]) { labels[id] = a.newLabel(); labelMade[id] = true; } + return labels[id]; + }; + for (uint8_t i = 0; i < ir.count; i++) { const IrInst& op = ir.ops[i]; switch (op.op) { @@ -38,10 +59,26 @@ size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { case IrOp::Add: a.addReg(reg(op.dst), reg(op.a), reg(op.b)); break; case IrOp::AddImm: a.addImm(reg(op.dst), reg(op.a), op.imm); break; case IrOp::Mul: a.mulReg(reg(op.dst), reg(op.a), reg(op.b)); break; + case IrOp::Mov: a.addImm(reg(op.dst), reg(op.a), 0); break; // dst = a + 0 + case IrOp::Label: + if (op.imm >= 0 && op.imm < kIrLabels) a.bind(labelFor(op.imm)); + break; + case IrOp::BranchGe: + if (op.imm >= 0 && op.imm < kIrLabels) { + a.cmp(reg(op.a), reg(op.b)); + a.branchIf(Cond::Hs, labelFor(op.imm)); // unsigned >= + } + break; + case IrOp::BranchNe: + if (op.imm >= 0 && op.imm < kIrLabels) { + a.cmp(reg(op.a), reg(op.b)); + a.branchIf(Cond::Ne, labelFor(op.imm)); + } + break; case IrOp::LoadCtrl: a.load8(reg(op.dst), reg(kArg4), op.imm); break; // dst = ctrls[imm] case IrOp::Call: if (!op.callFn) return 0; - a.call(reg(op.dst), reg(op.a), reinterpret_cast(op.callFn)); + a.call(reg(op.dst), reg(op.a), reg(op.b), reg(op.c), reinterpret_cast(op.callFn)); break; case IrOp::Inline: switch (op.inlineOp) { diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index 1b130bae..37ec23d9 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -11,12 +11,28 @@ namespace mm::moonlive { // R0..R4 β†’ a0..a4 (10..14, the host args: buf, nLights, cpl, t, ctrls β€” a4=kArg4 the controls -// arena pointer). R5..R11 β†’ t0,t1,t2,t3,t4,t5,a5 (caller-saved temps). t6(31) and a6(16) are the -// internal scratch (store8 address, call address build), not vregs. -static const uint8_t kRvReg[kRegCount] = {10, 11, 12, 13, 14, 5, 6, 7, 28, 29, 30, 15}; +// arena pointer). R5..R11 β†’ t0,t1,t2,t3,t4,t5,a5 (caller-saved temps). t6(31) is the internal +// scratch (store8 address, call address build + result stash), not a vreg. +static constexpr uint8_t kRvReg[kRegCount] = {10, 11, 12, 13, 14, 5, 6, 7, 28, 29, 30, 15, + 16, 17}; static uint8_t xr(Reg r) { return kRvReg[r]; } +// t6 is the ONLY caller-saved register outside kRvReg, so it is the only safe scratch: every other +// free register is callee-saved (s0/s1, s6..s11) and would have to be preserved. Both uses below are +// transient β€” store8 consumes it within two instructions, and call() finishes with it before any +// store8 can run β€” so one register serves both. static constexpr uint8_t kScratchAddr = 31; // t6 β€” store8 address temp -static constexpr uint8_t kScratchFn = 16; // a6 β€” call address build / result stash +static constexpr uint8_t kScratchFn = 31; // t6 β€” call address build / result stash + +// A scratch register that is ALSO a vreg silently corrupts values. kScratchFn was x16/a6, which is +// kRvReg[12] = vreg R12: call() stashed its result in a6, then the restore loop reloaded x16 from +// the frame and destroyed it, so every call returned R12's stale value. Latent only because it needs +// vregsUsed > 12. Checked here so the map can never grow over a scratch again. +constexpr bool rvScratchOutsideMap() { + constexpr uint8_t scratch[] = {kScratchAddr, kScratchFn}; + for (uint8_t r : kRvReg) for (uint8_t s : scratch) if (r == s) return false; + return true; +} +static_assert(rvScratchOutsideMap(), "a scratch register is also a vreg β€” calls will corrupt it"); void RiscvAssembler::emit32(uint32_t w) { if (len_ + 4 > kCap) { overflow_ = true; return; } @@ -111,30 +127,43 @@ void RiscvAssembler::branchNe(Reg a, Reg b, Label l) { // live across the call must be preserved β€” save the whole pool + ra + the host args around the // call (mirrors the host backend). The fn address is built with lui+addi (the hi/lo split, +1 // to the upper when the low 12 bits' sign bit is set). 64-byte frame, 16-byte aligned. -void RiscvAssembler::call(Reg d, Reg a, const void* fn) { - emit32(encAddi(2, 2, -64)); // addi sp, sp, -64 - emit32(encSw(1, 2, 60)); // sw ra, 60(sp) - // save the host args a0..a3 and all pool temps - static const uint8_t saved[] = {10, 11, 12, 13, 5, 6, 7, 28, 29, 30, 14, 15}; +void RiscvAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { + // 80-byte frame, 16-byte aligned: 14 saved registers (56 bytes), three argument staging slots + // (56/60/64), and ra at 76. Every register the map hands out is saved here, or a value live + // across a call is destroyed and the caller silently computes with rubbish β€” which is why the + // list mirrors kRvReg exactly. + emit32(encAddi(2, 2, -80)); // addi sp, sp, -80 + emit32(encSw(1, 2, 76)); // sw ra, 76(sp) + static const uint8_t saved[] = {10, 11, 12, 13, 14, 5, 6, 7, 28, 29, 30, 15, 16, 17}; int off = 0; for (uint8_t r : saved) { emit32(encSw(r, 2, off)); off += 4; } - // arg into a0 (read BEFORE the address build touches a6) - emit32(encAddi(10, xr(a), 0)); // mv a0, aArg (if aArg==a0, no-op) - // a6 = fn address via lui + addi (hi/lo split) + // The three args into a0/a1/a2 (the standard ABI registers a host built-in reads). Staged + // through the frame first: a source may itself BE a0/a1/a2, so moving them in place could + // overwrite a source a later move still needs. Slots 56/60/64 sit above the saved set (14 + // registers, offsets 0..52) and below ra at 76. + emit32(encSw(xr(a), 2, 56)); + emit32(encSw(xr(b), 2, 60)); + emit32(encSw(xr(c), 2, 64)); + emit32(encLw(10, 2, 56)); // a0 = arg0 + emit32(encLw(11, 2, 60)); // a1 = arg1 + emit32(encLw(12, 2, 64)); // a2 = arg2 + // t6 = fn address via lui + addi (hi/lo split). t6 is caller-saved, so the callee may clobber + // it β€” harmless: it is dead across the call, written before jalr and rewritten after. uint32_t addr = static_cast(reinterpret_cast(fn)); uint32_t hi = (addr + 0x800) >> 12; // round for the sign-extended addi int32_t lo = static_cast(addr) - static_cast(hi << 12); - emit32(encLui(kScratchFn, hi & 0xfffff)); // lui a6, hi - emit32(encAddi(kScratchFn, kScratchFn, lo)); // addi a6, a6, lo - emit32((kScratchFn << 15) | (1 << 7) | 0x67); // jalr ra, a6, 0 - // stash result (a0) in a6 before restoring (a0 is restored to the old buf) - emit32(encAddi(kScratchFn, 10, 0)); // mv a6, a0 + emit32(encLui(kScratchFn, hi & 0xfffff)); // lui t6, hi + emit32(encAddi(kScratchFn, kScratchFn, lo)); // addi t6, t6, lo + emit32((kScratchFn << 15) | (1 << 7) | 0x67); // jalr ra, t6, 0 + // stash the result (a0) in t6 before restoring (a0 is restored to the old buf). t6 is outside + // the saved set, so the restore loop below cannot destroy it. + emit32(encAddi(kScratchFn, 10, 0)); // mv t6, a0 // restore off = 0; for (uint8_t r : saved) { emit32(encLw(r, 2, off)); off += 4; } - emit32(encLw(1, 2, 60)); // lw ra, 60(sp) - emit32(encAddi(2, 2, 64)); // addi sp, sp, 64 - emit32(encAddi(xr(d), kScratchFn, 0)); // mv dst, a6 (the result) + emit32(encLw(1, 2, 76)); // lw ra, 108(sp) + emit32(encAddi(2, 2, 80)); // addi sp, sp, 112 + emit32(encAddi(xr(d), kScratchFn, 0)); // mv dst, t6 (the result) } void RiscvAssembler::ret() { emit32(0x00008067u); } // ret = jalr x0, ra, 0 diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index def9b0a9..fd340fcf 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -1,5 +1,7 @@ #pragma once +#include "core/moonlive/MoonLiveIr.h" // kCodeCap β€” one cap for the staging buffer and every backend + #include #include @@ -13,7 +15,18 @@ namespace mm::moonlive { -enum Reg : uint8_t { R0 = 0, R1, R2, R3, R4, R5, R6, R7, R8, R9, R10, R11, kRegCount }; +// Twelve was the count every backend started with; RISC-V has room for more, and a nested loop +// needs it β€” two loop levels hold four values live, and a three-argument call needs three temps on +// top. Fourteen is what the CALLER-SAVED registers alone provide, and that is the whole map. +// +// It briefly reached eighteen by also mapping x18..x21 (s2..s5) on the reasoning that "the emitted +// routine is a leaf that saves what it uses". It does not: prologue() is empty, so the routine has +// no entry/exit save at all and would have returned to its caller with four callee-saved registers +// clobbered. Giving the routine a prologue would cost every script a save/restore it almost never +// needs; dropping the four costs nothing, since fourteen still exceeds Xtensa's twelve and no +// script measured here uses more than eleven. +enum Reg : uint8_t { R0 = 0, R1, R2, R3, R4, R5, R6, R7, R8, R9, R10, R11, + R12, R13, kRegCount }; using Label = uint8_t; enum class Cond : uint8_t { Lo /* unsigned < */, Hs /* unsigned >= */ }; @@ -38,12 +51,13 @@ class RiscvAssembler { void branchIfZero(Reg a, Label l); // beqz a, l (bge x0, a... use bgeu against x0) void branchGeU(Reg a, Reg b, Label l); // bgeu a, b, l void branchNe(Reg a, Reg b, Label l); // bne a, b, l - void call(Reg d, Reg a, const void* fn); // standard call to a host built-in + void call(Reg d, Reg a, Reg b, Reg c, const void* fn); // standard call to a host built-in void epilogue() { ret(); } void ret(); private: - static constexpr size_t kCap = 768; + // The emitted-code buffer, sized by the engine's shared cap (kCodeCap). + static constexpr size_t kCap = kCodeCap; static constexpr uint8_t kMaxLabels = 16; static constexpr uint8_t kMaxFixups = 32; diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 50b1fc62..d8871edb 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -17,9 +17,20 @@ namespace mm::moonlive { // R0..R3 β†’ a2..a5 (the windowed-ABI args); R4..R11 β†’ a6..a11, a14, a15. a12/a13 are internal // scratch (store8 address, branchIfZero zero-reg, call result stash), so not in the pool. -static const uint8_t kXtReg[kRegCount] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15}; +static constexpr uint8_t kXtReg[kRegCount] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15}; static uint8_t ar(Reg r) { return kXtReg[r]; } +// A scratch register that is ALSO a vreg silently corrupts values β€” see the RISC-V backend, where +// kScratchFn aliased vreg R12 and every call returned a stale value. Checked here so the map can +// never grow over a scratch. +constexpr bool xtScratchOutsideMap() { + constexpr uint8_t scratch[] = {12, 13}; + for (uint8_t r : kXtReg) for (uint8_t s : scratch) if (r == s) return false; + return true; +} +static_assert(xtScratchOutsideMap(), "a scratch register is also a vreg β€” calls will corrupt it"); + + void XtensaAssembler::emit(const uint8_t* p, size_t n) { if (len_ + n > kCap) { overflow_ = true; return; } std::memcpy(buf_ + len_, p, n); len_ += n; @@ -54,8 +65,25 @@ void XtensaAssembler::addFixup(size_t at, Label label) { // a13 is the assembler's reserved scratch (also kZero in branchIfZero); it holds no live vreg. // Single movi for the common 0..255 case. Without this, Const values >255 truncate to 8 bits. void XtensaAssembler::movImm(Reg d, int32_t imm) { - const uint32_t v = static_cast(imm) & 0xffff; const uint8_t dr = ar(d); + // The wide `movi` field is 12-bit SIGNED (-2048..2047), which is the only encoding here that can + // hold a negative constant. The compiler emits Const(-1) to express subtraction β€” `a - b` is + // `a + (b * -1)` β€” and building that through the zero-extended byte path below would materialise + // 65535, making every subtraction correct only modulo 256: invisible in a stored colour byte, + // silently fatal in a bounds-guarded index (the light is dropped) or a host-call argument. + // A negative below the 12-bit field's reach has no encoding here, and falling through to the + // unsigned path below would materialise a different number in silence β€” the failure mode that + // cost this backend a long debugging session. Fail the compile instead. + if (imm < -2048) { overflow_ = true; return; } + if (imm < 0) { + const uint32_t f = static_cast(imm) & 0xfff; + const uint8_t b[3] = {uint8_t((dr << 4) | 0x2), + uint8_t(0xa0 | ((f >> 8) & 0xf)), + uint8_t(f & 0xff)}; + emit(b, 3); // movi aD, #imm12 + return; + } + const uint32_t v = static_cast(imm) & 0xffff; if (v <= 0xff) { const uint8_t b[3] = {uint8_t((dr << 4) | 0x2), 0xa0, uint8_t(v)}; emit(b, 3); @@ -135,14 +163,30 @@ void XtensaAssembler::branchNe(Reg a, Reg b, Label l) { // mirroring the host backend's full-register-save. Cold path (once per call). The 48-byte // frame from prologue() has room at offsets 16/20/28. The 32-bit fn address is built in a8 // byte-by-byte (movi/slli/add) β€” no l32r literal pool. -void XtensaAssembler::call(Reg d, Reg a, const void* fn) { +void XtensaAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { // Save the rotate-out scratch a8, a9, a11 (a10 will carry argβ†’result). auto s32i = [&](uint8_t r, uint8_t off4){ const uint8_t b[3]={uint8_t((r<<4)|2),0x61,off4}; emit(b,3); }; auto l32i = [&](uint8_t r, uint8_t off4){ const uint8_t b[3]={uint8_t((r<<4)|2),0x21,off4}; emit(b,3); }; s32i(8, 4); s32i(9, 5); s32i(11, 7); // [a1+16]=a8, [a1+20]=a9, [a1+28]=a11 - - // arg into a10 (read aArg BEFORE the address build clobbers a8/a9). - emit2(uint16_t((uint32_t(ar(a)) << 8) | (10 << 4) | 0xd)); // mov a10, aArg + // a14/a15 are vregs R10/R11 (kXtReg), and CALL8 rotates the window out from under them β€” so a + // value live across a call in either was destroyed. Reachable on the SHIPPED default: grid.mlv + // is a nested loop (11 vregs, so R10 is in use) whose body calls addLight. The entry frame is 48 + // bytes and call() uses 16/20/24/28, so 32/36 are free. + s32i(14, 8); s32i(15, 9); // [a1+32]=a14, [a1+36]=a15 + + s32i(10, 6); // [a1+24]=a10 β€” a vreg (R8) call8 rotates out + + // The three args into a10/a11/a12 β€” call8 shifts the window by 8, so the callee reads them as + // its a2/a3/a4. Moved HIGH-first (a12, then a11, then a10) so an earlier write cannot clobber a + // source a later one still needs. + // + // argA goes through a13 first. a11 is vreg R9, so argA can BE a11 β€” and writing argB into a11 + // would then destroy argA before the a10 move reads it. High-first ordering alone does not cover + // that case; a13 is outside the vreg map, so staging there does. + emit2(uint16_t((uint32_t(ar(a)) << 8) | (13 << 4) | 0xd)); // mov a13, argA (a13 is scratch) + 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 uint32_t addr = static_cast(reinterpret_cast(fn)); auto moviA8 = [&](uint8_t v){ const uint8_t b[3]={0x82,0xa0,v}; emit(b,3); }; @@ -156,7 +200,8 @@ void XtensaAssembler::call(Reg d, Reg a, const void* fn) { emit3(0x0000e0u | (8u << 8)); // callx8 a8 β†’ result in a10 // stash result (a10) in a12 (not in the saved set), restore a8/a9/a11, then dst = a12. emit2(uint16_t((10u << 8) | (12u << 4) | 0xd)); // mov a12, a10 - l32i(8, 4); l32i(9, 5); l32i(11, 7); + l32i(8, 4); l32i(9, 5); l32i(10, 6); l32i(11, 7); + l32i(14, 8); l32i(15, 9); emit2(uint16_t((12u << 8) | (uint32_t(ar(d)) << 4) | 0xd)); // mov aDst, a12 } diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index 6b920582..5b79f125 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -1,5 +1,7 @@ #pragma once +#include "core/moonlive/MoonLiveIr.h" // kCodeCap β€” one cap for the staging buffer and every backend + #include #include @@ -39,11 +41,12 @@ class XtensaAssembler { void branchIfZero(Reg a, Label l); // beqz aA, l (nLights==0 guard) void branchGeU(Reg a, Reg b, Label l); // bgeu aA, aB, l (Bounds: skip if a>=b) void branchNe(Reg a, Reg b, Label l); // bne aA, aB, l (loop test) - void call(Reg d, Reg a, const void* fn); // windowed call8 to a host built-in + void call(Reg d, Reg a, Reg b, Reg c, const void* fn); // windowed call8 to a host built-in void epilogue(); // retw.n private: - static constexpr size_t kCap = 768; + // The emitted-code buffer, sized by the engine's shared cap (kCodeCap). + static constexpr size_t kCap = kCodeCap; static constexpr uint8_t kMaxLabels = 16; static constexpr uint8_t kMaxFixups = 32; diff --git a/src/platform/esp32/moonlive_lower_riscv.cpp b/src/platform/esp32/moonlive_lower_riscv.cpp index eb4c2a60..00c8cac9 100644 --- a/src/platform/esp32/moonlive_lower_riscv.cpp +++ b/src/platform/esp32/moonlive_lower_riscv.cpp @@ -19,12 +19,33 @@ Reg reg(VReg v) { return static_cast(v); } size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { // StoreElem folds the address into the index vreg (no scratch); FillElems needs two. - if (!out || cap == 0 || ir.vregsUsed + 2 > kRegCount) return 0; - const Reg sCtr = static_cast(ir.vregsUsed); - const Reg sAddr = static_cast(ir.vregsUsed + 1); + // Reserve scratch only for the inline ops this program actually contains: FillElems needs two + // (loop counter + per-channel address), StoreElem one (the address β€” it must NOT be folded into + // the caller's index vreg, which destroys a `for` counter). Reserving the maximum unconditionally + // cost a register every script paid for, and that register is what a nested loop was short of on + // the smallest file. + const uint8_t scratch = ir.hasInline(InlineOp::FillElems) ? 2 + : ir.hasInline(InlineOp::StoreElem) ? 1 : 0; + if (!out || cap == 0 || ir.vregsUsed + scratch > kRegCount) return 0; + // sAddr FIRST: it is the one StoreElem also uses, and a store-only program reserves a single + // scratch β€” so the shared one has to be the lowest index or it would name an unreserved register. + const Reg sAddr = static_cast(ir.vregsUsed); // per-channel address (both ops) + const Reg sCtr = static_cast(ir.vregsUsed + 1); // FillElems loop counter RiscvAssembler a; + // An IR label id becomes an assembler label ON FIRST USE. Allocating the whole range up front + // exhausts the assembler's fixed label table, and the inline ops (StoreElem's bounds guard, + // FillElems' loop) then get nothing when they ask for their own β€” which broke every program + // that contains no loop at all. Lazy allocation costs one lookup and leaves the table for the + // labels a program actually has. + Label labels[kIrLabels]; + bool labelMade[kIrLabels] = {}; + auto labelFor = [&](int32_t id) -> Label { + if (!labelMade[id]) { labels[id] = a.newLabel(); labelMade[id] = true; } + return labels[id]; + }; + for (uint8_t i = 0; i < ir.count; i++) { const IrInst& op = ir.ops[i]; switch (op.op) { @@ -32,22 +53,41 @@ size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { case IrOp::Add: a.addReg(reg(op.dst), reg(op.a), reg(op.b)); break; case IrOp::AddImm: a.addImm(reg(op.dst), reg(op.a), op.imm); break; case IrOp::Mul: a.mulReg(reg(op.dst), reg(op.a), reg(op.b)); break; + // A real register move, NOT add-immediate-zero: Xtensa's addi.n cannot encode 0 β€” + // the ISA reuses that slot for -1 β€” so `dst = a + 0` silently computed a - 1. A loop + // counter initialised through Mov therefore started at -1, the unsigned loop guard saw + // 0xffffffff >= limit, and the body never ran. It compiled, reported no error, and + // placed no lights. + case IrOp::Mov: a.movReg(reg(op.dst), reg(op.a)); break; + case IrOp::Label: + if (op.imm >= 0 && op.imm < kIrLabels) a.bind(labelFor(op.imm)); + break; + case IrOp::BranchGe: + if (op.imm >= 0 && op.imm < kIrLabels) + a.branchGeU(reg(op.a), reg(op.b), labelFor(op.imm)); + break; + case IrOp::BranchNe: + if (op.imm >= 0 && op.imm < kIrLabels) + a.branchNe(reg(op.a), reg(op.b), labelFor(op.imm)); + break; case IrOp::LoadCtrl: a.load8(reg(op.dst), reg(kArg4), op.imm); break; // dst = ctrls[imm] (a4 = kArg4) case IrOp::Call: // The IR carries the host's function pointer (the light TU's random16), valid in // the single flashed image β€” call it directly, same as the other backends. if (!op.callFn) return 0; - a.call(reg(op.dst), reg(op.a), reinterpret_cast(op.callFn)); + a.call(reg(op.dst), reg(op.a), reg(op.b), reg(op.c), reinterpret_cast(op.callFn)); break; case IrOp::Inline: switch (op.inlineOp) { case InlineOp::StoreElem: { Label skip = a.newLabel(); + // The address goes in SCRATCH, not the index vreg: folding it in destroyed + // a `for` counter, which the loop's step and test read again after the store. a.branchGeU(reg(op.a), reg(kArg1), skip); - a.mulReg(reg(op.a), reg(op.a), reg(kArg2)); // index = index*cpl - a.store8(reg(kArg0), reg(op.a), reg(op.b)); - a.addImm(reg(op.a), reg(op.a), 1); a.store8(reg(kArg0), reg(op.a), reg(op.c)); - a.addImm(reg(op.a), reg(op.a), 1); a.store8(reg(kArg0), reg(op.a), reg(op.d)); + a.mulReg(sAddr, reg(op.a), reg(kArg2)); // addr = index * cpl + a.store8(reg(kArg0), sAddr, reg(op.b)); + a.addImm(sAddr, sAddr, 1); a.store8(reg(kArg0), sAddr, reg(op.c)); + a.addImm(sAddr, sAddr, 1); a.store8(reg(kArg0), sAddr, reg(op.d)); a.bind(skip); break; } diff --git a/src/platform/esp32/moonlive_lower_xtensa.cpp b/src/platform/esp32/moonlive_lower_xtensa.cpp index 0f778cb1..9c4224c4 100644 --- a/src/platform/esp32/moonlive_lower_xtensa.cpp +++ b/src/platform/esp32/moonlive_lower_xtensa.cpp @@ -19,15 +19,34 @@ Reg reg(VReg v) { return static_cast(v); } } size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { - // StoreElem needs no scratch (it folds the address into the index vreg); FillElems needs two - // (counter + per-channel addr) above the program's vregs. Reserve the max either uses. - if (!out || cap == 0 || ir.vregsUsed + 2 > kRegCount) return 0; - const Reg sCtr = static_cast(ir.vregsUsed); // FillElems loop counter - const Reg sAddr = static_cast(ir.vregsUsed + 1); // FillElems per-channel address + // Reserve scratch only for the inline ops this program actually contains: FillElems needs two + // (loop counter + per-channel address), StoreElem one (the address β€” it must NOT be folded into + // the caller's index vreg, which destroys a `for` counter). Reserving the maximum unconditionally + // cost a register every script paid for, and that register is what a nested loop was short of on + // the smallest file. + const uint8_t scratch = ir.hasInline(InlineOp::FillElems) ? 2 + : ir.hasInline(InlineOp::StoreElem) ? 1 : 0; + if (!out || cap == 0 || ir.vregsUsed + scratch > kRegCount) return 0; + // sAddr FIRST: it is the one StoreElem also uses, and a store-only program reserves a single + // scratch β€” so the shared one has to be the lowest index or it would name an unreserved register. + const Reg sAddr = static_cast(ir.vregsUsed); // per-channel address (both ops) + const Reg sCtr = static_cast(ir.vregsUsed + 1); // FillElems loop counter XtensaAssembler a; a.prologue(); + // An IR label id becomes an assembler label ON FIRST USE. Allocating the whole range up front + // exhausts the assembler's fixed label table, and the inline ops (StoreElem's bounds guard, + // FillElems' loop) then get nothing when they ask for their own β€” which broke every program + // that contains no loop at all. Lazy allocation costs one lookup and leaves the table for the + // labels a program actually has. + Label labels[kIrLabels]; + bool labelMade[kIrLabels] = {}; + auto labelFor = [&](int32_t id) -> Label { + if (!labelMade[id]) { labels[id] = a.newLabel(); labelMade[id] = true; } + return labels[id]; + }; + for (uint8_t i = 0; i < ir.count; i++) { const IrInst& op = ir.ops[i]; switch (op.op) { @@ -35,22 +54,43 @@ size_t lowerToBytes(const IrProgram& ir, uint8_t* out, size_t cap) { case IrOp::Add: a.addReg(reg(op.dst), reg(op.a), reg(op.b)); break; case IrOp::AddImm: a.addImm(reg(op.dst), reg(op.a), op.imm); break; case IrOp::Mul: a.mulReg(reg(op.dst), reg(op.a), reg(op.b)); break; + // A real register move, NOT add-immediate-zero: Xtensa's addi.n cannot encode 0 β€” + // the ISA reuses that slot for -1 β€” so `dst = a + 0` silently computed a - 1. A loop + // counter initialised through Mov therefore started at -1, the unsigned loop guard saw + // 0xffffffff >= limit, and the body never ran. It compiled, reported no error, and + // placed no lights. + case IrOp::Mov: a.movReg(reg(op.dst), reg(op.a)); break; + case IrOp::Label: + if (op.imm >= 0 && op.imm < kIrLabels) a.bind(labelFor(op.imm)); + break; + case IrOp::BranchGe: + if (op.imm >= 0 && op.imm < kIrLabels) + a.branchGeU(reg(op.a), reg(op.b), labelFor(op.imm)); + break; + case IrOp::BranchNe: + if (op.imm >= 0 && op.imm < kIrLabels) + a.branchNe(reg(op.a), reg(op.b), labelFor(op.imm)); + break; case IrOp::LoadCtrl: a.load8(reg(op.dst), reg(kArg4), op.imm); break; // dst = ctrls[imm] (a6 = kArg4) case IrOp::Call: if (!op.callFn) return 0; - a.call(reg(op.dst), reg(op.a), reinterpret_cast(op.callFn)); + a.call(reg(op.dst), reg(op.a), reg(op.b), reg(op.c), reinterpret_cast(op.callFn)); break; case IrOp::Inline: switch (op.inlineOp) { case InlineOp::StoreElem: { - // setRGB(index=a, r=b, g=c, b=d): bounds-guard, then fold the address - // INTO the index vreg (dead after) so no extra scratch is needed. + // setRGB(index=a, r=b, g=c, b=d): bounds-guard, then build the address in + // SCRATCH. It used to fold into the index vreg, on the assumption that the + // index is dead after the store β€” true for a throwaway temp, false for a + // `for` counter, which the loop's own step and test read again. `setRGB(i,…)` + // inside a loop therefore left the counter holding i*cpl+2 and the loop ran + // the wrong number of times. Label skip = a.newLabel(); a.branchGeU(reg(op.a), reg(kArg1), skip); // index >= nLights β†’ skip - a.mulReg(reg(op.a), reg(op.a), reg(kArg2)); // index = index * cpl (= addr) - a.store8(reg(kArg0), reg(op.a), reg(op.b)); // store r - a.addImm(reg(op.a), reg(op.a), 1); a.store8(reg(kArg0), reg(op.a), reg(op.c)); - a.addImm(reg(op.a), reg(op.a), 1); a.store8(reg(kArg0), reg(op.a), reg(op.d)); + a.mulReg(sAddr, reg(op.a), reg(kArg2)); // addr = index * cpl + a.store8(reg(kArg0), sAddr, reg(op.b)); // store r + a.addImm(sAddr, sAddr, 1); a.store8(reg(kArg0), sAddr, reg(op.c)); + a.addImm(sAddr, sAddr, 1); a.store8(reg(kArg0), sAddr, reg(op.d)); a.bind(skip); break; } diff --git a/src/ui/app.js b/src/ui/app.js index fd03fd9c..d9a8403c 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -884,7 +884,7 @@ function renderChildTabs(mod, childrenEl, depth) { addTab.title = "add " + rolesAcceptedBy(mod).join(" / "); addTab.addEventListener("click", () => { // THIS card's own footer β€” a plain querySelector would match the first .card-footer in the - // subtree, which belongs to a nested child's card (Layers would then offer the Layer's + // subtree, which belongs to a nested child's card (Effects would then offer the Layer's // effects instead of another layer). Scope to direct children of this card. const card = childrenEl.parentElement; const footer = [...card.children].find(el => el.classList.contains("card-footer")); @@ -1026,7 +1026,7 @@ function createCard(mod, depth) { card.appendChild(title); // -- Controls -- - // Child-hosting modules deeper in the tree (Layers, Layer, Drivers, Layouts) + // Child-hosting modules deeper in the tree (Effects, Layer, Drivers, Layouts) // collapse their own controls so the children are the focus by default. // Modules that merely host a code-wired child (Network β†’ Improv) keep their // controls expanded β€” the parent's settings are the main point, the code-wired @@ -1388,7 +1388,7 @@ function allAcceptedChildRoles() { // // We test mod.role against the UNION of all containers' acceptsChildRoles, not // against this module's specific parent. That's exact while the roleβ†’container -// mapping is 1:1 (effectβ†’Layer, driverβ†’Drivers, layoutβ†’Layouts, layerβ†’Layers) β€” +// mapping is 1:1 (effectβ†’Layer, driverβ†’Drivers, layoutβ†’Layouts, layerβ†’Effects) β€” // a child of an add-accepted role is always under the one container that // accepts it. If a role ever becomes accepted by more than one container, this // would need the parent threaded in to scope the check to the actual parent. @@ -2142,7 +2142,7 @@ function buildCaptureToggles(body, moduleName) { const ctrl = mod && (mod.controls || []).find(c => c.name === "captures"); if (!ctrl) return; const names = Array.isArray(ctrl.options) && ctrl.options.length - ? ctrl.options : ["Layouts", "Layers", "Drivers", "Services"]; + ? ctrl.options : ["Layouts", "Effects", "Drivers", "Services"]; const wrap = document.createElement("div"); wrap.className = "surface-popup-captures"; names.forEach((n, i) => { @@ -3135,10 +3135,10 @@ function syncVisibleControls(mod) { const card = document.querySelector(`.card[data-module="${cssEscape(mod.name)}"]`); if (!card) return false; // The controls host is THIS card's own collapse wrapper β€” must be a DIRECT - // child (`:scope >`), not any descendant: a container card (e.g. Layers) nests + // child (`:scope >`), not any descendant: a container card (e.g. Effects) nests // its child cards (Layer) inside .card-children, and a plain // `card.querySelector(".card-controls-collapse")` would reach down and match - // the CHILD's wrapper. That made Layers adopt Layer's control rows as its own, + // the CHILD's wrapper. That made Effects adopt Layer's control rows as its own, // so both cards saw a control-set mismatch every WS frame and rebuilt each // other's rows in a loop β€” tearing down (and closing) any open